diff --git a/src/Notification.zig b/src/Notification.zig index ccfd886f..1dff474f 100644 --- a/src/Notification.zig +++ b/src/Notification.zig @@ -56,6 +56,7 @@ const Allocator = std.mem.Allocator; // proper isolation between different CDP clients while allowing a single client // to receive events from all its tabs. const ModelContextTool = @import("browser/webapi/ModelContext.zig").Tool; +const Cookie = @import("browser/webapi/storage/Cookie.zig"); const Notification = @This(); // Every event type (which are hard-coded), has a list of Listeners. @@ -93,6 +94,7 @@ const EventListeners = struct { runtime_console_message: List = .{}, model_context_tool_added: List = .{}, model_context_tool_removed: List = .{}, + cookie_changed: List = .{}, }; const Events = union(enum) { @@ -118,6 +120,7 @@ const Events = union(enum) { runtime_console_message: *const ConsoleMessage, model_context_tool_added: *const ModelContextToolEvent, model_context_tool_removed: *const ModelContextToolEvent, + cookie_changed: *const CookieChanged, }; const EventType = std.meta.FieldEnum(Events); @@ -236,6 +239,23 @@ pub const ModelContextToolEvent = struct { tool: *const ModelContextTool, }; +// Fired by Cookie.Jar after a mutation. Pointers and slices borrow from the +// dispatcher's stack — listeners must copy whatever they need to retain. +pub const CookieChanged = struct { + kind: Kind, + name: []const u8, + value: []const u8, + // Cookie domain in jar-canonical form (leading "." for explicit Domain + // attribute; bare host otherwise). + domain: []const u8, + path: []const u8, + secure: bool, + http_only: bool, + same_site: Cookie.SameSite, + + pub const Kind = enum { changed, deleted }; +}; + pub const DialogResponse = struct { accept: bool = false, // Set when the CDP client sent a `promptText` with `accept: true`. Memory diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 8e3ceb47..56ff821c 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -379,6 +379,11 @@ pub fn deinit(self: *Frame) void { self._parse_state.deinit(self); + // Unregister CookieStore from session notifications before the JS + // context (and thus the scheduler) is destroyed, otherwise a late + // mutation could schedule a callback that never runs. + if (self.window._cookie_store) |cs| cs.detach(); + const page = self._page; if (self._queued_navigation) |qn| { diff --git a/src/browser/Session.zig b/src/browser/Session.zig index ce8c2470..f6f85347 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -112,7 +112,7 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi .storage_shed = .{}, .browser = browser, .notification = notification, - .cookie_jar = storage.Cookie.Jar.init(allocator), + .cookie_jar = storage.Cookie.Jar.init(allocator, notification), // CLI defaults; LP.configureLoading can flip these per-session. .subframe_loading_enabled = !browser.app.config.disableSubframes(), .worker_loading_enabled = !browser.app.config.disableWorkers(), diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 96c5cd65..2f9a073c 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -959,6 +959,8 @@ pub const PageJsApis = flattenTypes(&.{ @import("../webapi/streams/TransformStream.zig"), @import("../webapi/Node.zig"), @import("../webapi/storage/storage.zig"), + @import("../webapi/storage/CookieStore.zig"), + @import("../webapi/event/CookieChangeEvent.zig"), @import("../webapi/URL.zig"), @import("../webapi/Window.zig"), @import("../webapi/Performance.zig"), @@ -1038,6 +1040,8 @@ pub const WorkerJsApis = flattenTypes(&.{ @import("../webapi/ImageData.zig"), @import("../webapi/Performance.zig"), @import("../webapi/PerformanceObserver.zig"), + @import("../webapi/storage/CookieStore.zig"), + @import("../webapi/event/CookieChangeEvent.zig"), }); // Master list of ALL JS APIs across all contexts. diff --git a/src/browser/tests/cookie_store.html b/src/browser/tests/cookie_store.html new file mode 100644 index 00000000..9838d7cb --- /dev/null +++ b/src/browser/tests/cookie_store.html @@ -0,0 +1,216 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index a59c2210..1ff84415 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -80,6 +80,7 @@ pub const Type = union(enum) { submit_event: *@import("event/SubmitEvent.zig"), form_data_event: *@import("event/FormDataEvent.zig"), close_event: *@import("event/CloseEvent.zig"), + cookie_change_event: *@import("event/CookieChangeEvent.zig"), }; pub const Options = struct { @@ -170,6 +171,7 @@ pub fn is(self: *Event, comptime T: type) ?*T { .submit_event => |e| return if (T == @import("event/SubmitEvent.zig")) e else null, .form_data_event => |e| return if (T == @import("event/FormDataEvent.zig")) e else null, .close_event => |e| return if (T == @import("event/CloseEvent.zig")) e else null, + .cookie_change_event => |e| return if (T == @import("event/CookieChangeEvent.zig")) e else null, .ui_event => |e| { if (T == @import("event/UIEvent.zig")) { return e; diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index 01505d5e..845537cd 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -49,6 +49,7 @@ pub const Type = union(enum) { file_reader: *@import("FileReader.zig"), font_face_set: *@import("css/FontFaceSet.zig"), websocket: *@import("net/WebSocket.zig"), + cookie_store: *@import("storage/CookieStore.zig"), }; pub fn init(page: *Page) !*EventTarget { @@ -159,6 +160,7 @@ pub fn format(self: *EventTarget, writer: *std.Io.Writer) !void { .file_reader => writer.writeAll(""), .font_face_set => writer.writeAll(""), .websocket => writer.writeAll(""), + .cookie_store => writer.writeAll(""), }; } @@ -181,6 +183,7 @@ pub fn toString(self: *EventTarget) []const u8 { .file_reader => return "[object FileReader]", .font_face_set => return "[object FontFaceSet]", .websocket => return "[object WebSocket]", + .cookie_store => return "[object CookieStore]", }; } diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index f6bef76c..f49e1f95 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -42,6 +42,7 @@ const MessageEvent = @import("event/MessageEvent.zig"); const MessagePort = @import("MessagePort.zig"); const MediaQueryList = @import("css/MediaQueryList.zig"); const storage = @import("storage/storage.zig"); +const CookieStore = @import("storage/CookieStore.zig"); const Element = @import("Element.zig"); const CSSStyleProperties = @import("css/CSSStyleProperties.zig"); const CustomElementRegistry = @import("CustomElementRegistry.zig"); @@ -53,6 +54,7 @@ const log = lp.log; const IS_DEBUG = builtin.mode == .Debug; const Allocator = std.mem.Allocator; +const Execution = js.Execution; pub fn registerTypes() []const type { return &.{ Window, CrossOriginWindow }; @@ -72,6 +74,7 @@ _screen: *Screen, _visual_viewport: *VisualViewport, _performance: Performance, _storage_bucket: storage.Bucket = .{}, +_cookie_store: ?*CookieStore = null, _on_load: ?js.Function.Global = null, _on_pageshow: ?js.Function.Global = null, _on_popstate: ?js.Function.Global = null, @@ -241,6 +244,14 @@ pub fn getSessionStorage(self: *Window) *storage.Lookup { return &self._storage_bucket.session; } +pub fn getCookieStore(self: *Window, exec: *Execution) !*CookieStore { + if (self._cookie_store) |cs| return cs; + const cs = try exec._factory.eventTarget(CookieStore{ ._proto = undefined }); + try cs.attach(exec); + self._cookie_store = cs; + return cs; +} + pub fn getOrigin(self: *const Window) []const u8 { return self._frame.origin orelse "null"; } @@ -921,6 +932,7 @@ pub const JsApi = struct { pub const performance = bridge.accessor(Window.getPerformance, null, .{}); pub const localStorage = bridge.accessor(Window.getLocalStorage, null, .{}); pub const sessionStorage = bridge.accessor(Window.getSessionStorage, null, .{}); + pub const cookieStore = bridge.accessor(Window.getCookieStore, null, .{}); pub const origin = bridge.accessor(Window.getOrigin, null, .{}); pub const location = bridge.accessor(Window.getLocation, Window.setLocation, .{ .deletable = false }); pub const history = bridge.accessor(Window.getHistory, null, .{}); diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig index 621993af..3a3608c0 100644 --- a/src/browser/webapi/WorkerGlobalScope.zig +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -44,6 +44,7 @@ const WorkerLocation = @import("WorkerLocation.zig"); const MessageEvent = @import("event/MessageEvent.zig"); const ErrorEvent = @import("event/ErrorEvent.zig"); const Fetch = @import("net/Fetch.zig"); +const CookieStore = @import("storage/CookieStore.zig"); const builtin = @import("builtin"); const IS_DEBUG = builtin.mode == .Debug; @@ -102,6 +103,7 @@ _on_rejection_handled: ?JS.Function.Global = null, _on_unhandled_rejection: ?JS.Function.Global = null, _on_message: ?JS.Function.Global = null, _on_messageerror: ?JS.Function.Global = null, +_cookie_store: ?*CookieStore = null, _location: WorkerLocation, @@ -265,6 +267,13 @@ pub fn getLocation(self: *WorkerGlobalScope) *WorkerLocation { return &self._location; } +pub fn getCookieStore(self: *WorkerGlobalScope) !*CookieStore { + if (self._cookie_store) |cs| return cs; + const cs = try self._factory.eventTargetWithAllocator(self.arena, CookieStore{ ._proto = undefined }); + self._cookie_store = cs; + return cs; +} + pub fn getOnError(self: *const WorkerGlobalScope) ?JS.Function.Global { return self._on_error; } @@ -659,6 +668,7 @@ pub const JsApi = struct { } }.wrap, null, .{}); pub const location = bridge.accessor(WorkerGlobalScope.getLocation, null, .{}); + pub const cookieStore = bridge.accessor(WorkerGlobalScope.getCookieStore, null, .{}); pub const onerror = bridge.accessor(WorkerGlobalScope.getOnError, WorkerGlobalScope.setOnError, .{}); pub const onrejectionhandled = bridge.accessor(WorkerGlobalScope.getOnRejectionHandled, WorkerGlobalScope.setOnRejectionHandled, .{}); diff --git a/src/browser/webapi/event/CookieChangeEvent.zig b/src/browser/webapi/event/CookieChangeEvent.zig new file mode 100644 index 00000000..0b4252fe --- /dev/null +++ b/src/browser/webapi/event/CookieChangeEvent.zig @@ -0,0 +1,137 @@ +// Copyright (C) 2023-2026 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 lp = @import("lightpanda"); + +const js = @import("../../js/js.zig"); +const Notification = @import("../../../Notification.zig"); + +const Event = @import("../Event.zig"); +const Cookie = @import("../storage/Cookie.zig"); +const CookieStore = @import("../storage/CookieStore.zig"); + +const String = lp.String; +const Allocator = std.mem.Allocator; +const Execution = js.Execution; + +// https://developer.mozilla.org/en-US/docs/Web/API/CookieChangeEvent +const CookieChangeEvent = @This(); + +_proto: *Event, +_changed: []CookieStore.CookieListItem, +_deleted: []CookieStore.CookieListItem, + +const CookieChangeEventOptions = struct { + changed: ?[]CookieStore.CookieListItem = null, + deleted: ?[]CookieStore.CookieListItem = null, +}; + +const Options = Event.inheritOptions(CookieChangeEvent, CookieChangeEventOptions); + +pub fn init(typ: []const u8, _opts: ?Options, exec: *const Execution) !*CookieChangeEvent { + const arena = try exec.getArena(.tiny, "CookieChangeEvent"); + errdefer exec.releaseArena(arena); + const type_string = try String.init(arena, typ, .{}); + + const opts = _opts orelse Options{}; + + const event = try exec._factory.event( + arena, + type_string, + CookieChangeEvent{ + ._proto = undefined, + ._changed = opts.changed orelse &.{}, + ._deleted = opts.deleted orelse &.{}, + }, + ); + + Event.populatePrototypes(event, opts, false); + return event; +} + +/// Internal constructor used by the change-notification dispatcher to +/// build a trusted "change" event from a single-cookie mutation snapshot. +/// The CookieListItem is materialised on the event's own arena. +pub fn initSingle( + kind: Notification.CookieChanged.Kind, + snapshot: Notification.CookieChanged, + exec: *const Execution, +) !*CookieChangeEvent { + const arena = try exec.getArena(.tiny, "CookieChangeEvent"); + errdefer exec.releaseArena(arena); + const type_string = try String.init(arena, "change", .{}); + + const item = try arena.create(CookieStore.CookieListItem); + item.* = .{ + .name = try String.init(arena, snapshot.name, .{}), + .value = try String.init(arena, snapshot.value, .{}), + .domain = if (snapshot.domain.len > 0 and snapshot.domain[0] == '.') + try String.init(arena, snapshot.domain[1..], .{}) + else + null, + .path = try String.init(arena, snapshot.path, .{}), + .expires = null, + .secure = snapshot.secure, + .sameSite = switch (snapshot.same_site) { + .strict => .strict, + .lax => .lax, + .none => .none, + }, + .partitioned = false, + }; + + const event = try exec._factory.event( + arena, + type_string, + CookieChangeEvent{ + ._proto = undefined, + ._changed = if (kind == .changed) item[0..1] else &.{}, + ._deleted = if (kind == .deleted) item[0..1] else &.{}, + }, + ); + + Event.populatePrototypes(event, Options{}, true); + return event; +} + +pub fn asEvent(self: *CookieChangeEvent) *Event { + return self._proto; +} + +pub fn getChanged(self: *const CookieChangeEvent) []CookieStore.CookieListItem { + return self._changed; +} + +pub fn getDeleted(self: *const CookieChangeEvent) []CookieStore.CookieListItem { + return self._deleted; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(CookieChangeEvent); + + pub const Meta = struct { + pub const name = "CookieChangeEvent"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(CookieChangeEvent.init, .{}); + pub const changed = bridge.accessor(CookieChangeEvent.getChanged, null, .{}); + pub const deleted = bridge.accessor(CookieChangeEvent.getDeleted, null, .{}); +}; diff --git a/src/browser/webapi/storage/Cookie.zig b/src/browser/webapi/storage/Cookie.zig index 6eef2b57..1bfa75b6 100644 --- a/src/browser/webapi/storage/Cookie.zig +++ b/src/browser/webapi/storage/Cookie.zig @@ -21,6 +21,7 @@ const lp = @import("lightpanda"); const URL = @import("../../URL.zig"); const DateTime = @import("../../../datetime.zig").DateTime; +const Notification = @import("../../../Notification.zig"); const public_suffix_list = @import("../../../data/public_suffix_list.zig").lookup; const log = lp.log; @@ -449,11 +450,16 @@ pub fn appliesTo(self: *const Cookie, url: *const PreparedUri, same_site: bool, pub const Jar = struct { allocator: Allocator, cookies: std.ArrayList(Cookie), + // Optional notifier for cookie change events. When set, `add` dispatches + // `cookie_changed` after a real mutation. Session populates this; the + // standalone test construction leaves it null. + notification: ?*Notification = null, - pub fn init(allocator: Allocator) Jar { + pub fn init(allocator: Allocator, notification: ?*Notification) Jar { return .{ .cookies = .{}, .allocator = allocator, + .notification = notification, }; } @@ -503,20 +509,48 @@ pub const Jar = struct { return; } - c.deinit(); if (is_expired) { + // Dispatch while c still points at the live old cookie, + // then free its arena, then remove from the array. After + // swapRemove, items[i] holds a different (still-live) + // entry, so deinit must happen before that. + self.dispatchChange(.deleted, c); + c.deinit(); _ = self.cookies.swapRemove(i); } else { + // Free the old cookie's arena before overwriting the slot; + // after the assignment, c points at the new cookie. + c.deinit(); self.cookies.items[i] = cookie; + self.dispatchChange(.changed, &self.cookies.items[i]); } return; } if (!is_expired) { try self.cookies.append(self.allocator, cookie); + self.dispatchChange(.changed, &self.cookies.items[self.cookies.items.len - 1]); } } + fn dispatchChange( + self: *Jar, + kind: Notification.CookieChanged.Kind, + cookie: *const Cookie, + ) void { + const notification = self.notification orelse return; + notification.dispatch(.cookie_changed, &.{ + .kind = kind, + .name = cookie.name, + .value = cookie.value, + .domain = cookie.domain, + .path = cookie.path, + .secure = cookie.secure, + .http_only = cookie.http_only, + .same_site = cookie.same_site, + }); + } + pub fn removeExpired(self: *Jar, request_time: ?i64) void { if (self.cookies.items.len == 0) return; const time = request_time orelse std.time.timestamp(); @@ -691,7 +725,7 @@ test "Jar: add" { const now = std.time.timestamp(); - var jar = Jar.init(testing.allocator); + var jar = Jar.init(testing.allocator, null); defer jar.deinit(); try expectCookies(&.{}, jar); @@ -723,7 +757,7 @@ test "Jar: add" { test "Jar: non-HTTP add must not replace or duplicate an HttpOnly cookie" { const now = std.time.timestamp(); - var jar = Jar.init(testing.allocator); + var jar = Jar.init(testing.allocator, null); defer jar.deinit(); try jar.add(try Cookie.parse(testing.allocator, test_url, "session=REAL;Path=/;HttpOnly"), now, true); @@ -740,7 +774,7 @@ test "Jar: non-HTTP add must not replace or duplicate an HttpOnly cookie" { } test "Jar: add limit" { - var jar = Jar.init(testing.allocator); + var jar = Jar.init(testing.allocator, null); defer jar.deinit(); const now = std.time.timestamp(); @@ -802,7 +836,7 @@ test "Jar: forRequest" { const now = std.time.timestamp(); - var jar = Jar.init(testing.allocator); + var jar = Jar.init(testing.allocator, null); defer jar.deinit(); const url2 = "http://test.lightpanda.io/"; @@ -948,7 +982,7 @@ test "Jar: forRequest SameSite=Strict on cross-site navigation" { } }.expect; - var jar = Jar.init(testing.allocator); + var jar = Jar.init(testing.allocator, null); defer jar.deinit(); const victim_url: [:0]const u8 = "http://victim.example/"; diff --git a/src/browser/webapi/storage/CookieStore.zig b/src/browser/webapi/storage/CookieStore.zig new file mode 100644 index 00000000..1e475286 --- /dev/null +++ b/src/browser/webapi/storage/CookieStore.zig @@ -0,0 +1,538 @@ +// Copyright (C) 2023-2026 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 lp = @import("lightpanda"); + +const js = @import("../../js/js.zig"); +const URL = @import("../../URL.zig"); +const Frame = @import("../../Frame.zig"); +const Session = @import("../../Session.zig"); +const Notification = @import("../../../Notification.zig"); + +const Cookie = @import("Cookie.zig"); +const EventTarget = @import("../EventTarget.zig"); +const CookieChangeEvent = @import("../event/CookieChangeEvent.zig"); + +const Allocator = std.mem.Allocator; +const Execution = js.Execution; +const String = lp.String; + +pub fn registerTypes() []const type { + return &.{ CookieStore, CookieListItem }; +} + +// https://developer.mozilla.org/en-US/docs/Web/API/CookieStore +const CookieStore = @This(); + +_proto: *EventTarget, +_on_change: ?js.Function.Global = null, +_exec: ?*Execution = null, + +pub fn asEventTarget(self: *CookieStore) *EventTarget { + return self._proto; +} + +/// Registers this CookieStore as a listener for jar-change notifications on +/// the given execution's session. Must be called once after construction by +/// the owning global (Window today; WorkerGlobalScope once wired up). +/// Idempotent on re-call. +pub fn attach(self: *CookieStore, exec: *Execution) !void { + if (self._exec != null) return; + self._exec = exec; + try exec.session.notification.register(.cookie_changed, self, onCookieChanged); +} + +/// Removes this CookieStore from the notification list. +pub fn detach(self: *CookieStore) void { + const exec = self._exec orelse return; + exec.session.notification.unregisterAll(self); + self._exec = null; +} + +fn onCookieChanged(ctx: *anyopaque, data: *const Notification.CookieChanged) !void { + const self: *CookieStore = @ptrCast(@alignCast(ctx)); + const exec = self._exec orelse return; + + // CookieStore exposes only cookies that script would see for the + // current document — same filter as `match` (HttpOnly hidden, + // same-site treated as first-party against the document URL). + const doc_url = exec.url.*; + const target = Cookie.PreparedUri{ + .host = URL.getHostname(doc_url), + .path = URL.getPathname(doc_url), + .secure = URL.isHTTPS(doc_url), + }; + if (target.host.len == 0) return; + + const probe = Cookie{ + .arena = undefined, + .name = data.name, + .value = data.value, + .domain = data.domain, + .path = data.path, + .expires = null, + .secure = data.secure, + .http_only = data.http_only, + .same_site = data.same_site, + }; + if (!probe.appliesTo(&target, true, true, false)) return; + + // Per spec, `change` is dispatched as a queued task — never synchronously + // from the mutation site. We snapshot the notification fields onto a + // small page arena that the scheduled callback releases after dispatch. + const arena = try exec.getArena(.tiny, "CookieStore.change"); + errdefer exec.releaseArena(arena); + + const cb = try arena.create(ChangeCallback); + cb.* = .{ + .cookie_store = self, + .exec = exec, + .arena = arena, + .kind = data.kind, + .name = try arena.dupe(u8, data.name), + .value = try arena.dupe(u8, data.value), + .domain = try arena.dupe(u8, data.domain), + .path = try arena.dupe(u8, data.path), + .secure = data.secure, + .same_site = data.same_site, + }; + + try exec.js.scheduler.add(cb, ChangeCallback.run, 0, .{ + .name = "CookieStore.change", + .low_priority = false, + .finalizer = ChangeCallback.cancelled, + }); +} + +const ChangeCallback = struct { + cookie_store: *CookieStore, + // Execution is stored only to ensure we can release the arena. + // The CookieStore could have been detached in the meantime, and so its + // _exec pointer could have been reset. + exec: *Execution, + arena: Allocator, + kind: Notification.CookieChanged.Kind, + name: []const u8, + value: []const u8, + domain: []const u8, + path: []const u8, + secure: bool, + same_site: Cookie.SameSite, + + fn cancelled(ctx: *anyopaque) void { + const self: *ChangeCallback = @ptrCast(@alignCast(ctx)); + self.releaseArena(); + } + + fn releaseArena(self: *ChangeCallback) void { + self.exec.releaseArena(self.arena); + } + + fn run(ctx: *anyopaque) !?u32 { + const self: *ChangeCallback = @ptrCast(@alignCast(ctx)); + defer self.releaseArena(); + + const cs = self.cookie_store; + // We use the CookieStore's exec here instead of self.exec to detect if + // the store has been detached. In this case, we don't dispatch the + // event. + const exec = cs._exec orelse return null; + const target = cs.asEventTarget(); + + // Skip event construction when nobody is listening. + if (!exec.hasDirectListeners(target, "change", cs._on_change)) { + return null; + } + + const event = try CookieChangeEvent.initSingle(self.kind, .{ + .kind = self.kind, + .name = self.name, + .value = self.value, + .domain = self.domain, + .path = self.path, + .secure = self.secure, + .http_only = false, + .same_site = self.same_site, + }, exec); + + try exec.dispatch(target, event.asEvent(), cs._on_change, .{ + .context = "CookieStore.change", + }); + + return null; + } +}; + +pub fn getOnChange(self: *const CookieStore) ?js.Function.Global { + return self._on_change; +} + +pub fn setOnChange(self: *CookieStore, setter: ?FunctionSetter) void { + const s = setter orelse { + self._on_change = null; + return; + }; + self._on_change = switch (s) { + .func => |f| f, + .anything => null, + }; +} + +const FunctionSetter = union(enum) { + func: js.Function.Global, + anything: js.Value, +}; + +// https://developer.mozilla.org/en-US/docs/Web/API/CookieStore/get +const GetOptions = struct { + name: ?[]const u8 = null, + url: ?[]const u8 = null, +}; + +const GetInput = union(enum) { + name: []const u8, + options: GetOptions, +}; + +// https://developer.mozilla.org/en-US/docs/Web/API/CookieStore/set +const CookieInit = struct { + name: []const u8, + value: []const u8, + expires: ?f64 = null, + domain: ?[]const u8 = null, + path: []const u8 = "/", + sameSite: SameSite = .strict, + partitioned: bool = false, +}; + +const SetInput = union(enum) { + name: []const u8, + options: CookieInit, +}; + +// https://developer.mozilla.org/en-US/docs/Web/API/CookieStore/delete +const DeleteOptions = struct { + name: []const u8, + domain: ?[]const u8 = null, + path: []const u8 = "/", + partitioned: bool = false, +}; + +const DeleteInput = union(enum) { + name: []const u8, + options: DeleteOptions, +}; + +const SameSite = enum { + strict, + lax, + none, + pub const js_enum_from_string = true; +}; + +pub fn get(_: *CookieStore, input: GetInput, exec: *const Execution) !js.Promise { + const local = exec.js.local.?; + + const name: ?[]const u8, const url: ?[]const u8 = switch (input) { + .name => |n| .{ n, null }, + .options => |o| .{ o.name, o.url }, + }; + + const items = matchCookies(exec, name, url, true) catch |err| { + return local.rejectPromise(.{ .type_error = @errorName(err) }); + }; + + if (items.len == 0) { + return local.resolvePromise(@as(?*CookieListItem, null)); + } + return local.resolvePromise(items[0]); +} + +pub fn getAll(_: *CookieStore, input: ?GetInput, exec: *const Execution) !js.Promise { + const local = exec.js.local.?; + + const name: ?[]const u8, const url: ?[]const u8 = if (input) |inp| switch (inp) { + .name => |n| .{ n, null }, + .options => |o| .{ o.name, o.url }, + } else .{ null, null }; + + const items = matchCookies(exec, name, url, false) catch |err| { + return local.rejectPromise(.{ .type_error = @errorName(err) }); + }; + return local.resolvePromise(items); +} + +pub fn set(_: *CookieStore, input: SetInput, value: ?[]const u8, exec: *const Execution) !js.Promise { + const local = exec.js.local.?; + + const init: CookieInit = switch (input) { + .options => |o| o, + .name => |n| .{ + .name = n, + .value = value orelse return local.rejectPromise(.{ .type_error = "value is required" }), + }, + }; + + storeCookie(exec, init) catch |err| { + return local.rejectPromise(.{ .type_error = @errorName(err) }); + }; + + return local.resolvePromise({}); +} + +pub fn delete(_: *CookieStore, input: DeleteInput, exec: *const Execution) !js.Promise { + const local = exec.js.local.?; + + const opts: DeleteOptions = switch (input) { + .options => |o| o, + .name => |n| .{ .name = n }, + }; + + // Deletion per spec is an expired set: write a cookie with the same + // name/path/domain but with Expires in the past, and the Jar will drop + // any existing match (or no-op if none). + storeCookie(exec, .{ + .name = opts.name, + .value = "", + .expires = 0, + .domain = opts.domain, + .path = opts.path, + .sameSite = .strict, + .partitioned = opts.partitioned, + }) catch |err| { + return local.rejectPromise(.{ .type_error = @errorName(err) }); + }; + + return local.resolvePromise({}); +} + +// Resolve the optional `url` per CookieStore.get/getAll spec. In a Window +// context, only the document's own URL is allowed (matches the cookie scope +// script already sees). In a Worker context, any same-origin URL is allowed. +fn resolveQueryUrl(exec: *const Execution, _override: ?[]const u8) ![:0]const u8 { + const current = exec.url.*; + const override = _override orelse return current; + + const resolved = try URL.resolve(exec.call_arena, exec.base(), override, .{ .always_dupe = true }); + if (!exec.isSameOrigin(resolved)) return error.SecurityError; + + switch (exec.js.global) { + .frame => { + if (!std.mem.eql(u8, resolved, current)) return error.InvalidUrl; + }, + .worker => {}, + } + return resolved; +} + +fn matchCookies( + exec: *const Execution, + name: ?[]const u8, + url: ?[]const u8, + first_only: bool, +) ![]*CookieListItem { + const session = exec.session; + const url_resolved = try resolveQueryUrl(exec, url); + + const target = Cookie.PreparedUri{ + .host = URL.getHostname(url_resolved), + .path = URL.getPathname(url_resolved), + .secure = URL.isHTTPS(url_resolved), + }; + if (target.host.len == 0) return error.SecurityError; + + session.cookie_jar.removeExpired(null); + + var items: std.ArrayList(*CookieListItem) = .empty; + for (session.cookie_jar.cookies.items) |*cookie| { + // CookieStore exposes only cookies that script would see for the + // current document. HttpOnly cookies stay hidden. + if (!cookie.appliesTo(&target, true, true, false)) continue; + if (name) |n| { + if (!std.mem.eql(u8, cookie.name, n)) continue; + } + + const item = try exec.arena.create(CookieListItem); + item.* = .{ + .name = String.wrap(cookie.name), + .value = String.wrap(cookie.value), + .domain = if (cookie.domain.len > 0 and cookie.domain[0] == '.') + String.wrap(cookie.domain[1..]) + else + null, + .path = String.wrap(cookie.path), + .expires = if (cookie.expires) |e| e * 1000.0 else null, + .secure = cookie.secure, + .sameSite = switch (cookie.same_site) { + .strict => .strict, + .lax => .lax, + .none => .none, + }, + .partitioned = false, + }; + try items.append(exec.call_arena, item); + if (first_only) break; + } + + return items.items; +} + +fn storeCookie(exec: *const Execution, init: CookieInit) !void { + const session = exec.session; + const url = exec.url.*; + + // Reject inputs the cookie model can't represent. `=` is allowed in + // values but not in names; `;`/CR/LF/NUL break the cookie wire format + // everywhere and so are forbidden in every field. + if (init.name.len == 0) return error.InvalidCookieName; + if (std.mem.indexOfAny(u8, init.name, "=;\r\n\x00") != null) return error.InvalidCookieName; + if (std.mem.indexOfAny(u8, init.value, ";\r\n\x00") != null) return error.InvalidCookieValue; + if (std.mem.indexOfAny(u8, init.path, ";\r\n\x00") != null) return error.InvalidCookiePath; + if (init.domain) |d| { + if (std.mem.indexOfAny(u8, d, ";\r\n\x00") != null) return error.InvalidCookieDomain; + } + + const is_https = URL.isHTTPS(url); + // Per spec, SameSite=None requires Secure. CookieStore additionally + // marks any cookie written from an HTTPS document as Secure. + const secure = is_https or init.sameSite == .none; + + // Cookie-name-prefix rules — match Cookie.parse, case-insensitive to + // catch impersonation attempts (e.g. "__HoSt-"). + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#name-cookie-name-prefixes + if (std.ascii.startsWithIgnoreCase(init.name, "__Host-")) { + if (!is_https) return error.InvalidPrefixedCookie; + if (init.domain) |d| { + if (d.len > 0) return error.InvalidPrefixedCookie; + } + const effective_path = if (init.path.len > 0) init.path else "/"; + if (!std.mem.eql(u8, effective_path, "/")) return error.InvalidPrefixedCookie; + } else if (std.ascii.startsWithIgnoreCase(init.name, "__Secure-")) { + if (!is_https) return error.InvalidPrefixedCookie; + } + + var arena = std.heap.ArenaAllocator.init(session.cookie_jar.allocator); + errdefer arena.deinit(); + const aa = arena.allocator(); + + const owned_name = try aa.dupe(u8, init.name); + const owned_value = try aa.dupe(u8, init.value); + const owned_path = try Cookie.parsePath(aa, url, init.path); + const owned_domain = try Cookie.parseDomain(aa, url, init.domain); + + const cookie: Cookie = .{ + .arena = arena, + .name = owned_name, + .value = owned_value, + .path = owned_path, + .domain = owned_domain, + + // CookieStore.expires is a unix timestamp in milliseconds; Cookie tracks + // expiry in seconds. A timestamp at or before "now" deletes the cookie via + // the Jar's expiry path. + .expires = if (init.expires) |ms| ms / 1000.0 else null, + + .secure = secure, + .http_only = false, + .same_site = switch (init.sameSite) { + .strict => .strict, + .lax => .lax, + .none => .none, + }, + }; + + // CookieStore is a script API, so is_http = false. + try session.cookie_jar.add(cookie, std.time.timestamp(), false); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(CookieStore); + + pub const Meta = struct { + pub const name = "CookieStore"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const get = bridge.function(CookieStore.get, .{ .dom_exception = true }); + pub const getAll = bridge.function(CookieStore.getAll, .{ .dom_exception = true }); + pub const set = bridge.function(CookieStore.set, .{ .dom_exception = true }); + pub const delete = bridge.function(CookieStore.delete, .{ .dom_exception = true }); + pub const onchange = bridge.accessor(CookieStore.getOnChange, CookieStore.setOnChange, .{}); +}; + +// CookieListItem: per CookieStore.get / getAll return shape, documented inline on +// https://developer.mozilla.org/en-US/docs/Web/API/CookieStore +pub const CookieListItem = struct { + name: String, + value: String, + domain: ?String, + path: String, + expires: ?f64, + secure: bool, + sameSite: SameSite, + partitioned: bool, + + fn getName(self: *const CookieListItem) String { + return self.name; + } + fn getValue(self: *const CookieListItem) String { + return self.value; + } + fn getDomain(self: *const CookieListItem) ?String { + return self.domain; + } + fn getPath(self: *const CookieListItem) String { + return self.path; + } + fn getExpires(self: *const CookieListItem) ?f64 { + return self.expires; + } + fn getSecure(self: *const CookieListItem) bool { + return self.secure; + } + fn getSameSite(self: *const CookieListItem) []const u8 { + return @tagName(self.sameSite); + } + fn getPartitioned(self: *const CookieListItem) bool { + return self.partitioned; + } + + pub const JsApi = struct { + pub const bridge = js.Bridge(CookieListItem); + pub const Meta = struct { + pub const name = "CookieListItem"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + pub const name = bridge.accessor(CookieListItem.getName, null, .{}); + pub const value = bridge.accessor(CookieListItem.getValue, null, .{}); + pub const domain = bridge.accessor(CookieListItem.getDomain, null, .{}); + pub const path = bridge.accessor(CookieListItem.getPath, null, .{}); + pub const expires = bridge.accessor(CookieListItem.getExpires, null, .{}); + pub const secure = bridge.accessor(CookieListItem.getSecure, null, .{}); + pub const sameSite = bridge.accessor(CookieListItem.getSameSite, null, .{}); + pub const partitioned = bridge.accessor(CookieListItem.getPartitioned, null, .{}); + }; +}; + +const testing = @import("../../../testing.zig"); +test "WebApi: CookieStore" { + try testing.htmlRunner("cookie_store.html", .{}); +} diff --git a/src/testing.zig b/src/testing.zig index 3b4f4dce..d4df54c4 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -564,8 +564,11 @@ test "tests:afterAll" { @import("root").v8_peak_memory = test_browser.env.isolate.getHeapStatistics().total_physical_size; - test_notification.deinit(); + // Browser must be deinit'd before the notification — Session/Frame + // teardown may unregister notification listeners (e.g. CookieStore + // detach), which dereferences `notification.listeners`. test_browser.deinit(); + test_notification.deinit(); test_app.deinit(); test_config.deinit(@import("root").tracking_allocator); }