diff --git a/src/browser/tests/storage.html b/src/browser/tests/storage.html index cb41bb3d..53aa2f92 100644 --- a/src/browser/tests/storage.html +++ b/src/browser/tests/storage.html @@ -91,9 +91,9 @@ diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index f49e1f95..261a5de7 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -73,7 +73,6 @@ _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, @@ -236,12 +235,19 @@ 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 { diff --git a/src/browser/webapi/storage/storage.zig b/src/browser/webapi/storage/storage.zig index 396c81b9..127685fe 100644 --- a/src/browser/webapi/storage/storage.zig +++ b/src/browser/webapi/storage/storage.zig @@ -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; } diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 053957d8..10d8879e 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -980,6 +980,96 @@ test "MCP - evaluate error reporting" { } }, 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 - Actions: click, fill, scroll, hover, press, selectOption, setChecked" { defer testing.reset(); const aa = testing.arena_allocator;