mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-06-11 01:25:53 -04:00
Merge branch 'main' into agent
This commit is contained in:
5
.github/workflows/e2e-integration-test.yml
vendored
5
.github/workflows/e2e-integration-test.yml
vendored
@@ -59,10 +59,13 @@ jobs:
|
||||
|
||||
- run: chmod a+x ./lightpanda
|
||||
|
||||
# Always activate cache
|
||||
- run: mkdir -p /tmp/lp-cache
|
||||
|
||||
- name: run end to end integration tests
|
||||
continue-on-error: true
|
||||
run: |
|
||||
./lightpanda serve --http-proxy ${{ secrets.MASSIVE_PROXY_RESIDENTIAL_US }} --log-level error & echo $! > LPD.pid
|
||||
./lightpanda serve --http-cache-dir /tmp/lp-cache --http-proxy ${{ secrets.MASSIVE_PROXY_RESIDENTIAL_US }} --log-level error & echo $! > LPD.pid
|
||||
go run integration/main.go |tee result.log
|
||||
kill `cat LPD.pid`
|
||||
|
||||
|
||||
10
.github/workflows/e2e-test.yml
vendored
10
.github/workflows/e2e-test.yml
vendored
@@ -103,10 +103,13 @@ jobs:
|
||||
- if: matrix.wba == true
|
||||
run: echo "${{ secrets.WBA_PRIVATE_KEY_PEM }}" > private_key.pem
|
||||
|
||||
# Always activate cache
|
||||
- run: mkdir -p /tmp/lp-cache
|
||||
|
||||
- id: args
|
||||
name: build LP args
|
||||
run: |
|
||||
args=""
|
||||
args="--http-cache-dir /tmp/lp-cache"
|
||||
[ "${{ matrix.proxy }}" = "true" ] && args="$args --http-proxy http://127.0.0.1:3000"
|
||||
[ "${{ matrix.robotstxt }}" = "true" ] && args="$args --obey-robots"
|
||||
[ "${{ matrix.wba }}" = "true" ] && args="$args --web-bot-auth-key-file private_key.pem"
|
||||
@@ -159,10 +162,13 @@ jobs:
|
||||
- if: matrix.wba == true
|
||||
run: echo "${{ secrets.WBA_PRIVATE_KEY_PEM }}" > private_key.pem
|
||||
|
||||
# Always activate cache
|
||||
- run: mkdir -p /tmp/lp-cache
|
||||
|
||||
- id: args
|
||||
name: build LP args
|
||||
run: |
|
||||
args=""
|
||||
args="--http-cache-dir /tmp/lp-cache"
|
||||
[ "${{ matrix.robotstxt }}" = "true" ] && args="$args --obey-robots"
|
||||
[ "${{ matrix.wba }}" = "true" ] && args="$args --web-bot-auth-key-file private_key.pem"
|
||||
[ "${{ matrix.wba }}" = "true" ] && args="$args --web-bot-auth-domain ${{ vars.WBA_DOMAIN }}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -383,6 +383,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| {
|
||||
@@ -637,7 +642,12 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
|
||||
self._http_status = null;
|
||||
self._http_headers = .empty;
|
||||
|
||||
self.url = try self.arena.dupeZ(u8, request_url);
|
||||
self.url = blk: {
|
||||
if (URL.isCompleteHTTPUrl(request_url)) {
|
||||
break :blk try self.arena.dupeZ(u8, request_url);
|
||||
}
|
||||
break :blk try std.mem.concatWithSentinel(self.arena, u8, &.{ "http://", request_url }, 0);
|
||||
};
|
||||
self.origin = try URL.getOrigin(self.arena, self.url);
|
||||
|
||||
self._req_id = req_id;
|
||||
|
||||
@@ -136,7 +136,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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
216
src/browser/tests/cookie_store.html
Normal file
216
src/browser/tests/cookie_store.html
Normal file
@@ -0,0 +1,216 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="testing.js"></script>
|
||||
<script id=interface>
|
||||
testing.expectEqual('object', typeof cookieStore);
|
||||
testing.expectEqual(cookieStore, window.cookieStore);
|
||||
testing.expectEqual(true, cookieStore instanceof CookieStore);
|
||||
testing.expectEqual(true, cookieStore instanceof EventTarget);
|
||||
testing.expectEqual('function', typeof cookieStore.get);
|
||||
testing.expectEqual('function', typeof cookieStore.getAll);
|
||||
testing.expectEqual('function', typeof cookieStore.set);
|
||||
testing.expectEqual('function', typeof cookieStore.delete);
|
||||
</script>
|
||||
|
||||
<script id=set-and-get>
|
||||
testing.async(async () => {
|
||||
await cookieStore.set('user', 'lp');
|
||||
const item = await cookieStore.get('user');
|
||||
testing.expectEqual('user', item.name);
|
||||
testing.expectEqual('lp', item.value);
|
||||
testing.expectEqual('/', item.path);
|
||||
testing.expectEqual('strict', item.sameSite);
|
||||
testing.expectEqual(null, item.expires);
|
||||
await cookieStore.delete('user');
|
||||
testing.expectEqual(null, await cookieStore.get('user'));
|
||||
});
|
||||
</script>
|
||||
|
||||
<script id=set-options>
|
||||
testing.async(async () => {
|
||||
await cookieStore.set({ name: 'prefs', value: 'dark', sameSite: 'lax' });
|
||||
const item = await cookieStore.get({ name: 'prefs' });
|
||||
testing.expectEqual('prefs', item.name);
|
||||
testing.expectEqual('dark', item.value);
|
||||
testing.expectEqual('lax', item.sameSite);
|
||||
await cookieStore.delete('prefs');
|
||||
});
|
||||
</script>
|
||||
|
||||
<script id=get-missing>
|
||||
testing.async(async () => {
|
||||
const item = await cookieStore.get('does-not-exist');
|
||||
testing.expectEqual(null, item);
|
||||
});
|
||||
</script>
|
||||
|
||||
<script id=get-all>
|
||||
testing.async(async () => {
|
||||
await cookieStore.set('a', '1');
|
||||
await cookieStore.set('b', '2');
|
||||
const all = await cookieStore.getAll();
|
||||
testing.expectEqual(true, all.length >= 2);
|
||||
const names = all.map(c => c.name);
|
||||
testing.expectEqual(true, names.includes('a'));
|
||||
testing.expectEqual(true, names.includes('b'));
|
||||
|
||||
const justA = await cookieStore.getAll('a');
|
||||
testing.expectEqual(1, justA.length);
|
||||
testing.expectEqual('a', justA[0].name);
|
||||
testing.expectEqual('1', justA[0].value);
|
||||
|
||||
await cookieStore.delete('a');
|
||||
await cookieStore.delete('b');
|
||||
});
|
||||
</script>
|
||||
|
||||
<script id=delete-options>
|
||||
testing.async(async () => {
|
||||
await cookieStore.set('tmp', 'x');
|
||||
await cookieStore.delete({ name: 'tmp' });
|
||||
testing.expectEqual(null, await cookieStore.get('tmp'));
|
||||
});
|
||||
</script>
|
||||
|
||||
<script id=onchange-handler>
|
||||
testing.expectEqual(null, cookieStore.onchange);
|
||||
cookieStore.onchange = function() {};
|
||||
testing.expectEqual('function', typeof cookieStore.onchange);
|
||||
cookieStore.onchange = null;
|
||||
testing.expectEqual(null, cookieStore.onchange);
|
||||
</script>
|
||||
|
||||
<script id=cookie-change-event>
|
||||
const ev = new CookieChangeEvent('change');
|
||||
testing.expectEqual('change', ev.type);
|
||||
testing.expectEqual(0, ev.changed.length);
|
||||
testing.expectEqual(0, ev.deleted.length);
|
||||
</script>
|
||||
|
||||
<script id=change-event-from-cookieStore-set>
|
||||
testing.async(async () => {
|
||||
const events = [];
|
||||
const handler = (e) => events.push(e);
|
||||
cookieStore.addEventListener('change', handler);
|
||||
|
||||
await cookieStore.set('ev-from-set', 'v1');
|
||||
// Spec says change events are queued tasks — yield to the loop.
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
|
||||
testing.expectEqual(1, events.length);
|
||||
testing.expectEqual(1, events[0].changed.length);
|
||||
testing.expectEqual('ev-from-set', events[0].changed[0].name);
|
||||
testing.expectEqual('v1', events[0].changed[0].value);
|
||||
testing.expectEqual(0, events[0].deleted.length);
|
||||
|
||||
await cookieStore.delete('ev-from-set');
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
|
||||
testing.expectEqual(2, events.length);
|
||||
testing.expectEqual(0, events[1].changed.length);
|
||||
testing.expectEqual(1, events[1].deleted.length);
|
||||
testing.expectEqual('ev-from-set', events[1].deleted[0].name);
|
||||
|
||||
cookieStore.removeEventListener('change', handler);
|
||||
});
|
||||
</script>
|
||||
|
||||
<script id=set-rejects-invalid-input>
|
||||
testing.async(async () => {
|
||||
const expectReject = async (label, init, valueArg) => {
|
||||
try {
|
||||
if (valueArg !== undefined) {
|
||||
await cookieStore.set(init, valueArg);
|
||||
} else {
|
||||
await cookieStore.set(init);
|
||||
}
|
||||
testing.fail(`expected ${label} to reject`);
|
||||
} catch (err) {
|
||||
testing.expectEqual('TypeError', err.name);
|
||||
}
|
||||
};
|
||||
|
||||
// Empty name.
|
||||
await expectReject('empty name', '', 'v');
|
||||
// Forbidden chars in name.
|
||||
await expectReject('name with =', 'a=b', 'v');
|
||||
await expectReject('name with ;', 'a;b', 'v');
|
||||
await expectReject('name with newline', 'a\nb', 'v');
|
||||
// Forbidden chars in value.
|
||||
await expectReject('value with ;', 'k', 'v;injected=1');
|
||||
await expectReject('value with CR', 'k', 'v\rinjected');
|
||||
// Forbidden chars in path / domain.
|
||||
await expectReject('path with ;', { name: 'k', value: 'v', path: '/;evil' });
|
||||
await expectReject('domain with newline', { name: 'k', value: 'v', domain: 'bad\n.example' });
|
||||
|
||||
// A clean call still works after the rejections.
|
||||
await cookieStore.set('after-validation', 'ok');
|
||||
const item = await cookieStore.get('after-validation');
|
||||
testing.expectEqual('ok', item.value);
|
||||
await cookieStore.delete('after-validation');
|
||||
});
|
||||
</script>
|
||||
|
||||
<script id=change-event-delete-reports-removed-value>
|
||||
testing.async(async () => {
|
||||
const events = [];
|
||||
const handler = (e) => events.push(e);
|
||||
cookieStore.addEventListener('change', handler);
|
||||
|
||||
await cookieStore.set('ev-delete-val', 'original');
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
|
||||
// Sanity: the change event must report the value that was stored.
|
||||
testing.expectEqual('original', events[0].changed[0].value);
|
||||
|
||||
await cookieStore.delete('ev-delete-val');
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
|
||||
// The deleted CookieListItem should describe the cookie that was
|
||||
// removed, so its value must be what the jar held ("original") —
|
||||
// not the deletion payload's value ("").
|
||||
testing.expectEqual(1, events[1].deleted.length);
|
||||
testing.expectEqual('ev-delete-val', events[1].deleted[0].name);
|
||||
testing.expectEqual('original', events[1].deleted[0].value);
|
||||
|
||||
cookieStore.removeEventListener('change', handler);
|
||||
});
|
||||
</script>
|
||||
|
||||
<script id=change-event-from-document-cookie>
|
||||
testing.async(async () => {
|
||||
const events = [];
|
||||
const handler = (e) => events.push(e);
|
||||
cookieStore.addEventListener('change', handler);
|
||||
|
||||
document.cookie = 'ev-from-doc=v1; path=/';
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
|
||||
testing.expectEqual(1, events.length);
|
||||
testing.expectEqual('ev-from-doc', events[0].changed[0].name);
|
||||
testing.expectEqual('v1', events[0].changed[0].value);
|
||||
|
||||
document.cookie = 'ev-from-doc=DELETED; path=/; max-age=0';
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
|
||||
testing.expectEqual(2, events.length);
|
||||
testing.expectEqual('ev-from-doc', events[1].deleted[0].name);
|
||||
|
||||
cookieStore.removeEventListener('change', handler);
|
||||
});
|
||||
</script>
|
||||
|
||||
<script id=change-event-onchange-attribute>
|
||||
testing.async(async () => {
|
||||
let captured = null;
|
||||
cookieStore.onchange = (e) => { captured = e; };
|
||||
|
||||
await cookieStore.set('ev-onchange', 'v1');
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
|
||||
testing.expectEqual(true, captured !== null);
|
||||
testing.expectEqual('ev-onchange', captured.changed[0].name);
|
||||
|
||||
cookieStore.onchange = null;
|
||||
await cookieStore.delete('ev-onchange');
|
||||
});
|
||||
</script>
|
||||
@@ -91,9 +91,9 @@
|
||||
|
||||
<script id="localstorage_limits">
|
||||
localStorage.clear();
|
||||
for (i = 0; i < 5; i++) {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const v = "v".repeat(1024 * 1024);
|
||||
localStorage.setItem(v, v);
|
||||
localStorage.setItem(i.toString(), v);
|
||||
}
|
||||
testing.expectError("QuotaExceededError", () => localStorage.setItem("last", "v"));
|
||||
</script>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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("<FileReader>"),
|
||||
.font_face_set => writer.writeAll("<FontFaceSet>"),
|
||||
.websocket => writer.writeAll("<WebSocket>"),
|
||||
.cookie_store => writer.writeAll("<CookieStore>"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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]",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -71,7 +73,7 @@ _model_context: ModelContext = .init,
|
||||
_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,
|
||||
@@ -233,12 +235,27 @@ pub fn getPerformance(self: *Window) *Performance {
|
||||
return &self._performance;
|
||||
}
|
||||
|
||||
fn bucketForOrigin(self: *Window) *storage.Bucket {
|
||||
return self._frame._session.storage_shed.getOrPut(
|
||||
self._frame._session.browser.app.allocator,
|
||||
self._frame.js.origin.key,
|
||||
) catch @panic("OOM");
|
||||
}
|
||||
|
||||
pub fn getLocalStorage(self: *Window) *storage.Lookup {
|
||||
return &self._storage_bucket.local;
|
||||
return &self.bucketForOrigin().local;
|
||||
}
|
||||
|
||||
pub fn getSessionStorage(self: *Window) *storage.Lookup {
|
||||
return &self._storage_bucket.session;
|
||||
return &self.bucketForOrigin().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 {
|
||||
@@ -921,6 +938,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, .{});
|
||||
|
||||
@@ -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, .{});
|
||||
|
||||
137
src/browser/webapi/event/CookieChangeEvent.zig
Normal file
137
src/browser/webapi/event/CookieChangeEvent.zig
Normal file
@@ -0,0 +1,137 @@
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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, .{});
|
||||
};
|
||||
@@ -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;
|
||||
@@ -446,11 +447,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -500,20 +506,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();
|
||||
@@ -688,7 +722,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);
|
||||
|
||||
@@ -720,7 +754,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);
|
||||
@@ -737,7 +771,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();
|
||||
@@ -799,7 +833,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/";
|
||||
@@ -945,7 +979,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/";
|
||||
|
||||
538
src/browser/webapi/storage/CookieStore.zig
Normal file
538
src/browser/webapi/storage/CookieStore.zig
Normal file
@@ -0,0 +1,538 @@
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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", .{});
|
||||
}
|
||||
@@ -18,7 +18,6 @@
|
||||
|
||||
const std = @import("std");
|
||||
const js = @import("../../js/js.zig");
|
||||
const Frame = @import("../../Frame.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
@@ -35,6 +34,7 @@ pub const Shed = struct {
|
||||
var it = self._origins.iterator();
|
||||
while (it.next()) |kv| {
|
||||
allocator.free(kv.key_ptr.*);
|
||||
kv.value_ptr.*.deinit();
|
||||
allocator.destroy(kv.value_ptr.*);
|
||||
}
|
||||
self._origins.deinit(allocator);
|
||||
@@ -42,13 +42,12 @@ pub const Shed = struct {
|
||||
|
||||
pub fn getOrPut(self: *Shed, allocator: Allocator, origin: []const u8) !*Bucket {
|
||||
const gop = try self._origins.getOrPut(allocator, origin);
|
||||
if (gop.found_existing) {
|
||||
return gop.value_ptr.*;
|
||||
}
|
||||
if (gop.found_existing) return gop.value_ptr.*;
|
||||
errdefer std.debug.assert(self._origins.remove(origin));
|
||||
|
||||
const bucket = try allocator.create(Bucket);
|
||||
errdefer allocator.free(bucket);
|
||||
bucket.* = .{};
|
||||
errdefer allocator.destroy(bucket);
|
||||
bucket.* = .init(allocator);
|
||||
|
||||
gop.key_ptr.* = try allocator.dupe(u8, origin);
|
||||
gop.value_ptr.* = bucket;
|
||||
@@ -56,43 +55,85 @@ pub const Shed = struct {
|
||||
}
|
||||
};
|
||||
|
||||
pub const Bucket = struct { local: Lookup = .{}, session: Lookup = .{} };
|
||||
pub const Bucket = struct {
|
||||
local: Lookup,
|
||||
session: Lookup,
|
||||
|
||||
pub fn init(allocator: Allocator) Bucket {
|
||||
return .{
|
||||
.local = .{ ._allocator = allocator },
|
||||
.session = .{ ._allocator = allocator },
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Bucket) void {
|
||||
self.local.deinit();
|
||||
self.session.deinit();
|
||||
}
|
||||
};
|
||||
|
||||
pub const Lookup = struct {
|
||||
_data: std.StringHashMapUnmanaged([]const u8) = .empty,
|
||||
_size: usize = 0,
|
||||
_allocator: Allocator,
|
||||
|
||||
const max_size = 5 * 1024 * 1024;
|
||||
|
||||
pub fn deinit(self: *Lookup) void {
|
||||
var it = self._data.iterator();
|
||||
while (it.next()) |entry| {
|
||||
self._allocator.free(entry.key_ptr.*);
|
||||
self._allocator.free(entry.value_ptr.*);
|
||||
}
|
||||
self._data.deinit(self._allocator);
|
||||
self._size = 0;
|
||||
}
|
||||
|
||||
pub fn getItem(self: *const Lookup, key_: ?[]const u8) ?[]const u8 {
|
||||
const k = key_ orelse return null;
|
||||
return self._data.get(k);
|
||||
}
|
||||
|
||||
pub fn setItem(self: *Lookup, key_: ?[]const u8, value: []const u8, frame: *Frame) !void {
|
||||
pub fn setItem(self: *Lookup, key_: ?[]const u8, value: []const u8) !void {
|
||||
const k = key_ orelse return;
|
||||
|
||||
if (self._size + value.len > max_size) {
|
||||
const old_len = if (self._data.get(k)) |old| old.len else 0;
|
||||
std.debug.assert(old_len <= self._size);
|
||||
if (self._size - old_len + value.len > max_size) {
|
||||
return error.QuotaExceeded;
|
||||
}
|
||||
defer self._size += value.len;
|
||||
|
||||
const key_owned = try frame.dupeString(k);
|
||||
const value_owned = try frame.dupeString(value);
|
||||
if (self._data.getPtr(k)) |value_ptr| {
|
||||
const value_owned = try self._allocator.dupe(u8, value);
|
||||
self._size -= value_ptr.*.len;
|
||||
self._allocator.free(value_ptr.*);
|
||||
value_ptr.* = value_owned;
|
||||
self._size += value.len;
|
||||
} else {
|
||||
const key_owned = try self._allocator.dupe(u8, k);
|
||||
errdefer self._allocator.free(key_owned);
|
||||
const value_owned = try self._allocator.dupe(u8, value);
|
||||
errdefer self._allocator.free(value_owned);
|
||||
|
||||
const gop = try self._data.getOrPut(frame.arena, key_owned);
|
||||
gop.value_ptr.* = value_owned;
|
||||
try self._data.put(self._allocator, key_owned, value_owned);
|
||||
self._size += value.len;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn removeItem(self: *Lookup, key_: ?[]const u8) void {
|
||||
const k = key_ orelse return;
|
||||
if (self._data.get(k)) |value| {
|
||||
self._size -= value.len;
|
||||
_ = self._data.remove(k);
|
||||
}
|
||||
const kv = self._data.fetchRemove(k) orelse return;
|
||||
self._size -= kv.value.len;
|
||||
self._allocator.free(kv.key);
|
||||
self._allocator.free(kv.value);
|
||||
}
|
||||
|
||||
pub fn clear(self: *Lookup) void {
|
||||
var it = self._data.iterator();
|
||||
while (it.next()) |entry| {
|
||||
self._allocator.free(entry.key_ptr.*);
|
||||
self._allocator.free(entry.value_ptr.*);
|
||||
}
|
||||
self._data.clearRetainingCapacity();
|
||||
self._size = 0;
|
||||
}
|
||||
|
||||
@@ -565,7 +565,7 @@ pub extern "C" fn html5ever_get_memory_usage() -> Memory {
|
||||
use tikv_jemalloc_ctl::{epoch, stats};
|
||||
|
||||
// many statistics are cached and only updated when the epoch is advanced.
|
||||
drop(epoch::advance());
|
||||
let _ = epoch::advance();
|
||||
|
||||
Memory {
|
||||
resident: stats::resident::read().unwrap_or(0),
|
||||
|
||||
@@ -563,6 +563,96 @@ test "MCP - eval: bare expression still returns its value" {
|
||||
} }, out.written());
|
||||
}
|
||||
|
||||
test "MCP - eval: localStorage persists across navigations and is origin-scoped" {
|
||||
defer testing.reset();
|
||||
var out: std.io.Writer.Allocating = .init(testing.arena_allocator);
|
||||
const server = try testLoadPage("http://localhost:9582/src/browser/tests/mcp_actions.html", &out.writer);
|
||||
defer server.deinit();
|
||||
|
||||
// 1. Set a value in localStorage on localhost
|
||||
const first =
|
||||
\\{
|
||||
\\ "jsonrpc": "2.0",
|
||||
\\ "id": 1,
|
||||
\\ "method": "tools/call",
|
||||
\\ "params": {
|
||||
\\ "name": "eval",
|
||||
\\ "arguments": { "script": "localStorage.setItem('foo', 'bar'); localStorage.getItem('foo')" }
|
||||
\\ }
|
||||
\\}
|
||||
;
|
||||
try router.handleMessage(server, testing.arena_allocator, first);
|
||||
try testing.expectJson(.{ .id = 1, .result = .{
|
||||
.content = &.{.{ .type = "text", .text = "bar" }},
|
||||
} }, out.written());
|
||||
|
||||
// 2. Navigate to another origin (127.0.0.1)
|
||||
out.clearRetainingCapacity();
|
||||
const navigate_other =
|
||||
\\{
|
||||
\\ "jsonrpc": "2.0",
|
||||
\\ "id": 2,
|
||||
\\ "method": "tools/call",
|
||||
\\ "params": {
|
||||
\\ "name": "goto",
|
||||
\\ "arguments": { "url": "http://127.0.0.1:9582/src/browser/tests/mcp_actions.html" }
|
||||
\\ }
|
||||
\\}
|
||||
;
|
||||
try router.handleMessage(server, testing.arena_allocator, navigate_other);
|
||||
|
||||
// 3. Get the value on 127.0.0.1, verify it is null (isolated origin storage)
|
||||
out.clearRetainingCapacity();
|
||||
const second =
|
||||
\\{
|
||||
\\ "jsonrpc": "2.0",
|
||||
\\ "id": 3,
|
||||
\\ "method": "tools/call",
|
||||
\\ "params": {
|
||||
\\ "name": "eval",
|
||||
\\ "arguments": { "script": "localStorage.getItem('foo')" }
|
||||
\\ }
|
||||
\\}
|
||||
;
|
||||
try router.handleMessage(server, testing.arena_allocator, second);
|
||||
try testing.expectJson(.{ .id = 3, .result = .{
|
||||
.content = &.{.{ .type = "text", .text = "null" }},
|
||||
} }, out.written());
|
||||
|
||||
// 4. Navigate back to localhost
|
||||
out.clearRetainingCapacity();
|
||||
const navigate_back =
|
||||
\\{
|
||||
\\ "jsonrpc": "2.0",
|
||||
\\ "id": 4,
|
||||
\\ "method": "tools/call",
|
||||
\\ "params": {
|
||||
\\ "name": "goto",
|
||||
\\ "arguments": { "url": "http://localhost:9582/src/browser/tests/mcp_actions.html" }
|
||||
\\ }
|
||||
\\}
|
||||
;
|
||||
try router.handleMessage(server, testing.arena_allocator, navigate_back);
|
||||
|
||||
// 5. Get the value on localhost, verify it is still 'bar'
|
||||
out.clearRetainingCapacity();
|
||||
const third =
|
||||
\\{
|
||||
\\ "jsonrpc": "2.0",
|
||||
\\ "id": 5,
|
||||
\\ "method": "tools/call",
|
||||
\\ "params": {
|
||||
\\ "name": "eval",
|
||||
\\ "arguments": { "script": "localStorage.getItem('foo')" }
|
||||
\\ }
|
||||
\\}
|
||||
;
|
||||
try router.handleMessage(server, testing.arena_allocator, third);
|
||||
try testing.expectJson(.{ .id = 5, .result = .{
|
||||
.content = &.{.{ .type = "text", .text = "bar" }},
|
||||
} }, out.written());
|
||||
}
|
||||
|
||||
test "MCP - indexLines: exact match returns line + trailing newline" {
|
||||
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
@@ -564,7 +564,9 @@ test "tests:afterAll" {
|
||||
|
||||
@import("root").v8_peak_memory = test_browser.env.isolate.getHeapStatistics().total_physical_size;
|
||||
|
||||
// Sessions hold registrations on test_notification, so browser first.
|
||||
// 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();
|
||||
|
||||
Reference in New Issue
Block a user