From 9510e1361cfed28172b2d805a185d0d7196f9a34 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 15 Sep 2026 22:55:08 -0700 Subject: [PATCH 01/93] add an eviction system for RobotsStore --- src/network/EvictionQueue.zig | 99 +++++++++++++++++++++++++++++++++++ src/network/Robots.zig | 83 +++++++++++++++++++++++------ src/network/RobotsGate.zig | 23 ++------ src/server/cdp/domains/lp.zig | 2 +- 4 files changed, 171 insertions(+), 36 deletions(-) create mode 100644 src/network/EvictionQueue.zig diff --git a/src/network/EvictionQueue.zig b/src/network/EvictionQueue.zig new file mode 100644 index 000000000..a4fe35d78 --- /dev/null +++ b/src/network/EvictionQueue.zig @@ -0,0 +1,99 @@ +// 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 Allocator = std.mem.Allocator; + +pub fn EvictionQueue(comptime K: type) type { + return struct { + const Self = @This(); + + const Item = struct { + seq: u64, + key: K, + }; + + fn olderFirst(_: void, a: Item, b: Item) std.math.Order { + return std.math.order(a.seq, b.seq); + } + + const Heap = std.PriorityQueue(Item, void, olderFirst); + + allocator: Allocator, + capacity: usize, + next_seq: u64 = 0, + heap: Heap = .empty, + + pub fn init(allocator: Allocator, capacity: usize) Self { + return .{ .allocator = allocator, .capacity = capacity }; + } + + pub fn deinit(self: *Self) void { + self.heap.deinit(self.allocator); + } + + pub fn count(self: *const Self) usize { + return self.heap.count(); + } + + // Record a newly-inserted key. If this pushes the queue over + // capacity, returns the oldest key so the caller can evict it from + // its own map. Callers must only call this once per new key -- it + // does not check for duplicates. + pub fn insert(self: *Self, key: K) !?K { + const seq = self.next_seq; + self.next_seq += 1; + + try self.heap.push(self.allocator, .{ .seq = seq, .key = key }); + if (self.heap.count() <= self.capacity) { + return null; + } + return self.heap.pop().?.key; + } + }; +} + +const testing = @import("../testing.zig"); + +test "EvictionQueue: no eviction under capacity" { + var q = EvictionQueue([]const u8).init(testing.allocator, 3); + defer q.deinit(); + + try testing.expectEqual(null, try q.insert("a")); + try testing.expectEqual(null, try q.insert("b")); + try testing.expectEqual(null, try q.insert("c")); + try testing.expectEqual(3, q.count()); +} + +test "EvictionQueue: evicts oldest key once over capacity" { + var q = EvictionQueue([]const u8).init(testing.allocator, 2); + defer q.deinit(); + + try testing.expectEqual(null, try q.insert("a")); + try testing.expectEqual(null, try q.insert("b")); + + const evicted = try q.insert("c"); + try testing.expect(evicted != null); + try testing.expectString("a", evicted.?); + try testing.expectEqual(2, q.count()); + + const evicted2 = try q.insert("d"); + try testing.expect(evicted2 != null); + try testing.expectString("b", evicted2.?); + try testing.expectEqual(2, q.count()); +} diff --git a/src/network/Robots.zig b/src/network/Robots.zig index 5ade56263..86c3675f6 100644 --- a/src/network/Robots.zig +++ b/src/network/Robots.zig @@ -19,6 +19,8 @@ const std = @import("std"); const lp = @import("lightpanda"); +const EvictionQueue = @import("EvictionQueue.zig").EvictionQueue; + const log = lp.log; const CompiledPattern = struct { @@ -86,10 +88,9 @@ pub const ContentSignal = struct { pub const Robots = @This(); pub const empty: Robots = .{ .rules = &.{}, .content_signals = &.{} }; -// Think twice before deleting/freeing any entries from the map. Readers, e.g. -// get and getContentSignals, receive values from the map, and if another thread -// was to delete / free those values while in use, UAF. pub const RobotStore = struct { + pub const DEFAULT_CAPACITY = 1_000; + const RobotsEntry = union(enum) { present: Robots, allowed, @@ -100,10 +101,19 @@ pub const RobotStore = struct { allocator: std.mem.Allocator, map: RobotsMap, + evictions: EvictionQueue([]const u8), mutex: std.Io.Mutex = .init, pub fn init(allocator: std.mem.Allocator) RobotStore { - return .{ .allocator = allocator, .map = .empty }; + return .initCapacity(allocator, DEFAULT_CAPACITY); + } + + pub fn initCapacity(allocator: std.mem.Allocator, capacity: usize) RobotStore { + return .{ + .allocator = allocator, + .map = .empty, + .evictions = .init(allocator, capacity), + }; } pub fn deinit(self: *RobotStore) void { @@ -114,21 +124,32 @@ pub const RobotStore = struct { while (iter.next()) |entry| { self.allocator.free(entry.key_ptr.*); - - switch (entry.value_ptr.*) { - .present => |*robots| robots.deinit(self.allocator), - .allowed, .disallowed => {}, - } + self.freeEntry(entry.value_ptr); } self.map.deinit(self.allocator); + self.evictions.deinit(); } - pub fn get(self: *RobotStore, url: []const u8) ?RobotsEntry { + fn freeEntry(self: *RobotStore, entry: *RobotsEntry) void { + switch (entry.*) { + .present => |*robots| robots.deinit(self.allocator), + .allowed, .disallowed => {}, + } + } + + pub const Decision = enum { allowed, blocked }; + + pub fn checkPath(self: *RobotStore, url: []const u8, path: []const u8) ?Decision { self.mutex.lockUncancelable(lp.io); defer self.mutex.unlock(lp.io); - return self.map.get(url); + const entry = self.map.get(url) orelse return null; + return switch (entry) { + .allowed => .allowed, + .disallowed => .blocked, + .present => |robots| if (robots.isAllowed(path)) .allowed else .blocked, + }; } pub fn robotsFromBytes(self: *RobotStore, user_agent: []const u8, bytes: []const u8) !Robots { @@ -152,16 +173,28 @@ pub const RobotStore = struct { discarded.deinit(self.allocator); } - // The returned slice is owned by the store - pub fn getContentSignals(self: *RobotStore, url: []const u8) ?[]const ContentSignal { + pub fn getContentSignals( + self: *RobotStore, + allocator: std.mem.Allocator, + url: []const u8, + ) !?[]const ContentSignal { self.mutex.lockUncancelable(lp.io); defer self.mutex.unlock(lp.io); const entry = self.map.get(url) orelse return null; - return switch (entry) { + const signals = switch (entry) { .present => |robots| robots.content_signals, - .allowed, .disallowed => null, + .allowed, .disallowed => return null, }; + + const out = try allocator.alloc(ContentSignal, signals.len); + for (signals, 0..) |signal, i| { + out[i] = .{ + .name = try allocator.dupe(u8, signal.name), + .value = try allocator.dupe(u8, signal.value), + }; + } + return out; } /// This URL has no restrictions on crawling. @@ -201,6 +234,15 @@ pub const RobotStore = struct { } errdefer _ = self.map.remove(url); gop.key_ptr.* = try self.allocator.dupe(u8, url); + + if (try self.evictions.insert(gop.key_ptr.*)) |evict_key| { + if (self.map.fetchRemove(evict_key)) |kv| { + self.allocator.free(kv.key); + var entry = kv.value; + self.freeEntry(&entry); + } + } + return gop.value_ptr; } }; @@ -1257,13 +1299,20 @@ test "Robots: RobotStore.getContentSignals round-trips" { ); try store.put("https://example.com/robots.txt", robots); - const signals = store.getContentSignals("https://example.com/robots.txt").?; + const signals = (try store.getContentSignals(allocator, "https://example.com/robots.txt")).?; + defer { + for (signals) |signal| { + allocator.free(signal.name); + allocator.free(signal.value); + } + allocator.free(signals); + } try std.testing.expectEqual(1, signals.len); try std.testing.expectEqualStrings("ai-train", signals[0].name); try std.testing.expectEqualStrings("no", signals[0].value); // Unknown host has no stored robots. - try std.testing.expectEqual(null, store.getContentSignals("https://other.com/robots.txt")); + try std.testing.expectEqual(null, try store.getContentSignals(allocator, "https://other.com/robots.txt")); } fn testMatch(pattern: []const u8, path: []const u8) bool { diff --git a/src/network/RobotsGate.zig b/src/network/RobotsGate.zig index ace25406c..9507c1165 100644 --- a/src/network/RobotsGate.zig +++ b/src/network/RobotsGate.zig @@ -50,17 +50,10 @@ pub fn check(self: *RobotsGate, transfer: *Transfer) !Result { const url = transfer.req.url; const robots_url = try URL.getRobotsUrl(transfer.arena.allocator(), url); - if (self.network.robot_store.get(robots_url)) |robot_entry| { - switch (robot_entry) { + if (self.network.robot_store.checkPath(robots_url, URL.getPathname(url))) |decision| { + switch (decision) { .allowed => return .allowed, - .disallowed => { - log.warn(.http, "blocked by robots", .{ .url = url }); - return .blocked; - }, - .present => |robots| { - if (robots.isAllowed(URL.getPathname(url))) { - return .allowed; - } + .blocked => { log.warn(.http, "blocked by robots", .{ .url = url }); return .blocked; }, @@ -141,17 +134,11 @@ fn flushPending(self: *RobotsGate, robots_url: []const u8) void { var queued = self.single_flight.take(robots_url) orelse return; defer queued.deinit(self.single_flight.allocator); - const robot_entry = self.network.robot_store.get(robots_url); for (queued.items) |transfer| { transfer.unpark(); - const allowed = if (robot_entry) |entry| switch (entry) { - .allowed => true, - .disallowed => false, - .present => |robots| robots.isAllowed(URL.getPathname(transfer.req.url)), - } else true; - - if (!allowed) { + const decision = self.network.robot_store.checkPath(robots_url, URL.getPathname(transfer.req.url)); + if (decision == .blocked) { lp.metrics.robots_access.incr(.deny); log.warn(.http, "blocked by robots", .{ .url = transfer.req.url }); transfer.failAsync(error.RobotsBlocked); diff --git a/src/server/cdp/domains/lp.zig b/src/server/cdp/domains/lp.zig index 9f9e15402..ea554b832 100644 --- a/src/server/cdp/domains/lp.zig +++ b/src/server/cdp/domains/lp.zig @@ -306,7 +306,7 @@ fn getContentSignal(cmd: anytype) !void { return cmd.sendResult(.{ .available = false, .contentSignals = empty }, .{}); }; - const signals = network.robot_store.getContentSignals(robots_url); + const signals = try network.robot_store.getContentSignals(cmd.arena, robots_url); return cmd.sendResult(.{ .available = signals != null, .contentSignals = signals orelse empty, From 9fa9e5ae0d80110db5a80d87b7e4186d30127f75 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 16 Sep 2026 18:51:17 -0700 Subject: [PATCH 02/93] add robot store entry limit option --- src/Config.zig | 8 ++++++++ src/help.zon | 3 +++ src/network/Network.zig | 2 +- src/network/Robots.zig | 8 +++----- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index 7eb5bf809..c15d51314 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -253,6 +253,7 @@ pub const ExperimentalFeatures = packed struct(u2) { /// Common CLI args. const CommonOptions = .{ .{ .name = "obey_robots", .type = bool }, + .{ .name = "robot_store_entry_limit", .type = ?u32, .default = 1000 }, .{ .name = "proxy_bearer_token", .type = ?[:0]const u8 }, .{ .name = "http_proxy", .type = ?[:0]const u8 }, .{ .name = "http_max_concurrent", .type = ?u8 }, @@ -563,6 +564,13 @@ pub fn obeyRobots(self: *const Config) bool { }; } +pub fn robotStoreEntryLimit(self: *const Config) u32 { + return switch (self.mode) { + inline .serve, .fetch, .mcp, .agent => |opts| opts.robot_store_entry_limit.?, + else => 1000, + }; +} + pub fn httpVersion(self: *const Config) HttpVersion { return switch (self.mode) { inline .serve, .fetch, .mcp, .agent => |opts| opts.http_version, diff --git a/src/help.zon b/src/help.zon index c6aba3f2a..576741170 100644 --- a/src/help.zon +++ b/src/help.zon @@ -489,6 +489,9 @@ \\ --obey-robots \\ Fetches and obeys robots.txt of the target page. \\ Defaults to false. + \\ --robot-store-entry-limit + \\ Maximum number of entries kept in the RobotStore. + \\ Defaults to 1000. \\ --proxy-bearer-token \\ Token sent for bearer authentication with the proxy: \\ Proxy-Authorization: Bearer . diff --git a/src/network/Network.zig b/src/network/Network.zig index 71ba1fe3f..f6593c008 100644 --- a/src/network/Network.zig +++ b/src/network/Network.zig @@ -121,7 +121,7 @@ pub fn init(app: *App) !Network { .connections = connections, .cache = cache, - .robot_store = RobotStore.init(allocator), + .robot_store = RobotStore.init(allocator, config.robotStoreEntryLimit()), .web_bot_auth = web_bot_auth, .rate_limiter = if (config.httpNavDelay()) |ms| RateLimiter.init(allocator, ms, config.httpNavBurst()) else null, .adblocker = adblocker, diff --git a/src/network/Robots.zig b/src/network/Robots.zig index 86c3675f6..d9990a4b4 100644 --- a/src/network/Robots.zig +++ b/src/network/Robots.zig @@ -89,8 +89,6 @@ pub const Robots = @This(); pub const empty: Robots = .{ .rules = &.{}, .content_signals = &.{} }; pub const RobotStore = struct { - pub const DEFAULT_CAPACITY = 1_000; - const RobotsEntry = union(enum) { present: Robots, allowed, @@ -104,8 +102,8 @@ pub const RobotStore = struct { evictions: EvictionQueue([]const u8), mutex: std.Io.Mutex = .init, - pub fn init(allocator: std.mem.Allocator) RobotStore { - return .initCapacity(allocator, DEFAULT_CAPACITY); + pub fn init(allocator: std.mem.Allocator, capacity: u32) RobotStore { + return .initCapacity(allocator, capacity); } pub fn initCapacity(allocator: std.mem.Allocator, capacity: usize) RobotStore { @@ -1289,7 +1287,7 @@ test "Robots: content-signal prefers specific user-agent over wildcard" { test "Robots: RobotStore.getContentSignals round-trips" { const allocator = std.testing.allocator; - var store = RobotStore.init(allocator); + var store = RobotStore.init(allocator, 1000); defer store.deinit(); const robots = try store.robotsFromBytes("MyBot", From b9cd6f1d88e61b416083ab1350e2fb8cfd8647de Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 17 Sep 2026 20:00:58 -0700 Subject: [PATCH 03/93] remove initCapacity on Robots --- src/network/Robots.zig | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/network/Robots.zig b/src/network/Robots.zig index d9990a4b4..6cd5dfada 100644 --- a/src/network/Robots.zig +++ b/src/network/Robots.zig @@ -103,10 +103,6 @@ pub const RobotStore = struct { mutex: std.Io.Mutex = .init, pub fn init(allocator: std.mem.Allocator, capacity: u32) RobotStore { - return .initCapacity(allocator, capacity); - } - - pub fn initCapacity(allocator: std.mem.Allocator, capacity: usize) RobotStore { return .{ .allocator = allocator, .map = .empty, From 2f7a21974a8d8154839a8204a2ba379373c1117b Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 17 Sep 2026 20:01:29 -0700 Subject: [PATCH 04/93] get rid of count on EvictionQueue --- src/network/EvictionQueue.zig | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/network/EvictionQueue.zig b/src/network/EvictionQueue.zig index a4fe35d78..87e4ee4f8 100644 --- a/src/network/EvictionQueue.zig +++ b/src/network/EvictionQueue.zig @@ -47,10 +47,6 @@ pub fn EvictionQueue(comptime K: type) type { self.heap.deinit(self.allocator); } - pub fn count(self: *const Self) usize { - return self.heap.count(); - } - // Record a newly-inserted key. If this pushes the queue over // capacity, returns the oldest key so the caller can evict it from // its own map. Callers must only call this once per new key -- it @@ -77,7 +73,7 @@ test "EvictionQueue: no eviction under capacity" { try testing.expectEqual(null, try q.insert("a")); try testing.expectEqual(null, try q.insert("b")); try testing.expectEqual(null, try q.insert("c")); - try testing.expectEqual(3, q.count()); + try testing.expectEqual(3, q.heap.count()); } test "EvictionQueue: evicts oldest key once over capacity" { @@ -90,10 +86,10 @@ test "EvictionQueue: evicts oldest key once over capacity" { const evicted = try q.insert("c"); try testing.expect(evicted != null); try testing.expectString("a", evicted.?); - try testing.expectEqual(2, q.count()); + try testing.expectEqual(2, q.heap.count()); const evicted2 = try q.insert("d"); try testing.expect(evicted2 != null); try testing.expectString("b", evicted2.?); - try testing.expectEqual(2, q.count()); + try testing.expectEqual(2, q.heap.count()); } From a8697b662b56e78d8d45699d59d8f6370670798d Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 17 Sep 2026 20:23:21 -0700 Subject: [PATCH 05/93] switch EvictionQueue to proper LRU --- src/network/EvictionQueue.zig | 78 ++++++++++++++++++++++++----------- 1 file changed, 53 insertions(+), 25 deletions(-) diff --git a/src/network/EvictionQueue.zig b/src/network/EvictionQueue.zig index 87e4ee4f8..ffc15142f 100644 --- a/src/network/EvictionQueue.zig +++ b/src/network/EvictionQueue.zig @@ -23,43 +23,51 @@ pub fn EvictionQueue(comptime K: type) type { return struct { const Self = @This(); - const Item = struct { - seq: u64, + const Map = if (K == []const u8) std.StringHashMapUnmanaged(*Entry) else std.AutoArrayHashMapUnmanaged(K, *Entry); + + const Entry = struct { key: K, + node: std.DoublyLinkedList.Node = .{}, }; - fn olderFirst(_: void, a: Item, b: Item) std.math.Order { - return std.math.order(a.seq, b.seq); - } - - const Heap = std.PriorityQueue(Item, void, olderFirst); - allocator: Allocator, capacity: usize, - next_seq: u64 = 0, - heap: Heap = .empty, + list: std.DoublyLinkedList = .{}, + map: Map = .empty, pub fn init(allocator: Allocator, capacity: usize) Self { return .{ .allocator = allocator, .capacity = capacity }; } pub fn deinit(self: *Self) void { - self.heap.deinit(self.allocator); + var it = self.map.valueIterator(); + while (it.next()) |node| self.allocator.destroy(node.*); + self.map.deinit(self.allocator); } - // Record a newly-inserted key. If this pushes the queue over - // capacity, returns the oldest key so the caller can evict it from - // its own map. Callers must only call this once per new key -- it - // does not check for duplicates. pub fn insert(self: *Self, key: K) !?K { - const seq = self.next_seq; - self.next_seq += 1; + const entry = try self.allocator.create(Entry); + entry.* = .{ .key = key }; + try self.map.put(self.allocator, key, entry); + self.list.append(&entry.node); - try self.heap.push(self.allocator, .{ .seq = seq, .key = key }); - if (self.heap.count() <= self.capacity) { - return null; - } - return self.heap.pop().?.key; + if (self.map.count() <= self.capacity) return null; + return self.evictOldest(); + } + + pub fn touch(self: *Self, key: K) void { + const entry = self.map.get(key).?; + self.list.remove(&entry.node); + self.list.append(&entry.node); + } + + fn evictOldest(self: *Self) ?K { + const node = self.list.popFirst() orelse return null; + const entry: *Entry = @fieldParentPtr("node", node); + const key = entry.key; + _ = self.map.remove(key); + self.allocator.destroy(entry); + return key; } }; } @@ -73,7 +81,7 @@ test "EvictionQueue: no eviction under capacity" { try testing.expectEqual(null, try q.insert("a")); try testing.expectEqual(null, try q.insert("b")); try testing.expectEqual(null, try q.insert("c")); - try testing.expectEqual(3, q.heap.count()); + try testing.expectEqual(3, q.list.len()); } test "EvictionQueue: evicts oldest key once over capacity" { @@ -86,10 +94,30 @@ test "EvictionQueue: evicts oldest key once over capacity" { const evicted = try q.insert("c"); try testing.expect(evicted != null); try testing.expectString("a", evicted.?); - try testing.expectEqual(2, q.heap.count()); + try testing.expectEqual(2, q.list.len()); const evicted2 = try q.insert("d"); try testing.expect(evicted2 != null); try testing.expectString("b", evicted2.?); - try testing.expectEqual(2, q.heap.count()); + try testing.expectEqual(2, q.list.len()); +} + +test "EvictionQueue: touch properly prevents eviction on oldest" { + var q = EvictionQueue([]const u8).init(testing.allocator, 2); + defer q.deinit(); + + try testing.expectEqual(null, try q.insert("a")); + try testing.expectEqual(null, try q.insert("b")); + q.touch("a"); + + const evicted = try q.insert("c"); + try testing.expect(evicted != null); + try testing.expectString("b", evicted.?); + try testing.expectEqual(2, q.list.len()); + q.touch("a"); + + const evicted2 = try q.insert("d"); + try testing.expect(evicted2 != null); + try testing.expectString("c", evicted2.?); + try testing.expectEqual(2, q.list.len()); } From 6ec287be35677e94421e93ba5d8398e876207a25 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 17 Sep 2026 20:23:34 -0700 Subject: [PATCH 06/93] properly use LRU EvictionQueue in Robots --- src/network/Robots.zig | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/network/Robots.zig b/src/network/Robots.zig index 6cd5dfada..7133a19c8 100644 --- a/src/network/Robots.zig +++ b/src/network/Robots.zig @@ -138,8 +138,10 @@ pub const RobotStore = struct { self.mutex.lockUncancelable(lp.io); defer self.mutex.unlock(lp.io); - const entry = self.map.get(url) orelse return null; - return switch (entry) { + const kv = self.map.getEntry(url) orelse return null; + self.evictions.touch(kv.key_ptr.*); + + return switch (kv.value_ptr.*) { .allowed => .allowed, .disallowed => .blocked, .present => |robots| if (robots.isAllowed(path)) .allowed else .blocked, From c289b13dc2304afadb23296aaccfb60fabc8b4d6 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 17 Sep 2026 20:24:58 -0700 Subject: [PATCH 07/93] rename EvictionQueue to LruCache --- src/network/{EvictionQueue.zig => LruCache.zig} | 14 +++++++------- src/network/Robots.zig | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) rename src/network/{EvictionQueue.zig => LruCache.zig} (90%) diff --git a/src/network/EvictionQueue.zig b/src/network/LruCache.zig similarity index 90% rename from src/network/EvictionQueue.zig rename to src/network/LruCache.zig index ffc15142f..27ec7a7d0 100644 --- a/src/network/EvictionQueue.zig +++ b/src/network/LruCache.zig @@ -19,7 +19,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -pub fn EvictionQueue(comptime K: type) type { +pub fn LruCache(comptime K: type) type { return struct { const Self = @This(); @@ -74,8 +74,8 @@ pub fn EvictionQueue(comptime K: type) type { const testing = @import("../testing.zig"); -test "EvictionQueue: no eviction under capacity" { - var q = EvictionQueue([]const u8).init(testing.allocator, 3); +test "LruCache: no eviction under capacity" { + var q = LruCache([]const u8).init(testing.allocator, 3); defer q.deinit(); try testing.expectEqual(null, try q.insert("a")); @@ -84,8 +84,8 @@ test "EvictionQueue: no eviction under capacity" { try testing.expectEqual(3, q.list.len()); } -test "EvictionQueue: evicts oldest key once over capacity" { - var q = EvictionQueue([]const u8).init(testing.allocator, 2); +test "LruCache: evicts oldest key once over capacity" { + var q = LruCache([]const u8).init(testing.allocator, 2); defer q.deinit(); try testing.expectEqual(null, try q.insert("a")); @@ -102,8 +102,8 @@ test "EvictionQueue: evicts oldest key once over capacity" { try testing.expectEqual(2, q.list.len()); } -test "EvictionQueue: touch properly prevents eviction on oldest" { - var q = EvictionQueue([]const u8).init(testing.allocator, 2); +test "LruCache: touch properly prevents eviction on oldest" { + var q = LruCache([]const u8).init(testing.allocator, 2); defer q.deinit(); try testing.expectEqual(null, try q.insert("a")); diff --git a/src/network/Robots.zig b/src/network/Robots.zig index 7133a19c8..38122c421 100644 --- a/src/network/Robots.zig +++ b/src/network/Robots.zig @@ -19,7 +19,7 @@ const std = @import("std"); const lp = @import("lightpanda"); -const EvictionQueue = @import("EvictionQueue.zig").EvictionQueue; +const LruCache = @import("LruCache.zig").LruCache; const log = lp.log; @@ -99,7 +99,7 @@ pub const RobotStore = struct { allocator: std.mem.Allocator, map: RobotsMap, - evictions: EvictionQueue([]const u8), + evictions: LruCache([]const u8), mutex: std.Io.Mutex = .init, pub fn init(allocator: std.mem.Allocator, capacity: u32) RobotStore { From 7e4f1dedc1dc445756a90ec39440d9f60779675a Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 17 Sep 2026 20:36:31 -0700 Subject: [PATCH 08/93] force min of 1 on robot entry limit --- src/Config.zig | 24 +++++++++++++++++++++++- src/network/LruCache.zig | 1 + 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/Config.zig b/src/Config.zig index c15d51314..23582f8bd 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -233,6 +233,28 @@ fn caPathValidator( } } +fn robotStoreEntryLimitValidator(_: Allocator, args: *std.process.Args.Iterator, target: *?u32) !void { + const str = args.next() orelse return error.MissingArgument; + const value = std.fmt.parseInt(u32, str, 10) catch { + log.fatal(.app, "invalid option value", .{ + .arg = "--robot-store-entry-limit", + .value = str, + .hint = "must be a positive integer", + }); + return error.InvalidArgument; + }; + + if (value == 0) { + log.fatal(.app, "invalid option value", .{ + .arg = "--robot-store-entry-limit", + .value = str, + .hint = "must be at least 1", + }); + return error.InvalidArgument; + } + target.* = value; +} + pub const HttpVersion = enum { auto, @"1.1", @@ -253,7 +275,7 @@ pub const ExperimentalFeatures = packed struct(u2) { /// Common CLI args. const CommonOptions = .{ .{ .name = "obey_robots", .type = bool }, - .{ .name = "robot_store_entry_limit", .type = ?u32, .default = 1000 }, + .{ .name = "robot_store_entry_limit", .type = ?u32, .default = 1000, .validator = robotStoreEntryLimitValidator }, .{ .name = "proxy_bearer_token", .type = ?[:0]const u8 }, .{ .name = "http_proxy", .type = ?[:0]const u8 }, .{ .name = "http_max_concurrent", .type = ?u8 }, diff --git a/src/network/LruCache.zig b/src/network/LruCache.zig index 27ec7a7d0..e5e6dc65a 100644 --- a/src/network/LruCache.zig +++ b/src/network/LruCache.zig @@ -36,6 +36,7 @@ pub fn LruCache(comptime K: type) type { map: Map = .empty, pub fn init(allocator: Allocator, capacity: usize) Self { + std.debug.assert(capacity > 0); return .{ .allocator = allocator, .capacity = capacity }; } From f17048f80789435f8477a69465d4b8473a56238d Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 16 Sep 2026 19:16:43 -0700 Subject: [PATCH 09/93] bring back Navigation.reload --- src/browser/webapi/navigation/Navigation.zig | 76 +++++++++----------- 1 file changed, 35 insertions(+), 41 deletions(-) diff --git a/src/browser/webapi/navigation/Navigation.zig b/src/browser/webapi/navigation/Navigation.zig index a12ba7cbc..63be0bde0 100644 --- a/src/browser/webapi/navigation/Navigation.zig +++ b/src/browser/webapi/navigation/Navigation.zig @@ -241,14 +241,9 @@ pub fn pushEntry( try self._entries.append(arena.allocator(), entry); self._index = index; - if (previous != null and should_dispatch) { - if (self._on_currententrychange) |cec| { - const event = (try NavigationCurrentEntryChangeEvent.initTrusted( - .wrap("currententrychange"), - .{ .from = previous.?, .navigationType = @tagName(.push) }, - frame, - )).asEvent(); - try self.dispatch(cec, event, frame); + if (should_dispatch) { + if (previous) |p| { + self.fireCurrentEntryChangeEvent(p, .{ .push = state.value }, frame); } } @@ -292,19 +287,35 @@ pub fn replaceEntry( }; if (should_dispatch) { - if (self._on_currententrychange) |cec| { - const event = (try NavigationCurrentEntryChangeEvent.initTrusted( - .wrap("currententrychange"), - .{ .from = previous, .navigationType = @tagName(.replace) }, - frame, - )).asEvent(); - try self.dispatch(cec, event, frame); - } + self.fireCurrentEntryChangeEvent(previous, .{ .replace = state.value }, frame); } return entry; } +fn fireCurrentEntryChangeEvent( + self: *Navigation, + previous: *NavigationHistoryEntry, + kind: ?NavigationKind, + frame: *Frame, +) void { + if (self._on_currententrychange) |cec| { + const event = + NavigationCurrentEntryChangeEvent.initTrusted( + .wrap("currententrychange"), + .{ .from = previous, .navigationType = if (kind) |k| @tagName(k) else null }, + frame, + ) catch |err| { + log.warn(.event, "Navigation.fireCurrentEntryChange", .{ .err = err }); + return; + }; + + self.dispatch(cec, event.asEvent(), frame) catch |err| { + log.warn(.event, "Navigation.fireCurrentEntryChange dispatch", .{ .err = err }); + }; + } +} + const NavigateOptions = struct { history: ?[]const u8 = null, info: ?js.Value = null, @@ -393,15 +404,7 @@ pub fn navigateInner( try frame.queueHashChange(old_url, new_url); } - if (self._on_currententrychange) |cec| { - // If we haven't navigated off, let us fire off an a currententrychange. - const event = (try NavigationCurrentEntryChangeEvent.initTrusted( - .wrap("currententrychange"), - .{ .from = previous, .navigationType = @tagName(kind) }, - frame, - )).asEvent(); - try self.dispatch(cec, event, frame); - } + self.fireCurrentEntryChangeEvent(previous, kind, frame); _ = try committed.persist(); _ = try finished.persist(); @@ -436,14 +439,11 @@ pub fn reload(self: *Navigation, _opts: ?ReloadOptions, frame: *Frame) !Navigati const entry = self.getCurrentEntry(); if (opts.state) |state| { const previous = entry; - entry._state = .{ .source = .navigation, .value = state.toJson(arena) catch return error.DataClone }; - - const event = try NavigationCurrentEntryChangeEvent.initTrusted( - .wrap("currententrychange"), - .{ .from = previous, .navigationType = @tagName(.reload) }, - frame, - ); - try self.dispatch(.{ .currententrychange = event }, frame); + entry._state = .{ + .source = .navigation, + .value = state.toJson(arena.allocator()) catch return error.DataClone, + }; + self.fireCurrentEntryChangeEvent(previous, .reload, frame); } return self.navigateInner(entry._url, .reload, frame); @@ -480,14 +480,7 @@ fn updateCurrentEntry(self: *Navigation, options: UpdateCurrentEntryOptions, fra .value = options.state.toJson(arena.allocator()) catch return error.DataClone, }; - if (self._on_currententrychange) |cec| { - const event = (try NavigationCurrentEntryChangeEvent.initTrusted( - .wrap("currententrychange"), - .{ .from = previous, .navigationType = null }, - frame, - )).asEvent(); - try self.dispatch(cec, event, frame); - } + self.fireCurrentEntryChangeEvent(previous, null, frame); } pub fn dispatch(self: *Navigation, func: js.Function.Global, event: *Event, frame: *Frame) !void { @@ -530,6 +523,7 @@ pub const JsApi = struct { pub const entries = bridge.function(Navigation.entries, .{}); pub const forward = bridge.function(Navigation.forward, .{}); pub const navigate = bridge.function(Navigation.navigate, .{}); + pub const reload = bridge.function(Navigation.reload, .{}); pub const traverseTo = bridge.function(Navigation.traverseTo, .{}); pub const updateCurrentEntry = bridge.function(Navigation.updateCurrentEntry, .{}); From 5f04c699d57d93c0d40ae449140c25bf0c3a628d Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 17 Sep 2026 20:36:41 -0700 Subject: [PATCH 10/93] add robots evicted metric --- src/Metrics.zig | 2 ++ src/network/Robots.zig | 1 + 2 files changed, 3 insertions(+) diff --git a/src/Metrics.zig b/src/Metrics.zig index 38f77323f..c7dabee96 100644 --- a/src/Metrics.zig +++ b/src/Metrics.zig @@ -92,6 +92,7 @@ http_navigation_delay_ms: Histogram(&.{ }) = .{}, robots_status: CounterEnum("category", @import("network/http.zig").StatusCategory) = .{}, robots_access: CounterEnum("result", enum { allow, deny }) = .{}, +robots_evictions: Counter = .{}, cors_check: CounterEnum("result", enum { same_origin, no_cors, simple, preflight }) = .{}, cors_preflight: CounterEnum("result", enum { allowed, blocked }) = .{}, cors_response: CounterEnum("result", enum { allowed, blocked }) = .{}, @@ -130,6 +131,7 @@ const help = .{ .http_navigation_delay_ms = "Time in milliseconds a throttled top-level navigation waited", .robots_status = "robots.txt response status", .robots_access = "robots.txt result", + .robots_evictions = "robots.txt cache entries evicted to stay within limit", .cors_check = "CORS initial classification: same_origin/no_cors need no CORS handling, simple needs response validation only, preflight needs an OPTIONS round-trip first", .cors_preflight = "CORS preflight (OPTIONS) results, one per request that required one", .cors_response = "CORS actual-response validation results", diff --git a/src/network/Robots.zig b/src/network/Robots.zig index 38122c421..b1fe63b40 100644 --- a/src/network/Robots.zig +++ b/src/network/Robots.zig @@ -233,6 +233,7 @@ pub const RobotStore = struct { if (try self.evictions.insert(gop.key_ptr.*)) |evict_key| { if (self.map.fetchRemove(evict_key)) |kv| { + lp.metrics.robots_evictions.incr(); self.allocator.free(kv.key); var entry = kv.value; self.freeEntry(&entry); From b8e2197b22f605e26d32cca21641c20e9d5605d0 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Fri, 18 Sep 2026 08:16:34 -0700 Subject: [PATCH 11/93] switch from LRU to Clock --- src/network/{LruCache.zig => ClockCache.zig} | 85 +++++++++----------- src/network/Robots.zig | 4 +- 2 files changed, 42 insertions(+), 47 deletions(-) rename src/network/{LruCache.zig => ClockCache.zig} (53%) diff --git a/src/network/LruCache.zig b/src/network/ClockCache.zig similarity index 53% rename from src/network/LruCache.zig rename to src/network/ClockCache.zig index e5e6dc65a..86a0abf7a 100644 --- a/src/network/LruCache.zig +++ b/src/network/ClockCache.zig @@ -19,21 +19,19 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -pub fn LruCache(comptime K: type) type { +pub fn ClockCache(comptime K: type) type { return struct { const Self = @This(); - const Map = if (K == []const u8) std.StringHashMapUnmanaged(*Entry) else std.AutoArrayHashMapUnmanaged(K, *Entry); - - const Entry = struct { - key: K, - node: std.DoublyLinkedList.Node = .{}, - }; + const Map = if (K == []const u8) + std.array_hash_map.String(bool) + else + std.array_hash_map.Auto(K, bool); allocator: Allocator, capacity: usize, - list: std.DoublyLinkedList = .{}, map: Map = .empty, + hand: usize = 0, pub fn init(allocator: Allocator, capacity: usize) Self { std.debug.assert(capacity > 0); @@ -41,52 +39,53 @@ pub fn LruCache(comptime K: type) type { } pub fn deinit(self: *Self) void { - var it = self.map.valueIterator(); - while (it.next()) |node| self.allocator.destroy(node.*); self.map.deinit(self.allocator); } pub fn insert(self: *Self, key: K) !?K { - const entry = try self.allocator.create(Entry); - entry.* = .{ .key = key }; - try self.map.put(self.allocator, key, entry); - self.list.append(&entry.node); + try self.map.put(self.allocator, key, true); if (self.map.count() <= self.capacity) return null; - return self.evictOldest(); + return self.evictOne(); } pub fn touch(self: *Self, key: K) void { - const entry = self.map.get(key).?; - self.list.remove(&entry.node); - self.list.append(&entry.node); + if (self.map.getPtr(key)) |referenced| referenced.* = true; } - fn evictOldest(self: *Self) ?K { - const node = self.list.popFirst() orelse return null; - const entry: *Entry = @fieldParentPtr("node", node); - const key = entry.key; - _ = self.map.remove(key); - self.allocator.destroy(entry); - return key; + fn evictOne(self: *Self) ?K { + const referenced = self.map.values(); + while (true) { + if (self.hand >= referenced.len) self.hand = 0; + + if (referenced[self.hand]) { + referenced[self.hand] = false; + self.hand += 1; + continue; + } + + const key = self.map.keys()[self.hand]; + self.map.swapRemoveAt(self.hand); + return key; + } } }; } const testing = @import("../testing.zig"); -test "LruCache: no eviction under capacity" { - var q = LruCache([]const u8).init(testing.allocator, 3); +test "ClockCache: no eviction under capacity" { + var q = ClockCache([]const u8).init(testing.allocator, 3); defer q.deinit(); try testing.expectEqual(null, try q.insert("a")); try testing.expectEqual(null, try q.insert("b")); try testing.expectEqual(null, try q.insert("c")); - try testing.expectEqual(3, q.list.len()); + try testing.expectEqual(3, q.map.count()); } -test "LruCache: evicts oldest key once over capacity" { - var q = LruCache([]const u8).init(testing.allocator, 2); +test "ClockCache: evicts once over capacity" { + var q = ClockCache([]const u8).init(testing.allocator, 2); defer q.deinit(); try testing.expectEqual(null, try q.insert("a")); @@ -95,30 +94,26 @@ test "LruCache: evicts oldest key once over capacity" { const evicted = try q.insert("c"); try testing.expect(evicted != null); try testing.expectString("a", evicted.?); - try testing.expectEqual(2, q.list.len()); - - const evicted2 = try q.insert("d"); - try testing.expect(evicted2 != null); - try testing.expectString("b", evicted2.?); - try testing.expectEqual(2, q.list.len()); + try testing.expectEqual(2, q.map.count()); } -test "LruCache: touch properly prevents eviction on oldest" { - var q = LruCache([]const u8).init(testing.allocator, 2); +test "ClockCache: touch protects a key from eviction" { + var q = ClockCache([]const u8).init(testing.allocator, 2); defer q.deinit(); try testing.expectEqual(null, try q.insert("a")); try testing.expectEqual(null, try q.insert("b")); - q.touch("a"); - const evicted = try q.insert("c"); - try testing.expect(evicted != null); - try testing.expectString("b", evicted.?); - try testing.expectEqual(2, q.list.len()); - q.touch("a"); + const evicted1 = try q.insert("c"); + try testing.expect(evicted1 != null); + try testing.expectString("a", evicted1.?); + try testing.expect(q.map.contains("b")); + try testing.expect(q.map.contains("c")); + q.touch("b"); const evicted2 = try q.insert("d"); try testing.expect(evicted2 != null); try testing.expectString("c", evicted2.?); - try testing.expectEqual(2, q.list.len()); + try testing.expect(q.map.contains("b")); + try testing.expectEqual(2, q.map.count()); } diff --git a/src/network/Robots.zig b/src/network/Robots.zig index b1fe63b40..f92cc9aba 100644 --- a/src/network/Robots.zig +++ b/src/network/Robots.zig @@ -19,7 +19,7 @@ const std = @import("std"); const lp = @import("lightpanda"); -const LruCache = @import("LruCache.zig").LruCache; +const ClockCache = @import("ClockCache.zig").ClockCache; const log = lp.log; @@ -99,7 +99,7 @@ pub const RobotStore = struct { allocator: std.mem.Allocator, map: RobotsMap, - evictions: LruCache([]const u8), + evictions: ClockCache([]const u8), mutex: std.Io.Mutex = .init, pub fn init(allocator: std.mem.Allocator, capacity: u32) RobotStore { From eb56ce87e4e5426653c78462a6905f6e854ec5b7 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Fri, 18 Sep 2026 09:05:23 -0700 Subject: [PATCH 12/93] resolve using owned Robots instead of checking store --- src/network/RobotsGate.zig | 64 ++++++++++++++++++++++---------------- 1 file changed, 38 insertions(+), 26 deletions(-) diff --git a/src/network/RobotsGate.zig b/src/network/RobotsGate.zig index 9507c1165..9de29a7d0 100644 --- a/src/network/RobotsGate.zig +++ b/src/network/RobotsGate.zig @@ -127,17 +127,26 @@ fn fetchThenResume(self: *RobotsGate, robots_url: [:0]const u8, transfer: *Trans fetch_transfer.submit() catch {}; } +const Outcome = union(enum) { + decision: Robots.RobotStore.Decision, + robots: Robots.Robots, +}; + // The robots.txt fetch resolved: hand every waiter back to the pipeline, // each judged against its own path. No store entry (fetch failed, or a 200 // whose body never got parsed) fails open. -fn flushPending(self: *RobotsGate, robots_url: []const u8) void { +fn flushPending(self: *RobotsGate, robots_url: []const u8, outcome: Outcome) void { var queued = self.single_flight.take(robots_url) orelse return; defer queued.deinit(self.single_flight.allocator); for (queued.items) |transfer| { transfer.unpark(); - const decision = self.network.robot_store.checkPath(robots_url, URL.getPathname(transfer.req.url)); + const decision: Robots.RobotStore.Decision = switch (outcome) { + .decision => |d| d, + .robots => |r| if (r.isAllowed(URL.getPathname(transfer.req.url))) .allowed else .blocked, + }; + if (decision == .blocked) { lp.metrics.robots_access.incr(.deny); log.warn(.http, "blocked by robots", .{ .url = transfer.req.url }); @@ -187,26 +196,29 @@ const RobotsContext = struct { switch (self.status) { 200 => { - if (self.buffer.items.len > 0) { - const robots: ?Robots = network.robot_store.robotsFromBytes( - network.config.http_headers.user_agent, - self.buffer.items, - ) catch |err| blk: { - // We only return an error if an allocation or something fails. - // Our parser does already leniently handle malformed input and takes whichever rules it can parse. - // On this case of an allocation failure, it is our fault so we put it as disallowed. - log.warn(.browser, "error while parsing robots.txt", .{ .robots_url = robots_url, .err = err }); - try network.robot_store.putDisallowed(robots_url); - break :blk null; - }; - if (robots) |r| { - try network.robot_store.put(robots_url, r); - // BE CAREFUL: robots can be invalidated after this call - } - } else { + if (self.buffer.items.len == 0) { // Empty robots.txt means we can short-circuit the allowed path. try network.robot_store.putAllowed(robots_url); + self.resolve(.{ .decision = .allowed }); + return; } + + const robots = network.robot_store.robotsFromBytes( + network.config.http_headers.user_agent, + self.buffer.items, + ) catch |err| { + // We only return an error if an allocation or something fails. + // Our parser does already leniently handle malformed input and takes whichever rules it can parse. + // On this case of an allocation failure, it is our fault so we put it as disallowed. + log.warn(.browser, "error while parsing robots.txt", .{ .robots_url = robots_url, .err = err }); + try network.robot_store.putDisallowed(robots_url); + self.resolve(.{ .decision = .blocked }); + return; + }; + + self.resolve(.{ .robots = robots }); + // BE CAREFUL: robots can be invalidated after this call + try network.robot_store.put(robots_url, robots); }, // Unauthorized/Forbidden: treat as fully disallowed since we can't verify permissions. 401, 403 => { @@ -215,11 +227,13 @@ const RobotsContext = struct { .status = self.status, }); try network.robot_store.putDisallowed(robots_url); + self.resolve(.{ .decision = .blocked }); }, // RFC9309: Unavailable (400-499) means that we may access any resources on the server. 400, 402, 404...499 => { log.debug(.http, "robots.txt unavailable", .{ .url = robots_url }); try network.robot_store.putAllowed(robots_url); + self.resolve(.{ .decision = .allowed }); }, // RFC9309: Unreachable (500-599) means that we are completely disallowed. 500...599 => { @@ -228,6 +242,7 @@ const RobotsContext = struct { .status = self.status, }); try network.robot_store.putDisallowed(robots_url); + self.resolve(.{ .decision = .blocked }); }, else => { log.debug(.http, "unexpected status on robots", .{ @@ -235,19 +250,16 @@ const RobotsContext = struct { .status = self.status, }); try network.robot_store.putDisallowed(robots_url); + self.resolve(.{ .decision = .blocked }); }, } - - // If anything above threw, error_callback fires next and resolves - // instead — resolve() must run exactly once. - self.resolve(); } fn errorCallback(ctx_ptr: *anyopaque, err: anyerror) void { const self: *RobotsContext = @ptrCast(@alignCast(ctx_ptr)); log.warn(.http, "robots fetch failed", .{ .err = err }); - self.resolve(); + self.resolve(.{ .decision = .allowed }); } fn shutdownCallback(ctx_ptr: *anyopaque) void { @@ -260,10 +272,10 @@ const RobotsContext = struct { arena.release(); } - fn resolve(self: *RobotsContext) void { + fn resolve(self: *RobotsContext, outcome: Outcome) void { const gate = self.gate; const arena = self.arena; - gate.flushPending(self.robots_url); + gate.flushPending(self.robots_url, outcome); arena.release(); } }; From 16153e73b1831e3b7490ef59f93638f6dd10a95c Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Fri, 18 Sep 2026 09:08:37 -0700 Subject: [PATCH 13/93] dont store arena_pool in RobotsContext --- src/network/RobotsGate.zig | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/network/RobotsGate.zig b/src/network/RobotsGate.zig index 9de29a7d0..ff1e7f06c 100644 --- a/src/network/RobotsGate.zig +++ b/src/network/RobotsGate.zig @@ -91,7 +91,6 @@ fn fetchThenResume(self: *RobotsGate, robots_url: [:0]const u8, transfer: *Trans .gate = self, .buffer = .empty, .arena = arena, - .arena_pool = client.arena_pool, .robots_url = owned_url, }; @@ -166,7 +165,6 @@ fn flushPending(self: *RobotsGate, robots_url: []const u8, outcome: Outcome) voi const RobotsContext = struct { gate: *RobotsGate, arena: *lp.Arena, - arena_pool: *ArenaPool, robots_url: [:0]const u8, buffer: std.ArrayList(u8), status: u16 = 0, From b55b8966fd0d19c731e52220b19c1389919ecf0e Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Fri, 18 Sep 2026 09:16:16 -0700 Subject: [PATCH 14/93] use robots_url before releasing arena in all RobotGate paths --- src/network/RobotsGate.zig | 46 +++++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/src/network/RobotsGate.zig b/src/network/RobotsGate.zig index ff1e7f06c..07851dac8 100644 --- a/src/network/RobotsGate.zig +++ b/src/network/RobotsGate.zig @@ -196,8 +196,7 @@ const RobotsContext = struct { 200 => { if (self.buffer.items.len == 0) { // Empty robots.txt means we can short-circuit the allowed path. - try network.robot_store.putAllowed(robots_url); - self.resolve(.{ .decision = .allowed }); + self.settle(.{ .decision = .allowed }); return; } @@ -209,14 +208,12 @@ const RobotsContext = struct { // Our parser does already leniently handle malformed input and takes whichever rules it can parse. // On this case of an allocation failure, it is our fault so we put it as disallowed. log.warn(.browser, "error while parsing robots.txt", .{ .robots_url = robots_url, .err = err }); - try network.robot_store.putDisallowed(robots_url); - self.resolve(.{ .decision = .blocked }); + self.settle(.{ .decision = .blocked }); return; }; - self.resolve(.{ .robots = robots }); // BE CAREFUL: robots can be invalidated after this call - try network.robot_store.put(robots_url, robots); + self.settle(.{ .robots = robots }); }, // Unauthorized/Forbidden: treat as fully disallowed since we can't verify permissions. 401, 403 => { @@ -224,14 +221,12 @@ const RobotsContext = struct { .url = robots_url, .status = self.status, }); - try network.robot_store.putDisallowed(robots_url); - self.resolve(.{ .decision = .blocked }); + self.settle(.{ .decision = .blocked }); }, // RFC9309: Unavailable (400-499) means that we may access any resources on the server. 400, 402, 404...499 => { log.debug(.http, "robots.txt unavailable", .{ .url = robots_url }); - try network.robot_store.putAllowed(robots_url); - self.resolve(.{ .decision = .allowed }); + self.settle(.{ .decision = .allowed }); }, // RFC9309: Unreachable (500-599) means that we are completely disallowed. 500...599 => { @@ -239,16 +234,14 @@ const RobotsContext = struct { .url = robots_url, .status = self.status, }); - try network.robot_store.putDisallowed(robots_url); - self.resolve(.{ .decision = .blocked }); + self.settle(.{ .decision = .blocked }); }, else => { log.debug(.http, "unexpected status on robots", .{ .url = robots_url, .status = self.status, }); - try network.robot_store.putDisallowed(robots_url); - self.resolve(.{ .decision = .blocked }); + self.settle(.{ .decision = .blocked }); }, } } @@ -257,7 +250,7 @@ const RobotsContext = struct { const self: *RobotsContext = @ptrCast(@alignCast(ctx_ptr)); log.warn(.http, "robots fetch failed", .{ .err = err }); - self.resolve(.{ .decision = .allowed }); + self.settle(.{ .decision = .allowed }); } fn shutdownCallback(ctx_ptr: *anyopaque) void { @@ -270,10 +263,27 @@ const RobotsContext = struct { arena.release(); } - fn resolve(self: *RobotsContext, outcome: Outcome) void { - const gate = self.gate; + fn settle(self: *RobotsContext, outcome: RobotsGate.Outcome) void { const arena = self.arena; + defer arena.release(); + + const gate = self.gate; + const network = gate.network; + gate.flushPending(self.robots_url, outcome); - arena.release(); + + switch (outcome) { + .decision => |d| switch (d) { + .allowed => network.robot_store.putAllowed(self.robots_url) catch |err| { + log.warn(.browser, "failed to cache robots decision", .{ .url = self.robots_url, .err = err }); + }, + .blocked => network.robot_store.putDisallowed(self.robots_url) catch |err| { + log.warn(.browser, "failed to cache robots decision", .{ .url = self.robots_url, .err = err }); + }, + }, + .robots => |r| network.robot_store.put(self.robots_url, r) catch |err| { + log.warn(.browser, "failed to cache robots rules", .{ .url = self.robots_url, .err = err }); + }, + } } }; From 74789d3af0aefaa927245b5b19321d834e413dc1 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 21 Sep 2026 07:07:08 -0700 Subject: [PATCH 15/93] use ClockCache as the map in RobotStore --- src/network/ClockCache.zig | 121 ++++++++++++++++++++++++------------- src/network/Robots.zig | 82 +++++++------------------ 2 files changed, 102 insertions(+), 101 deletions(-) diff --git a/src/network/ClockCache.zig b/src/network/ClockCache.zig index 86a0abf7a..84c41f310 100644 --- a/src/network/ClockCache.zig +++ b/src/network/ClockCache.zig @@ -19,14 +19,23 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -pub fn ClockCache(comptime K: type) type { +pub fn Entry(comptime V: type) type { + return struct { + value: V, + referenced: bool, + }; +} + +pub fn ClockCache(comptime V: type) type { return struct { const Self = @This(); - const Map = if (K == []const u8) - std.array_hash_map.String(bool) - else - std.array_hash_map.Auto(K, bool); + const Map = std.array_hash_map.String(Entry(V)); + + pub const InsertResult = union(enum) { + exists, + inserted: ?V, + }; allocator: Allocator, capacity: usize, @@ -39,34 +48,50 @@ pub fn ClockCache(comptime K: type) type { } pub fn deinit(self: *Self) void { + for (self.map.keys()) |key| { + self.allocator.free(key); + } self.map.deinit(self.allocator); } - pub fn insert(self: *Self, key: K) !?K { - try self.map.put(self.allocator, key, true); - - if (self.map.count() <= self.capacity) return null; - return self.evictOne(); + pub fn entries(self: *Self) []Entry(V) { + return self.map.values(); } - pub fn touch(self: *Self, key: K) void { - if (self.map.getPtr(key)) |referenced| referenced.* = true; + pub fn get(self: *Self, key: []const u8) ?*V { + const entry = self.map.getPtr(key) orelse return null; + entry.referenced = true; + return &entry.value; } - fn evictOne(self: *Self) ?K { - const referenced = self.map.values(); + pub fn insert(self: *Self, key: []const u8, value: V) !InsertResult { + const gop = try self.map.getOrPut(self.allocator, key); + if (gop.found_existing) return .exists; + + errdefer self.map.swapRemoveAt(gop.index); + gop.key_ptr.* = try self.allocator.dupe(u8, key); + gop.value_ptr.* = .{ .value = value, .referenced = true }; + + if (self.map.count() <= self.capacity) return .{ .inserted = null }; + return .{ .inserted = self.evictOne() }; + } + + fn evictOne(self: *Self) V { + const items = self.map.values(); while (true) { - if (self.hand >= referenced.len) self.hand = 0; + if (self.hand >= items.len) self.hand = 0; - if (referenced[self.hand]) { - referenced[self.hand] = false; + if (items[self.hand].referenced) { + items[self.hand].referenced = false; self.hand += 1; continue; } const key = self.map.keys()[self.hand]; + const value = items[self.hand].value; self.map.swapRemoveAt(self.hand); - return key; + self.allocator.free(key); + return value; } } }; @@ -74,46 +99,60 @@ pub fn ClockCache(comptime K: type) type { const testing = @import("../testing.zig"); +fn evictedOf(comptime V: type, r: ClockCache(V).InsertResult) ?V { + return switch (r) { + .exists => unreachable, + .inserted => |v| v, + }; +} + test "ClockCache: no eviction under capacity" { - var q = ClockCache([]const u8).init(testing.allocator, 3); + var q = ClockCache(u32).init(testing.allocator, 3); defer q.deinit(); - try testing.expectEqual(null, try q.insert("a")); - try testing.expectEqual(null, try q.insert("b")); - try testing.expectEqual(null, try q.insert("c")); + try testing.expectEqual(null, evictedOf(u32, try q.insert("a", 1))); + try testing.expectEqual(null, evictedOf(u32, try q.insert("b", 2))); + try testing.expectEqual(null, evictedOf(u32, try q.insert("c", 3))); try testing.expectEqual(3, q.map.count()); } test "ClockCache: evicts once over capacity" { - var q = ClockCache([]const u8).init(testing.allocator, 2); + var q = ClockCache(u32).init(testing.allocator, 2); defer q.deinit(); - try testing.expectEqual(null, try q.insert("a")); - try testing.expectEqual(null, try q.insert("b")); + try testing.expectEqual(null, evictedOf(u32, try q.insert("a", 1))); + try testing.expectEqual(null, evictedOf(u32, try q.insert("b", 2))); - const evicted = try q.insert("c"); - try testing.expect(evicted != null); - try testing.expectString("a", evicted.?); + const evicted = evictedOf(u32, try q.insert("c", 3)); + try testing.expectEqual(1, evicted.?); + try testing.expect(q.get("a") == null); try testing.expectEqual(2, q.map.count()); } test "ClockCache: touch protects a key from eviction" { - var q = ClockCache([]const u8).init(testing.allocator, 2); + var q = ClockCache(u32).init(testing.allocator, 2); defer q.deinit(); - try testing.expectEqual(null, try q.insert("a")); - try testing.expectEqual(null, try q.insert("b")); + _ = try q.insert("a", 1); + _ = try q.insert("b", 2); - const evicted1 = try q.insert("c"); - try testing.expect(evicted1 != null); - try testing.expectString("a", evicted1.?); - try testing.expect(q.map.contains("b")); - try testing.expect(q.map.contains("c")); + const evicted1 = evictedOf(u32, try q.insert("c", 3)); + try testing.expectEqual(1, evicted1.?); + try testing.expect(q.get("b") != null); + try testing.expect(q.get("c") != null); - q.touch("b"); - const evicted2 = try q.insert("d"); - try testing.expect(evicted2 != null); - try testing.expectString("c", evicted2.?); - try testing.expect(q.map.contains("b")); + _ = q.get("b"); + const evicted2 = evictedOf(u32, try q.insert("d", 4)); + try testing.expectEqual(3, evicted2.?); + try testing.expect(q.get("b") != null); try testing.expectEqual(2, q.map.count()); } + +test "ClockCache: insert does not overwrite" { + var q = ClockCache(u32).init(testing.allocator, 2); + defer q.deinit(); + + _ = try q.insert("a", 1); + try testing.expect((try q.insert("a", 99)) == .exists); + try testing.expectEqual(1, q.get("a").?.*); +} diff --git a/src/network/Robots.zig b/src/network/Robots.zig index f92cc9aba..33d0fcfba 100644 --- a/src/network/Robots.zig +++ b/src/network/Robots.zig @@ -95,18 +95,14 @@ pub const RobotStore = struct { disallowed, }; - const RobotsMap = @import("Network.zig").HostHashMap(RobotsEntry); - allocator: std.mem.Allocator, - map: RobotsMap, - evictions: ClockCache([]const u8), + map: ClockCache(RobotsEntry), mutex: std.Io.Mutex = .init, pub fn init(allocator: std.mem.Allocator, capacity: u32) RobotStore { return .{ .allocator = allocator, - .map = .empty, - .evictions = .init(allocator, capacity), + .map = .init(allocator, capacity), }; } @@ -114,15 +110,10 @@ pub const RobotStore = struct { self.mutex.lockUncancelable(lp.io); defer self.mutex.unlock(lp.io); - var iter = self.map.iterator(); - - while (iter.next()) |entry| { - self.allocator.free(entry.key_ptr.*); - self.freeEntry(entry.value_ptr); + for (self.map.entries()) |*entry| { + self.freeEntry(&entry.value); } - - self.map.deinit(self.allocator); - self.evictions.deinit(); + self.map.deinit(); } fn freeEntry(self: *RobotStore, entry: *RobotsEntry) void { @@ -138,10 +129,8 @@ pub const RobotStore = struct { self.mutex.lockUncancelable(lp.io); defer self.mutex.unlock(lp.io); - const kv = self.map.getEntry(url) orelse return null; - self.evictions.touch(kv.key_ptr.*); - - return switch (kv.value_ptr.*) { + const entry = self.map.get(url) orelse return null; + return switch (entry.*) { .allowed => .allowed, .disallowed => .blocked, .present => |robots| if (robots.isAllowed(path)) .allowed else .blocked, @@ -156,15 +145,10 @@ pub const RobotStore = struct { self.mutex.lockUncancelable(lp.io); defer self.mutex.unlock(lp.io); - if (try self.putKey(url)) |value_ptr| { - // first time seeing this url, store the value - value_ptr.* = .{ .present = robots }; - return; - } + if (try self.insert(url, .{ .present = robots })) return; // cannot overwrite an existing value, if it was `present`, we'd have // to free the value but it might be being used. - var discarded = robots; discarded.deinit(self.allocator); } @@ -178,7 +162,7 @@ pub const RobotStore = struct { defer self.mutex.unlock(lp.io); const entry = self.map.get(url) orelse return null; - const signals = switch (entry) { + const signals = switch (entry.*) { .present => |robots| robots.content_signals, .allowed, .disallowed => return null, }; @@ -197,50 +181,28 @@ pub const RobotStore = struct { pub fn putAllowed(self: *RobotStore, url: []const u8) !void { self.mutex.lockUncancelable(lp.io); defer self.mutex.unlock(lp.io); - - if (try self.putKey(url)) |value_ptr| { - // first time seeing this url, store the value - value_ptr.* = .allowed; - } - // cannot overwrite an existing value, if it was `present`, we'd have - // to free the value but it might be being used. + _ = try self.insert(url, .allowed); } /// This URL is fully restricted from crawling. pub fn putDisallowed(self: *RobotStore, url: []const u8) !void { self.mutex.lockUncancelable(lp.io); defer self.mutex.unlock(lp.io); - - if (try self.putKey(url)) |value_ptr| { - // first time seeing this url, store the value - value_ptr.* = .disallowed; - } - // cannot overwrite an existing value, if it was `present`, we'd have - // to free the value but it might be being used. + _ = try self.insert(url, .disallowed); } - // The RobotStore is shared across Browsers. Two rowsers can request the - // same robots URL at the same time, and they'll race here. First one wins. - // Caller holds the mutex. - fn putKey(self: *RobotStore, url: []const u8) !?*RobotsEntry { - const gop = try self.map.getOrPut(self.allocator, url); - if (gop.found_existing) { - // already have a value, caller should not overwrite - return null; + fn insert(self: *RobotStore, url: []const u8, entry: RobotsEntry) !bool { + switch (try self.map.insert(url, entry)) { + .exists => return false, + .inserted => |evicted| { + if (evicted) |value| { + lp.metrics.robots_evictions.incr(); + var e = value; + self.freeEntry(&e); + } + return true; + }, } - errdefer _ = self.map.remove(url); - gop.key_ptr.* = try self.allocator.dupe(u8, url); - - if (try self.evictions.insert(gop.key_ptr.*)) |evict_key| { - if (self.map.fetchRemove(evict_key)) |kv| { - lp.metrics.robots_evictions.incr(); - self.allocator.free(kv.key); - var entry = kv.value; - self.freeEntry(&entry); - } - } - - return gop.value_ptr; } }; From df42a26a399281a4498e1c779cf4056261325a76 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 21 Sep 2026 07:20:17 -0700 Subject: [PATCH 16/93] better dispatch of currententrychange event --- src/browser/webapi/navigation/Navigation.zig | 55 +++++++++++++------- 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/src/browser/webapi/navigation/Navigation.zig b/src/browser/webapi/navigation/Navigation.zig index 63be0bde0..20fbe9328 100644 --- a/src/browser/webapi/navigation/Navigation.zig +++ b/src/browser/webapi/navigation/Navigation.zig @@ -144,10 +144,20 @@ pub fn updateEntries( ) !void { switch (kind) { .replace => |state| { - _ = try self.replaceEntry(url, .{ .source = .navigation, .value = state }, frame, should_dispatch); + _ = try self.replaceEntry( + url, + .{ .source = .navigation, .value = state }, + frame, + should_dispatch, + ); }, .push => |state| { - _ = try self.pushEntry(url, .{ .source = .navigation, .value = state }, frame, should_dispatch); + _ = try self.pushEntry( + url, + .{ .source = .navigation, .value = state }, + frame, + should_dispatch, + ); }, .traverse => |index| { self._index = index; @@ -299,21 +309,30 @@ fn fireCurrentEntryChangeEvent( kind: ?NavigationKind, frame: *Frame, ) void { - if (self._on_currententrychange) |cec| { - const event = - NavigationCurrentEntryChangeEvent.initTrusted( - .wrap("currententrychange"), - .{ .from = previous, .navigationType = if (kind) |k| @tagName(k) else null }, - frame, - ) catch |err| { - log.warn(.event, "Navigation.fireCurrentEntryChange", .{ .err = err }); - return; - }; - - self.dispatch(cec, event.asEvent(), frame) catch |err| { - log.warn(.event, "Navigation.fireCurrentEntryChange dispatch", .{ .err = err }); - }; + if (!frame.hasDirectListeners( + self.asEventTarget(), + "currententrychange", + self._on_currententrychange, + )) { + return; } + + const event = + NavigationCurrentEntryChangeEvent.initTrusted( + .wrap("currententrychange"), + .{ + .from = previous, + .navigationType = if (kind) |k| @tagName(k) else null, + }, + frame, + ) catch |err| { + log.warn(.event, "Navigation.fireCurrentEntryChange", .{ .err = err }); + return; + }; + + self.dispatch(self._on_currententrychange, event.asEvent(), frame) catch |err| { + log.warn(.event, "Navigation.fireCurrentEntryChange dispatch", .{ .err = err }); + }; } const NavigateOptions = struct { @@ -483,8 +502,8 @@ fn updateCurrentEntry(self: *Navigation, options: UpdateCurrentEntryOptions, fra self.fireCurrentEntryChangeEvent(previous, null, frame); } -pub fn dispatch(self: *Navigation, func: js.Function.Global, event: *Event, frame: *Frame) !void { - return frame._event_manager.dispatchDirect( +pub fn dispatch(self: *Navigation, func: ?js.Function.Global, event: *Event, frame: *Frame) !void { + return frame.dispatch( self.asEventTarget(), event, func, From 8642328aa1182564237a77a925a5fb45570a5830 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 17 Sep 2026 22:57:54 -0700 Subject: [PATCH 17/93] add navigationsuccess and navigationerror to Navigation --- src/browser/webapi/navigation/Navigation.zig | 100 +++++++++++++++++-- 1 file changed, 93 insertions(+), 7 deletions(-) diff --git a/src/browser/webapi/navigation/Navigation.zig b/src/browser/webapi/navigation/Navigation.zig index 20fbe9328..ead126b9f 100644 --- a/src/browser/webapi/navigation/Navigation.zig +++ b/src/browser/webapi/navigation/Navigation.zig @@ -26,6 +26,7 @@ const Factory = @import("../../Factory.zig"); const Event = @import("../Event.zig"); const EventTarget = @import("../EventTarget.zig"); +const ErrorEvent = @import("../event/ErrorEvent.zig"); const log = lp.log; @@ -44,6 +45,8 @@ const NavigationCurrentEntryChangeEvent = @import("../event/NavigationCurrentEnt _proto: *EventTarget, _on_currententrychange: ?js.Function.Global = null, +_on_navigatesuccess: ?js.Function.Global = null, +_on_navigateerror: ?js.Function.Global = null, _current_navigation_kind: ?NavigationKind = null, @@ -66,6 +69,12 @@ pub fn onRemoveFrame(self: *Navigation) void { if (self._on_currententrychange) |cb| cb.release(); self._on_currententrychange = null; + if (self._on_navigatesuccess) |cb| cb.release(); + self._on_navigatesuccess = null; + + if (self._on_navigateerror) |cb| cb.release(); + self._on_navigateerror = null; + for (self._entries.items) |entry| { if (entry._on_dispose) |cb| cb.release(); entry._on_dispose = null; @@ -303,6 +312,48 @@ pub fn replaceEntry( return entry; } +fn fireNavigateSuccess(self: *Navigation, frame: *Frame) void { + if (self._on_navigatesuccess) |ons| { + const event = Event.initTrusted( + .wrap("navigatesuccess"), + null, + frame.page, + ) catch |err| { + log.warn(.event, "Navigation.fireNavigateSuccess", .{ .err = err }); + return; + }; + + self.dispatch(ons, event, frame) catch |err| { + log.warn(.event, "Navigation.fireNavigateSuccess dispatch", .{ .err = err }); + }; + } +} + +fn fireNavigateError(self: *Navigation, reason: js.Value, frame: *Frame) void { + if (self._on_navigateerror) |one| { + const message = std.fmt.allocPrint(frame.call_arena, "{f}", .{reason}) catch "navigate error"; + + const err_event = ErrorEvent.initTrusted( + .wrap("navigateerror"), + .{ + .message = message, + .filename = frame.url, + .lineno = 0, + .colno = 0, + .@"error" = reason.persist() catch null, + }, + frame.page, + ) catch |err| { + log.warn(.event, "Navigation.fireNavigateError", .{ .err = err }); + return; + }; + + self.dispatch(one, err_event.asEvent(), frame) catch |err| { + log.warn(.event, "Navigation.fireNavigateError dispatch", .{ .err = err }); + }; + } +} + fn fireCurrentEntryChangeEvent( self: *Navigation, previous: *NavigationHistoryEntry, @@ -335,6 +386,16 @@ fn fireCurrentEntryChangeEvent( }; } +fn resolveFinished( + self: *Navigation, + resolver: js.PromiseResolver, + comptime source: []const u8, + frame: *Frame, +) void { + resolver.resolve(source, {}); + self.fireNavigateSuccess(frame); +} + const NavigateOptions = struct { history: ?[]const u8 = null, info: ?js.Value = null, @@ -381,9 +442,8 @@ pub fn navigateInner( committed.resolve("navigation push", {}); // todo: Fire navigate event - finished.resolve("navigation push", {}); - _ = try self.pushEntry(url, .{ .source = .navigation, .value = state }, frame, true); + self.resolveFinished(finished, "navigation push", frame); } else { try frame.scheduleNavigation(url, .{ .reason = .navigation, .kind = kind }, .{ .script = frame }); } @@ -394,9 +454,8 @@ pub fn navigateInner( committed.resolve("navigation replace", {}); // todo: Fire navigate event - finished.resolve("navigation replace", {}); - _ = try self.replaceEntry(url, .{ .source = .navigation, .value = state }, frame, true); + self.resolveFinished(finished, "navigation replace", frame); } else { try frame.scheduleNavigation(url, .{ .reason = .navigation, .kind = kind }, .{ .script = frame }); } @@ -409,7 +468,8 @@ pub fn navigateInner( committed.resolve("navigation traverse", {}); // todo: Fire navigate event - finished.resolve("navigation traverse", {}); + self.fireCurrentEntryChangeEvent(previous, kind, frame); + self.resolveFinished(finished, "navigation traverse", frame); } else { try frame.scheduleNavigation(url, .{ .reason = .navigation, .kind = kind }, .{ .script = frame }); } @@ -423,8 +483,6 @@ pub fn navigateInner( try frame.queueHashChange(old_url, new_url); } - self.fireCurrentEntryChangeEvent(previous, kind, frame); - _ = try committed.persist(); _ = try finished.persist(); return .{ @@ -524,6 +582,24 @@ fn setOnCurrentEntryChange(self: *Navigation, listener: ?js.Function) !void { } } +fn getOnNavigateSuccess(self: *Navigation) ?js.Function.Global { + return self._on_navigatesuccess; +} + +fn setOnNavigateSuccess(self: *Navigation, listener: ?js.Function) !void { + if (self._on_navigatesuccess) |old| old.release(); + self._on_navigatesuccess = if (listener) |l| try l.persistWithThis(self) else null; +} + +fn getOnNavigateError(self: *Navigation) ?js.Function.Global { + return self._on_navigateerror; +} + +fn setOnNavigateError(self: *Navigation, listener: ?js.Function) !void { + if (self._on_navigateerror) |old| old.release(); + self._on_navigateerror = if (listener) |l| try l.persistWithThis(self) else null; +} + pub const JsApi = struct { pub const bridge = js.Bridge(Navigation); @@ -551,6 +627,16 @@ pub const JsApi = struct { Navigation.setOnCurrentEntryChange, .{}, ); + pub const onnavigatesuccess = bridge.accessor( + Navigation.getOnNavigateSuccess, + Navigation.setOnNavigateSuccess, + .{}, + ); + pub const onnavigateerror = bridge.accessor( + Navigation.getOnNavigateError, + Navigation.setOnNavigateError, + .{}, + ); }; const testing = @import("../../../testing.zig"); From 4f8536077e32443856cbd01147a03a0e752099c4 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 21 Sep 2026 07:28:03 -0700 Subject: [PATCH 18/93] fix fireNavigationSuccess --- src/browser/webapi/navigation/Navigation.zig | 53 +++++++------------- 1 file changed, 17 insertions(+), 36 deletions(-) diff --git a/src/browser/webapi/navigation/Navigation.zig b/src/browser/webapi/navigation/Navigation.zig index ead126b9f..508a3c2b7 100644 --- a/src/browser/webapi/navigation/Navigation.zig +++ b/src/browser/webapi/navigation/Navigation.zig @@ -313,45 +313,26 @@ pub fn replaceEntry( } fn fireNavigateSuccess(self: *Navigation, frame: *Frame) void { - if (self._on_navigatesuccess) |ons| { - const event = Event.initTrusted( - .wrap("navigatesuccess"), - null, - frame.page, - ) catch |err| { - log.warn(.event, "Navigation.fireNavigateSuccess", .{ .err = err }); - return; - }; - - self.dispatch(ons, event, frame) catch |err| { - log.warn(.event, "Navigation.fireNavigateSuccess dispatch", .{ .err = err }); - }; + if (!frame.hasDirectListeners( + self.asEventTarget(), + "navigatesuccess", + self._on_navigatesuccess, + )) { + return; } -} -fn fireNavigateError(self: *Navigation, reason: js.Value, frame: *Frame) void { - if (self._on_navigateerror) |one| { - const message = std.fmt.allocPrint(frame.call_arena, "{f}", .{reason}) catch "navigate error"; + const event = Event.initTrusted( + .wrap("navigatesuccess"), + null, + frame.page, + ) catch |err| { + log.warn(.event, "Navigation.fireNavigateSuccess", .{ .err = err }); + return; + }; - const err_event = ErrorEvent.initTrusted( - .wrap("navigateerror"), - .{ - .message = message, - .filename = frame.url, - .lineno = 0, - .colno = 0, - .@"error" = reason.persist() catch null, - }, - frame.page, - ) catch |err| { - log.warn(.event, "Navigation.fireNavigateError", .{ .err = err }); - return; - }; - - self.dispatch(one, err_event.asEvent(), frame) catch |err| { - log.warn(.event, "Navigation.fireNavigateError dispatch", .{ .err = err }); - }; - } + self.dispatch(self._on_navigatesuccess, event, frame) catch |err| { + log.warn(.event, "Navigation.fireNavigateSuccess dispatch", .{ .err = err }); + }; } fn fireCurrentEntryChangeEvent( From 9b5434e25a97e3b98f5bacf339b58278f2d1b1d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 21 Sep 2026 17:37:26 +0200 Subject: [PATCH 19/93] scroll: clamp offsets to the scrollable extent Every scroll write clamped at zero and nothing else, so an offset could exceed the scrollable extent without limit and a page probing `scrollTop >= scrollHeight - clientHeight` got a number Chrome would never produce. Element.scrollExtent is that bound, and setScrollTop/setScrollLeft/ scrollTo/scrollBy now share one writer that applies it. The extent is optional and null means unbounded: without a layout engine there is no honest box for an element sized by a stylesheet (getElementAxis reads only inline width/height) or one holding text (contentAxis sums element children), and refusing a scroll we can't prove impossible is worse than allowing one too many. html and body are excluded outright, so the viewport keeps its fabricated extent and stays unbounded. Chrome clamps all three, so the HTML fixture asserts the limit relationally - a real browser reserves scrollbar space in clientHeight and lands a few px lower. The gap we keep is pinned in a Zig test instead. The write path also does its arithmetic in i64: the old relative path could panic on an offset stored above maxInt(i32). --- src/browser/tests/element/position.html | 29 ++++ src/browser/webapi/Element.zig | 191 +++++++++++++++++------- src/browser/webapi/Window.zig | 6 +- 3 files changed, 171 insertions(+), 55 deletions(-) diff --git a/src/browser/tests/element/position.html b/src/browser/tests/element/position.html index 291bf60f3..e6821653f 100644 --- a/src/browser/tests/element/position.html +++ b/src/browser/tests/element/position.html @@ -600,3 +600,32 @@ testing.expectEqual(targetY, window.scrollY); } + +
+
content
+
+ + diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index f3a644b45..d8546f79e 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -1578,16 +1578,7 @@ pub fn getScrollTop(self: *Element, frame: *Frame) u32 { } pub fn setScrollTop(self: *Element, value: i32, frame: *Frame) !void { - const owner = self.ownerFrame(frame) orelse return; - const gop = try owner.page.element_scroll_positions.getOrPut(owner.page.frame_arena, self); - if (!gop.found_existing) { - gop.value_ptr.* = .{}; - } - const new_y: u32 = @intCast(@max(0, value)); - if (gop.value_ptr.y != new_y) { - gop.value_ptr.y = new_y; - try self.scheduleScrollEvents(owner); - } + return self.writeScroll(.{ .to = .{ .left = null, .top = value } }, frame); } pub fn getScrollLeft(self: *Element, frame: *Frame) u32 { @@ -1597,16 +1588,7 @@ pub fn getScrollLeft(self: *Element, frame: *Frame) u32 { } pub fn setScrollLeft(self: *Element, value: i32, frame: *Frame) !void { - const owner = self.ownerFrame(frame) orelse return; - const gop = try owner.page.element_scroll_positions.getOrPut(owner.page.frame_arena, self); - if (!gop.found_existing) { - gop.value_ptr.* = .{}; - } - const new_x: u32 = @intCast(@max(0, value)); - if (gop.value_ptr.x != new_x) { - gop.value_ptr.x = new_x; - try self.scheduleScrollEvents(owner); - } + return self.writeScroll(.{ .to = .{ .left = value, .top = null } }, frame); } pub const ScrollAxes = struct { x: bool = false, y: bool = false }; @@ -1616,14 +1598,6 @@ pub const ScrollAxes = struct { x: bool = false, y: bool = false }; const ScrollTarget = union(enum) { viewport, container: *Element, - - pub fn scrollBy(self: ScrollTarget, left: i32, top: i32, frame: *Frame) !void { - const opts: ScrollToOpts = .{ .opts = .{ .left = left, .top = top } }; - return switch (self) { - .container => |el| el.scrollBy(opts, null, frame), - .viewport => frame.window.scrollBy(opts, null, frame), - }; - } }; /// Nearest ancestor-or-self scroll container along any of `axes`. The walk @@ -1643,6 +1617,14 @@ pub fn scrollContainer(self: *Element, axes: ScrollAxes, frame: *Frame) ScrollTa return .viewport; } +/// Whether the element's own overscroll-behavior keeps a scroll from chaining +/// out of it along any of `axes`. +pub fn containsOverscroll(self: *Element, axes: ScrollAxes, frame: *Frame) bool { + const owner = self.ownerFrame(frame) orelse return false; + const contains = owner._style_manager.overscrollContainAxes(self); + return (axes.x and contains.x) or (axes.y and contains.y); +} + fn scrollsViewport(self: *const Element) bool { return switch (self.getTag()) { .html, .body => true, @@ -1685,6 +1667,29 @@ pub fn getScrollWidth(self: *Element, frame: *Frame) f64 { return @max(width, self.contentAxis(frame, .width)); } +/// The furthest offset a scroll along `axis` may reach, or null when there is +/// no box to measure against. Without an explicit size the client and the +/// content measurements collapse onto the same sum, so nothing can overflow: +/// an element sized by a stylesheet or holding only text stays unbounded, as +/// every scroll write was before there was an extent at all. Refusing a scroll +/// we can't prove impossible is worse than allowing one too many. html and +/// body are out too: their artificial giant defaults would fabricate an extent +/// against the real viewport. +fn scrollExtent(self: *Element, frame: *Frame, comptime axis: Axis) ?f64 { + if (self.scrollsViewport() or !self.getElementAxis(frame, axis).explicit) { + return null; + } + const client = self.clientAxis(frame, axis); + const content = switch (axis) { + .width => self.getScrollWidth(frame), + .height => self.getScrollHeight(frame), + }; + if (content <= client) { + return null; + } + return content - client; +} + // One axis of the direct child elements' size: laid end to end on a single // row for the width, stacked for the height. // @@ -2014,23 +2019,64 @@ pub const ScrollToOpts = union(enum) { pub fn scrollTo(self: *Element, opts: ?ScrollToOpts, y: ?i32, frame: *Frame) !void { const o = (opts orelse return).offsets(y); - const owner = self.ownerFrame(frame) orelse return; - const gop = try owner.page.element_scroll_positions.getOrPut(owner.page.frame_arena, self); - if (!gop.found_existing) { - gop.value_ptr.* = .{}; - } - const old_x = gop.value_ptr.x; - const old_y = gop.value_ptr.y; - if (o.left) |left| gop.value_ptr.x = @intCast(@max(0, left)); - if (o.top) |top| gop.value_ptr.y = @intCast(@max(0, top)); - if (gop.value_ptr.x != old_x or gop.value_ptr.y != old_y) { - try self.scheduleScrollEvents(owner); - } + return self.writeScroll(.{ .to = o }, frame); } // scrollBy(): like scrollTo() but relative to the current position. pub fn scrollBy(self: *Element, opts: ?ScrollToOpts, y: ?i32, frame: *Frame) !void { const o = (opts orelse return).offsets(y); + return self.writeScroll(.{ .by = o }, frame); +} + +/// Scrolls one axis by `delta`. +pub fn scrollByAxis(self: *Element, comptime axis: Axis, delta: i32, frame: *Frame) !void { + return self.writeScroll(.{ .by = switch (axis) { + .width => .{ .left = delta, .top = null }, + .height => .{ .left = null, .top = delta }, + } }, frame); +} + +/// Whether `delta` can move this container along `axis` at all. A wheel latches +/// to the nearest container for which this holds; an unmeasurable box has no +/// end to be at, so it always takes the delta. +pub fn canScrollAxis(self: *Element, comptime axis: Axis, delta: i32, frame: *Frame) bool { + const offset: i64 = switch (axis) { + .width => self.getScrollLeft(frame), + .height => self.getScrollTop(frame), + }; + if (delta < 0) { + return offset > 0; + } + const extent = self.scrollExtent(frame, axis) orelse return true; + const max: i64 = @floor(extent); + return offset < max; +} + +// Where a write puts the offsets: at an absolute position, or that much from +// wherever they are. +const ScrollWrite = union(enum) { + to: ScrollToOpts.Offsets, + by: ScrollToOpts.Offsets, + + // The absolute target for one axis, null when the write leaves it alone. + fn target(self: ScrollWrite, comptime axis: Axis, current: u32) ?i64 { + const offsets = switch (self) { + inline else => |o| o, + }; + const value = switch (axis) { + .width => offsets.left, + .height => offsets.top, + } orelse return null; + return switch (self) { + .to => value, + .by => @as(i64, current) + value, + }; + } +}; + +/// The single scroll write: clamps both axes, stores, and schedules the events +/// once for the pair. +fn writeScroll(self: *Element, write: ScrollWrite, frame: *Frame) !void { const owner = self.ownerFrame(frame) orelse return; const gop = try owner.page.element_scroll_positions.getOrPut(owner.page.frame_arena, self); if (!gop.found_existing) { @@ -2038,31 +2084,46 @@ pub fn scrollBy(self: *Element, opts: ?ScrollToOpts, y: ?i32, frame: *Frame) !vo } const old_x = gop.value_ptr.x; const old_y = gop.value_ptr.y; - gop.value_ptr.x = @intCast(@max(0, @as(i32, @intCast(gop.value_ptr.x)) +| (o.left orelse 0))); - gop.value_ptr.y = @intCast(@max(0, @as(i32, @intCast(gop.value_ptr.y)) +| (o.top orelse 0))); - if (gop.value_ptr.x != old_x or gop.value_ptr.y != old_y) { - try self.scheduleScrollEvents(owner); + + if (write.target(.width, old_x)) |target| { + gop.value_ptr.x = self.clampScroll(frame, .width, target); } + if (write.target(.height, old_y)) |target| { + gop.value_ptr.y = self.clampScroll(frame, .height, target); + } + + if (gop.value_ptr.x != old_x or gop.value_ptr.y != old_y) { + try self.scheduleScrollEvents(gop.value_ptr, owner); + } +} + +/// `target` brought into [0, scrollExtent]. +fn clampScroll(self: *Element, frame: *Frame, comptime axis: Axis, target: i64) u32 { + var clamped = target; + if (clamped < 0) { + clamped = 0; + } else if (self.scrollExtent(frame, axis)) |extent| { + const max: i64 = @floor(extent); + clamped = @min(clamped, max); + } + return @intCast(@min(clamped, std.math.maxInt(u32))); } // Scrolling an element fires a scroll event and then a scrollend event, // asynchronously and throttled, mirroring Window.scrollTo. Scrolls of the // scrolling element (the root) are fired at the document instead. -// `frame` is the element's owner frame (resolved by the public accessors). -fn scheduleScrollEvents(self: *Element, frame: *Frame) !void { - const gop = try frame.page.element_scroll_positions.getOrPut(frame.page.frame_arena, self); - if (!gop.found_existing) { - gop.value_ptr.* = .{}; - } - const task_pending = gop.value_ptr.state != .done; - gop.value_ptr.state = .scroll; +// `frame` is the element's owner frame, `pos` its entry in that frame's +// positions (both resolved by writeScroll). +fn scheduleScrollEvents(self: *Element, pos: *ScrollPosition, frame: *Frame) !void { + const task_pending = pos.state != .done; + pos.state = .scroll; if (task_pending) { return; } const task = try frame._factory.create(ScrollEventTask{ .frame = frame, .element = self }); errdefer { - gop.value_ptr.state = .done; + pos.state = .done; frame._factory.destroy(task); } try frame.js.scheduler.add(task, ScrollEventTask.run, 10, .{ @@ -2722,6 +2783,30 @@ test "WebApi: Element" { try testing.htmlRunner("element", .{}); } +test "Element: scroll extent" { + const frame = try testing.createFrame(); + defer testing.test_session.closeAllPages(); + + const root = try frame.window._document.createElement("div", null, frame); + try Frame.parse.htmlAsChildren(frame, root.asNode(), + \\
+ \\
text, and no element child to measure
+ ); + const box = root.asNode().firstChild().?.as(Element); + const text = box.nextElementSibling().?; + + try box.setScrollTop(9999, frame); + try testing.expectEqual(400, box.getScrollTop(frame)); + try box.setScrollTop(-1, frame); + try testing.expectEqual(0, box.getScrollTop(frame)); + + // A real browser clamps this one too, to the height of its text. contentAxis + // sums element children only, so we have no extent to clamp against and the + // offset stays unbounded. + try text.setScrollTop(9999, frame); + try testing.expectEqual(9999, text.getScrollTop(frame)); +} + test "Element: div chain slot size" { // Guard against accidental growth: new Element fields (e.g. _flags) must // fit in existing padding. Debug is larger from the _proto_canary fields. diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 5118fb774..c60f48b4c 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -1016,8 +1016,10 @@ pub fn scrollTo(self: *Window, opts: Element.ScrollToOpts, y: ?i32, frame: *Fram pub fn scrollBy(self: *Window, opts: Element.ScrollToOpts, y: ?i32, frame: *Frame) !void { const o = opts.offsets(y); - const absx = @as(i32, @intCast(self._scroll_pos.x)) +| (o.left orelse 0); - const absy = @as(i32, @intCast(self._scroll_pos.y)) +| (o.top orelse 0); + // The viewport has no honest extent, so a stored offset can sit above + // maxInt(i32): widen before saturating back down. + const absx: i32 = @intCast(@min(@as(i64, self._scroll_pos.x) + (o.left orelse 0), std.math.maxInt(i32))); + const absy: i32 = @intCast(@min(@as(i64, self._scroll_pos.y) + (o.top orelse 0), std.math.maxInt(i32))); return self.scrollTo(.{ .x = absx }, absy, frame); } From e4df45a967ca506a7b87e8d2b9cb9e981ac68c31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 21 Sep 2026 17:37:26 +0200 Subject: [PATCH 20/93] StyleManager: track overscroll-behavior Chaining a wheel out of a saturated container is exactly what sites use `overscroll-behavior: contain` to prevent, so the cascade needs to know about it before the wheel can chain. Two flags follow the overflow-x/overflow-y pattern: a shorthand arm in Slots.apply covers both fold paths, and overscrollContainAxes is the probe. `contain` and `none` both stop propagation, only `auto` lets it through. Props was exactly full at u8. splitOverflow serves two shorthands now, so it is splitAxisPair. --- src/browser/StyleManager.zig | 57 ++++++++++++++++--- src/browser/css/Parser.zig | 9 +-- .../webapi/css/CSSStyleDeclaration.zig | 4 +- 3 files changed, 57 insertions(+), 13 deletions(-) diff --git a/src/browser/StyleManager.zig b/src/browser/StyleManager.zig index 894a73913..d3f529d9c 100644 --- a/src/browser/StyleManager.zig +++ b/src/browser/StyleManager.zig @@ -654,7 +654,7 @@ fn rebuildIfDirty(self: *StyleManager) !void { /// Own-element cascade result, resolved for every property at once so one /// entry serves any probe. -const Props = packed struct(u8) { +const Props = packed struct(u10) { // Author value (inline or sheet). Without `author_display` it's the UA // fallback: .none when matchesUaDisplayNoneRule, else .other. display: Display = .other, @@ -664,6 +664,8 @@ const Props = packed struct(u8) { pointer_events_none: bool = false, overflow_x_scrolls: bool = false, overflow_y_scrolls: bool = false, + overscroll_x_contains: bool = false, + overscroll_y_contains: bool = false, fn probe(self: Props, comptime what: Probe, options: CheckVisibilityOptions) bool { return switch (what) { @@ -737,6 +739,15 @@ pub fn overflowAxes(self: *StyleManager, el: *Element) Element.ScrollAxes { return .{ .x = p.overflow_x_scrolls, .y = p.overflow_y_scrolls }; } +/// The axes along which `el` keeps a scroll from chaining out of it: its own +/// computed overscroll-behavior on that axis is contain or none. No ancestor +/// walk. +pub fn overscrollContainAxes(self: *StyleManager, el: *Element) Element.ScrollAxes { + self.rebuildIfDirty() catch return .{}; + const p = self.ownProps(el); + return .{ .x = p.overscroll_x_contains, .y = p.overscroll_y_contains }; +} + fn anyInChain(self: *StyleManager, el: *Element, comptime what: Probe, options: CheckVisibilityOptions) bool { var current: ?*Element = el; while (current) |elem| : (current = elem.parentElement()) { @@ -776,6 +787,8 @@ const Priorities = struct { pointer_events_none: u64 = 0, overflow_x_scrolls: u64 = 0, overflow_y_scrolls: u64 = 0, + overscroll_x_contains: u64 = 0, + overscroll_y_contains: u64 = 0, }; fn compute(self: *StyleManager, el: *Element) Props { @@ -1020,7 +1033,7 @@ fn getBucketKey(compound: Selector.Compound) ?BucketKey { } // The declaration names behind TrackedProperties, in field order. -const property_names = [_][]const u8{ "display", "visibility", "opacity", "pointer-events", "overflow-x", "overflow-y" }; +const property_names = [_][]const u8{ "display", "visibility", "opacity", "pointer-events", "overflow-x", "overflow-y", "overscroll-behavior-x", "overscroll-behavior-y" }; /// Extracts the tracked properties from a style declaration. The object holds /// one entry per name in first-declared order, so folding it in order gives a @@ -1113,6 +1126,8 @@ const TrackedProperties = struct { pointer_events_none: ?bool = null, overflow_x_scrolls: ?bool = null, overflow_y_scrolls: ?bool = null, + overscroll_x_contains: ?bool = null, + overscroll_y_contains: ?bool = null, fn apply(self: *TrackedProperties, name: []const u8, value: []const u8) void { if (std.ascii.eqlIgnoreCase(name, "display")) { @@ -1127,6 +1142,10 @@ const TrackedProperties = struct { self.overflow_x_scrolls = overflowScrolls(value); } else if (std.ascii.eqlIgnoreCase(name, "overflow-y")) { self.overflow_y_scrolls = overflowScrolls(value); + } else if (std.ascii.eqlIgnoreCase(name, "overscroll-behavior-x")) { + self.overscroll_x_contains = overscrollContains(value); + } else if (std.ascii.eqlIgnoreCase(name, "overscroll-behavior-y")) { + self.overscroll_y_contains = overscrollContains(value); } } @@ -1137,6 +1156,13 @@ const TrackedProperties = struct { std.ascii.eqlIgnoreCase(value, "overlay"); } + // `contain` keeps the scroll in the box, `none` also kills the bounce we + // don't render anyway; only `auto` lets a scroll chain outward. + fn overscrollContains(value: []const u8) bool { + return std.ascii.eqlIgnoreCase(value, "contain") or + std.ascii.eqlIgnoreCase(value, "none"); + } + fn isRelevant(self: TrackedProperties) bool { inline for (property_fields) |field| { if (@field(self, field) != null) { @@ -1277,6 +1303,12 @@ fn foldDeclarations(block: []const u8, customs: ?*CustomSink) !TrackedProperties return slots.props(); } +// The ` []` shorthands the cascade expands into the tracked longhands. +const axis_shorthands = [_]struct { name: []const u8, x: []const u8, y: []const u8 }{ + .{ .name = "overflow", .x = "overflow-x", .y = "overflow-y" }, + .{ .name = "overscroll-behavior", .x = "overscroll-behavior-x", .y = "overscroll-behavior-y" }, +}; + /// One block's winning value per tracked property, folded in declaration /// order. const Slots = struct { @@ -1300,11 +1332,13 @@ const Slots = struct { slots: [property_names.len]Slot = @splat(.{}), fn apply(self: *Slots, name: []const u8, value: []const u8, important: bool) void { - if (std.ascii.eqlIgnoreCase(name, "overflow")) { - const values = CssParser.splitOverflow(value) orelse return; - self.apply("overflow-x", values.x, important); - self.apply("overflow-y", values.y, important); - return; + for (axis_shorthands) |shorthand| { + if (std.ascii.eqlIgnoreCase(name, shorthand.name)) { + const values = CssParser.splitAxisPair(value) orelse return; + self.apply(shorthand.x, values.x, important); + self.apply(shorthand.y, values.y, important); + return; + } } for (property_names, &self.slots) |tracked, *slot| { if (std.ascii.eqlIgnoreCase(name, tracked)) { @@ -1752,6 +1786,15 @@ test "StyleManager: memo: reuse and invalidation" { try (try b.getOrCreateStyle(frame)).asCSSStyleDeclaration().setProperty("overflow", "hidden", null, frame); try testing.expectEqual(Element.ScrollAxes{}, sm.overflowAxes(b)); + // overscroll-behavior expands the same way; only `auto` chains outward. + try b.setStyle("overscroll-behavior: contain auto", frame); + try testing.expectEqual(Element.ScrollAxes{ .x = true, .y = false }, sm.overscrollContainAxes(b)); + try testing.expectEqual(Element.ScrollAxes{}, sm.overscrollContainAxes(p)); + try b.setStyle("overscroll-behavior-y: none", frame); + try testing.expectEqual(Element.ScrollAxes{ .x = false, .y = true }, sm.overscrollContainAxes(b)); + try b.setStyle("overscroll-behavior: contain; overscroll-behavior-x: auto", frame); + try testing.expectEqual(Element.ScrollAxes{ .x = false, .y = true }, sm.overscrollContainAxes(b)); + // A stylesheet change resets the memo sm.sheetModified(); try testing.expectEqual(false, sm.isHidden(p, .{})); diff --git a/src/browser/css/Parser.zig b/src/browser/css/Parser.zig index dc8806dae..8cda3dcc2 100644 --- a/src/browser/css/Parser.zig +++ b/src/browser/css/Parser.zig @@ -25,11 +25,12 @@ pub const Declaration = struct { important: bool, }; -pub const OverflowValues = struct { x: []const u8, y: []const u8 }; +pub const AxisPair = struct { x: []const u8, y: []const u8 }; -/// `overflow: []`; a single value applies to both axes. More than two -/// values is invalid and null, as is an empty declaration. -pub fn splitOverflow(value: []const u8) ?OverflowValues { +/// An ` []` axis shorthand such as `overflow` or `overscroll-behavior`; +/// a single value applies to both axes. More than two values is invalid and +/// null, as is an empty declaration. +pub fn splitAxisPair(value: []const u8) ?AxisPair { var it = std.mem.tokenizeAny(u8, value, &std.ascii.whitespace); const x = it.next() orelse return null; const y = it.next() orelse x; diff --git a/src/browser/webapi/css/CSSStyleDeclaration.zig b/src/browser/webapi/css/CSSStyleDeclaration.zig index 1eb09385a..7b0abf2b7 100644 --- a/src/browser/webapi/css/CSSStyleDeclaration.zig +++ b/src/browser/webapi/css/CSSStyleDeclaration.zig @@ -241,7 +241,7 @@ pub fn setProperty(self: *CSSStyleDeclaration, property_name: []const u8, value: fn applyParsedDeclaration(self: *CSSStyleDeclaration, declaration: CssParser.Declaration, frame: *Frame) !void { const normalized = normalizePropertyName(declaration.name, &frame.buf); if (overflow_shorthand.eqlSlice(normalized)) { - const values = CssParser.splitOverflow(declaration.value) orelse return; + const values = CssParser.splitAxisPair(declaration.value) orelse return; try self.applyParsedDeclaration(.{ .name = "overflow-x", .value = values.x, .important = declaration.important }, frame); try self.applyParsedDeclaration(.{ .name = "overflow-y", .value = values.y, .important = declaration.important }, frame); return; @@ -267,7 +267,7 @@ fn setPropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, value: const normalized = normalizePropertyName(property_name, &frame.buf); if (overflow_shorthand.eqlSlice(normalized)) { - const values = CssParser.splitOverflow(value) orelse return false; + const values = CssParser.splitAxisPair(value) orelse return false; const x = try self.setPropertyImpl("overflow-x", values.x, important, frame); const y = try self.setPropertyImpl("overflow-y", values.y, important, frame); return x or y; From 689045d72aa0c6381ce3e25fd1015e9eb387a98a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 21 Sep 2026 17:37:26 +0200 Subject: [PATCH 21/93] user_input: latch a wheel to one scroll container wheelScroll handed the whole delta to the nearest scroll container on each axis, whatever state it was in, so a saturated inner scroller trapped the wheel and the page never moved. scrollAxis walks outward per axis and gives the whole delta to the first container that can still move along it. A delta is never split: a container that can only take part of it keeps the rest, and the page moves on the next wheel. That is Chrome's rule in FindNodeToLatch (cc/input/input_handler.cc), confirmed against Chrome 153 - one wheel of 1000px over a container with 416px of travel leaves window.scrollY at 0. A container whose overscroll-behavior doesn't propagate takes the latch even when it can't move, which ends the walk. --- src/browser/frame/user_input.zig | 46 ++++++++++++++++++---- src/server/cdp/domains/input.zig | 66 ++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 7 deletions(-) diff --git a/src/browser/frame/user_input.zig b/src/browser/frame/user_input.zig index 961a47ff0..5e6b621ac 100644 --- a/src/browser/frame/user_input.zig +++ b/src/browser/frame/user_input.zig @@ -487,16 +487,48 @@ pub fn wheel(frame: *Frame, target: *Element, x: f64, y: f64, delta_x: f64, delt } // Deltas come from the wire, so guard NaN and saturate the addition. - try wheelScroll(target, deltaToScroll(delta_x), deltaToScroll(delta_y), owner); + try scrollAxis(target, .width, deltaToScroll(delta_x), owner); + try scrollAxis(target, .height, deltaToScroll(delta_y), owner); } -/// Each axis scrolls the nearest ancestor-or-self scroll container along it, -/// else the viewport. Relative deltas may land on different scrollers per +/// One axis' delta goes to the nearest ancestor-or-self scroll container that +/// can still move along it, and to that one alone: a wheel latches to a single +/// scroller and a delta is never split across two, matching Chrome's +/// FindNodeToLatch (cc/input/input_handler.cc). A container whose +/// overscroll-behavior doesn't propagate takes the latch even when it can't +/// move, which ends the walk. The viewport terminates it otherwise. +/// +/// Each axis walks on its own, so a wheel may latch to a different scroller per /// axis, unlike an absolute position. -fn wheelScroll(target: *Element, delta_x: i32, delta_y: i32, frame: *Frame) !void { - // A zero delta resolves to .viewport and scrolls it by nothing. - try target.scrollContainer(.{ .x = delta_x != 0 }, frame).scrollBy(delta_x, 0, frame); - try target.scrollContainer(.{ .y = delta_y != 0 }, frame).scrollBy(0, delta_y, frame); +fn scrollAxis(target: *Element, comptime axis: Element.Axis, delta: i32, frame: *Frame) !void { + if (delta == 0) { + return; + } + const axes: Element.ScrollAxes = switch (axis) { + .width => .{ .x = true }, + .height => .{ .y = true }, + }; + + var current: ?*Element = target; + while (current) |el| { + const container = switch (el.scrollContainer(axes, frame)) { + .container => |c| c, + .viewport => break, + }; + if (container.canScrollAxis(axis, delta, frame)) { + return container.scrollByAxis(axis, delta, frame); + } + if (container.containsOverscroll(axes, frame)) { + return; + } + current = container.parentElement(); + } + + const opts: Element.ScrollToOpts = switch (axis) { + .width => .{ .opts = .{ .left = delta } }, + .height => .{ .opts = .{ .top = delta } }, + }; + return frame.window.scrollBy(opts, null, frame); } fn deltaToScroll(d: f64) i32 { diff --git a/src/server/cdp/domains/input.zig b/src/server/cdp/domains/input.zig index 492d604de..5dcdfb190 100644 --- a/src/server/cdp/domains/input.zig +++ b/src/server/cdp/domains/input.zig @@ -514,6 +514,72 @@ test "cdp.input: dispatchMouseEvent mouseWheel scrolls a scroll container, not t try runner.waitForScript(frame._frame_id, "window.sheetScrolled === true", 1000); } +test "cdp.input: dispatchMouseEvent mouseWheel chains once the container is saturated" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{}); + const page = try bc.session.createPage(); + const frame = page.frame().?; + + const url = "http://localhost:9582/src/browser/tests/mcp_actions.html"; + try frame.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } }); + try testing.waitForPage(bc); + + var ls: lp.js.Local.Scope = undefined; + frame.js.localScope(&ls); + defer ls.deinit(); + + var try_catch: lp.js.TryCatch = undefined; + try_catch.init(&ls.local); + defer try_catch.deinit(); + + const leaf_x = try (try ls.local.compileAndRun("document.getElementById('innerleaf').getBoundingClientRect().x", null)).toF64(); + const leaf_y = try (try ls.local.compileAndRun("document.getElementById('innerleaf').getBoundingClientRect().y", null)).toF64(); + + // #outerscroll is a 100px box over 500px of content. A wheel latches to one + // scroller: the container takes the whole delta and keeps what doesn't fit, + // rather than passing the rest on. + try ctx.processMessage(.{ + .id = 1, + .method = "Input.dispatchMouseEvent", + .params = .{ .type = "mouseWheel", .x = leaf_x, .y = leaf_y, .deltaY = 1000 }, + }); + const latched = try ls.local.compileAndRun("document.getElementById('outerscroll').scrollTop === 400 && window.scrollY === 0", null); + try testing.expect(latched.isTrue()); + + // Saturated now, so the next wheel latches to the viewport instead. + try ctx.processMessage(.{ + .id = 2, + .method = "Input.dispatchMouseEvent", + .params = .{ .type = "mouseWheel", .x = leaf_x, .y = leaf_y, .deltaY = 100 }, + }); + const chained = try ls.local.compileAndRun("document.getElementById('outerscroll').scrollTop === 400 && window.scrollY === 100", null); + try testing.expect(chained.isTrue()); + + // overscroll-behavior keeps the latch on a container that can't move, so + // nothing scrolls at all. + _ = try ls.local.compileAndRun("document.getElementById('outerscroll').style.overscrollBehavior = 'contain'", null); + try ctx.processMessage(.{ + .id = 3, + .method = "Input.dispatchMouseEvent", + .params = .{ .type = "mouseWheel", .x = leaf_x, .y = leaf_y, .deltaY = 100 }, + }); + const contained = try ls.local.compileAndRun("document.getElementById('outerscroll').scrollTop === 400 && window.scrollY === 100", null); + try testing.expect(contained.isTrue()); + + // Reversing direction latches back to the container, which can move again. + // The 100 it can't give back stays unscrolled: no split here either. + _ = try ls.local.compileAndRun("document.getElementById('outerscroll').style.overscrollBehavior = 'auto'", null); + try ctx.processMessage(.{ + .id = 4, + .method = "Input.dispatchMouseEvent", + .params = .{ .type = "mouseWheel", .x = leaf_x, .y = leaf_y, .deltaY = -500 }, + }); + const upward = try ls.local.compileAndRun("document.getElementById('outerscroll').scrollTop === 0 && window.scrollY === 100", null); + try testing.expect(upward.isTrue()); +} + test "cdp.input: dispatchMouseEvent mouseWheel on page content scrolls the viewport" { var ctx = try testing.context(); defer ctx.deinit(); From bbd99f47e87a6675b5ff350d03964b4727c48a4d Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 22 Sep 2026 16:31:52 +0800 Subject: [PATCH 22/93] webdriver: execute Adds the ability to execute JavaScript via WebDriver. The other webdriver endpoints were able to re-use the existing BiDi code (e.g. navigating via webdriver or bidi quickly ends up in the same function). But for execute, it's completely different, from input parameters, to running the code, to the result that's returned. So there's a dedicated handler for this: `execute.zig`. But the rest of the infrastructure (the http waiting for a reply, the routing, the parameter parsing, ... are all the same). --- src/server/Server.zig | 226 +++++++++++ src/server/bidi/BiDi.zig | 18 + src/server/bidi/browsing_context.zig | 2 + src/server/bidi/execute.zig | 580 +++++++++++++++++++++++++++ src/server/bidi/http_command.zig | 165 ++++++-- 5 files changed, 960 insertions(+), 31 deletions(-) create mode 100644 src/server/bidi/execute.zig diff --git a/src/server/Server.zig b/src/server/Server.zig index 206631dab..72c5194f0 100644 --- a/src/server/Server.zig +++ b/src/server/Server.zig @@ -2221,6 +2221,232 @@ test "server: HTTP element commands" { } } +test "server: HTTP execute script" { + const session_id = try createHTTPSession("{\"capabilities\":{}}", false); + defer deleteHTTPSession(&session_id, true) catch |err| @panic(@errorName(err)); + + var c = try createTestClient(); + defer c.deinit(); + + const url = "http://127.0.0.1:9582/src/browser/tests/webdriver/elements.html"; + try testing.expectEqual("{\"value\":null}", responseBody(try sessionCommand(&c, "POST", &session_id, "/url", "{\"url\":\"" ++ url ++ "\"}"))); + + try testing.expectEqual( + "{\"value\":{\"script\":30000,\"pageLoad\":300000,\"implicit\":0}}", + responseBody(try sessionCommand(&c, "GET", &session_id, "/timeouts", "")), + ); + + // the script is a function body, so `arguments` is bound and `return` works + try testing.expectEqual("{\"value\":5}", try executeSync(&c, &session_id, "return arguments[0] + arguments[1];", "[2,3]")); + try testing.expectEqual("{\"value\":\"hi\"}", try executeSync(&c, &session_id, "return 'hi';", "[]")); + try testing.expectEqual("{\"value\":true}", try executeSync(&c, &session_id, "return 1 < 2;", "[]")); + + // a whole number isn't 2e0, and what JSON can't hold is null + try testing.expectEqual("{\"value\":2}", try executeSync(&c, &session_id, "return 2.0;", "[]")); + try testing.expectEqual("{\"value\":1.5}", try executeSync(&c, &session_id, "return 1.5;", "[]")); + try testing.expectEqual("{\"value\":null}", try executeSync(&c, &session_id, "return 0/0;", "[]")); + + // undefined, and a body that doesn't return at all + try testing.expectEqual("{\"value\":null}", try executeSync(&c, &session_id, "return undefined;", "[]")); + try testing.expectEqual("{\"value\":null}", try executeSync(&c, &session_id, "var x = 1;", "[]")); + + try testing.expectEqual( + "{\"value\":{\"a\":1,\"b\":[true,null,\"x\"]}}", + try executeSync(&c, &session_id, "return {a: 1, b: [true, null, 'x']};", "[]"), + ); + + // a function has no own enumerable properties, so it clones to {} + try testing.expectEqual("{\"value\":{}}", try executeSync(&c, &session_id, "return function() {};", "[]")); + + // toJSON wins over the property walk + try testing.expectEqual( + "{\"value\":\"1970-01-01T00:00:00.000Z\"}", + try executeSync(&c, &session_id, "return new Date(0);", "[]"), + ); + + // an element comes back as a reference, and goes back in as the node + { + const body = try executeSync(&c, &session_id, "return document.getElementById('msg');", "[]"); + const parsed = try std.json.parseFromSliceLeaky(std.json.Value, testing.arena_allocator, body, .{}); + const reference = parsed.object.get("value").?.object.get(http_command.element_key).?.string; + + // the same node the find endpoints hand out + try testing.expectEqual(reference, try findElement(&c, &session_id, "css selector", "#msg")); + try testing.expectEqual("{\"value\":\"hello\"}", try elementCommand(&c, &session_id, reference, "/text")); + + const args = try std.fmt.allocPrint(testing.arena_allocator, "[{{\"" ++ http_command.element_key ++ "\":\"{s}\"}}]", .{reference}); + try testing.expectEqual("{\"value\":\"msg\"}", try executeSync(&c, &session_id, "return arguments[0].id;", args)); + } + + // a collection is an array of references, a non-element node is a bare {} + { + const body = try executeSync(&c, &session_id, "return document.querySelectorAll('.item');", "[]"); + try testing.expectEqual(2, (try elementReferences(body)).len); + try testing.expectEqual("{\"value\":[{}]}", try executeSync(&c, &session_id, "return [document.getElementById('msg').firstChild];", "[]")); + } + + // a reference nothing handed out + { + const res = try executeRaw(&c, &session_id, "sync", "return 1;", "[{\"" ++ http_command.element_key ++ "\":\"99\"}]"); + try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 404 Not Found\r\n")); + try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"no such element\"") != null); + } + + // a throw fails the command; it isn't reported inside a successful result + { + const res = try executeRaw(&c, &session_id, "sync", "throw new Error('nope');", "[]"); + try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 500 Internal Server Error\r\n")); + try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"javascript error\"") != null); + try testing.expect(std.mem.indexOf(u8, res, "Error: nope") != null); + } + + { + const res = try executeRaw(&c, &session_id, "sync", "return (", "[]"); + try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"javascript error\"") != null); + try testing.expect(std.mem.indexOf(u8, res, "SyntaxError") != null); + } + + // a cycle is an error, not a collapsed value like a RemoteValue's + { + const res = try executeRaw(&c, &session_id, "sync", "var a = {}; a.self = a; return a;", "[]"); + try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"javascript error\"") != null); + try testing.expect(std.mem.indexOf(u8, res, "circular reference") != null); + } + + // a returned promise is resolved before we answer + try testing.expectEqual("{\"value\":7}", try executeSync(&c, &session_id, "return Promise.resolve(7);", "[]")); + try testing.expectEqual( + "{\"value\":8}", + try executeSync(&c, &session_id, "return new Promise(function(r) { setTimeout(function() { r(8); }, 5); });", "[]"), + ); + { + const res = try executeRaw(&c, &session_id, "sync", "return Promise.reject(new Error('late'));", "[]"); + try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"javascript error\"") != null); + try testing.expect(std.mem.indexOf(u8, res, "Error: late") != null); + } + + // async: the callback is the last argument, and only its first call counts + try testing.expectEqual("{\"value\":42}", try executeAsync(&c, &session_id, "arguments[0](42);", "[]")); + try testing.expectEqual( + "{\"value\":42}", + try executeAsync(&c, &session_id, "var cb = arguments[arguments.length - 1]; cb(arguments[0] * 2);", "[21]"), + ); + try testing.expectEqual( + "{\"value\":\"late\"}", + try executeAsync(&c, &session_id, "var cb = arguments[0]; setTimeout(function() { cb('late'); cb('again'); }, 5);", "[]"), + ); + // what an async body returns is ignored + try testing.expectEqual("{\"value\":null}", try executeAsync(&c, &session_id, "arguments[0](); return 9;", "[]")); + + // only the first call counts; the rest are a no-op on a settled promise + try testing.expectEqual("{\"value\":1}", try executeAsync(&c, &session_id, "arguments[0](1); arguments[0](2);", "[]")); + + // The body is promise-called, so throwing rejects it and fails the + // command even though the callback already ran -- and that failure must + // not be a *second* answer on a connection we already handed back. + { + const res = try executeRaw(&c, &session_id, "async", "arguments[0](1); throw new Error('too late');", "[]"); + try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"javascript error\"") != null); + try testing.expect(std.mem.indexOf(u8, res, "Error: too late") != null); + } + try testing.expectEqual("{\"value\":2}", try executeSync(&c, &session_id, "return 2;", "[]")); + + // a throw before the callback still fails the command + { + const res = try executeRaw(&c, &session_id, "async", "throw new Error('early');", "[]"); + try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"javascript error\"") != null); + try testing.expect(std.mem.indexOf(u8, res, "Error: early") != null); + } + + // a script that never completes is answered by the script timeout + { + try testing.expectEqual("{\"value\":null}", responseBody(try sessionCommand(&c, "POST", &session_id, "/timeouts", "{\"script\":50}"))); + try testing.expectEqual( + "{\"value\":{\"script\":50,\"pageLoad\":300000,\"implicit\":0}}", + responseBody(try sessionCommand(&c, "GET", &session_id, "/timeouts", "")), + ); + + const res = try executeRaw(&c, &session_id, "async", "// never calls back", "[]"); + try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 500 Internal Server Error\r\n")); + try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"script timeout\"") != null); + + // a sync script whose promise never settles times out the same way + const promise = try executeRaw(&c, &session_id, "sync", "return new Promise(function() {});", "[]"); + try testing.expect(std.mem.indexOf(u8, promise, "\"error\":\"script timeout\"") != null); + + // A script that resolves AFTER it timed out: answering is not the + // promise settling, so the Pending has to outlive its own answer. + // Freeing it on the timeout leaves V8 holding our callbacks on a + // live promise and the late resolve lands in freed memory -- which a + // release build segfaults on, but the debug allocator here does not + // trap, so this covers the path rather than proving the invariant. + // selenium/http/demo.js in ../demo is what actually catches it. + const late = try executeRaw(&c, &session_id, "async", "var cb = arguments[0]; setTimeout(function() { window.__late = true; cb('way late'); }, 150);", "[]"); + try testing.expect(std.mem.indexOf(u8, late, "\"error\":\"script timeout\"") != null); + lp.io.sleep(.fromMilliseconds(400), .awake) catch {}; + // the assertion only means anything if the stale resolve actually ran + try testing.expectEqual("{\"value\":true}", try executeSync(&c, &session_id, "return window.__late === true;", "[]")); + try testing.expectEqual("{\"value\":\"alive\"}", try executeSync(&c, &session_id, "return 'alive';", "[]")); + } + + // null turns the script timeout off + { + try testing.expectEqual("{\"value\":null}", responseBody(try sessionCommand(&c, "POST", &session_id, "/timeouts", "{\"script\":null}"))); + try testing.expectEqual( + "{\"value\":{\"script\":null,\"pageLoad\":300000,\"implicit\":0}}", + responseBody(try sessionCommand(&c, "GET", &session_id, "/timeouts", "")), + ); + try testing.expectEqual("{\"value\":1}", try executeSync(&c, &session_id, "return 1;", "[]")); + } + + // Navigating out from under a running script answers it. The Pending + // stays alive past that answer -- V8 still holds its callback -- until + // the frame, and with it the context, is destroyed. + { + const handle = blk: { + const body = responseBody(try sessionCommand(&c, "GET", &session_id, "/window", "")); + break :blk try testing.arena_allocator.dupe(u8, body[10..46]); + }; + + var ws = try createTestClient(); + defer ws.deinit(); + var path_buf: [64]u8 = undefined; + try ws.handshake(try std.fmt.bufPrint(&path_buf, "/session/{s}", .{&session_id})); + + // the script never calls back, so its connection parks + var parked = try createTestClient(); + defer parked.deinit(); + try writeSessionCommand(&parked, "POST", &session_id, "/execute/async", "{\"script\":\"// never calls back\",\"args\":[]}"); + lp.io.sleep(.fromMilliseconds(50), .awake) catch {}; + + try ws.bidiCommand(try std.fmt.allocPrint(testing.arena_allocator, + \\{{"id":1,"method":"browsingContext.navigate","params":{{"context":"{s}","url":"about:blank","wait":"complete"}}}} + , .{handle})); + + const res = try parked.httpRequest(""); + try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"javascript error\"") != null); + try testing.expect(std.mem.indexOf(u8, res, "document was unloaded") != null); + } +} + +// POST /execute/{sync,async}: the raw response, so a test can assert on an +// error too. +fn executeRaw(c: *TestClient, session_id: *const [36]u8, kind: []const u8, script: []const u8, args: []const u8) ![]const u8 { + const arena = testing.arena_allocator; + const quoted = try std.json.Stringify.valueAlloc(arena, script, .{}); + const body = try std.fmt.allocPrint(arena, "{{\"script\":{s},\"args\":{s}}}", .{ quoted, args }); + const path = try std.fmt.allocPrint(arena, "/execute/{s}", .{kind}); + return sessionCommand(c, "POST", session_id, path, body); +} + +fn executeSync(c: *TestClient, session_id: *const [36]u8, script: []const u8, args: []const u8) ![]const u8 { + return responseBody(try executeRaw(c, session_id, "sync", script, args)); +} + +fn executeAsync(c: *TestClient, session_id: *const [36]u8, script: []const u8, args: []const u8) ![]const u8 { + return responseBody(try executeRaw(c, session_id, "async", script, args)); +} + fn findElement(c: *TestClient, session_id: *const [36]u8, using: []const u8, value: []const u8) ![]const u8 { const body = try std.fmt.allocPrint(testing.arena_allocator, "{{\"using\":\"{s}\",\"value\":\"{s}\"}}", .{ using, value }); const res = responseBody(try sessionCommand(c, "POST", session_id, "/element", body)); diff --git a/src/server/bidi/BiDi.zig b/src/server/bidi/BiDi.zig index badcd37bc..9724eed81 100644 --- a/src/server/bidi/BiDi.zig +++ b/src/server/bidi/BiDi.zig @@ -36,6 +36,7 @@ const Server = @import("../Server.zig"); const script = @import("script.zig"); const http_command = @import("http_command.zig"); +const execute = @import("execute.zig"); const remote_value = @import("remote_value.zig"); const posix = std.posix; @@ -84,6 +85,13 @@ handles: remote_value.Handles, // Commands awaiting promise resolution pending: std.ArrayList(*script.Pending) = .empty, +// The HTTP session's execute/sync and execute/async, awaiting a promise or +// the callback an async script was handed. +execute_pending: std.ArrayList(*execute.Pending) = .empty, + +// The HTTP session's timeouts. BiDi has no equivalent. +timeouts: Timeouts = .{}, + input_state: @import("input.zig").State = .{}, subscriptions: std.ArrayList(Subscription) = .empty, @@ -121,6 +129,12 @@ const InputMessage = struct { method: ?[]const u8 = null, }; +pub const Timeouts = struct { + script: ?u32 = 30_000, + pageLoad: u32 = 300_000, + implicit: u32 = 0, +}; + pub fn init(self: *BiDi, app: *App, inbox: *Inbox, origin: Origin) !void { const allocator = app.allocator; { @@ -176,11 +190,14 @@ pub fn deinit(self: *BiDi) void { // Cancel first, so that any completions during session teardown are still valid script.Pending.cancelAll(self); + execute.Pending.cancelAll(self); self.handles.deinit(); self.browser.closeSession(); // Now we can destroy script.Pending.destroyAll(self); + execute.Pending.destroyAll(self); self.pending.deinit(allocator); + self.execute_pending.deinit(allocator); self.input_state.deinit(allocator); self.node_registry.deinit(); @@ -262,6 +279,7 @@ const UserContext = struct { pub fn resetRealm(self: *BiDi) void { script.Pending.realmReset(self); + execute.Pending.realmReset(self); self.handles.releaseAll(); self.node_registry.reset(); if (self.browsing_context) |*ctx| { diff --git a/src/server/bidi/browsing_context.zig b/src/server/bidi/browsing_context.zig index aff470434..a1fcc1e99 100644 --- a/src/server/bidi/browsing_context.zig +++ b/src/server/bidi/browsing_context.zig @@ -32,6 +32,7 @@ const Notification = @import("../../Notification.zig"); const BiDi = @import("BiDi.zig"); const script = @import("script.zig"); +const execute = @import("execute.zig"); const remote_value = @import("remote_value.zig"); const log = lp.log; @@ -547,6 +548,7 @@ fn onFrameCreated(ptr: *anyopaque, frame: *Frame) !void { fn onFrameDestroyed(ptr: *anyopaque, frame: *const Frame) !void { const bidi: *BiDi = @ptrCast(@alignCast(ptr)); script.Pending.contextDestroyed(bidi, frame.js.id); + execute.Pending.contextDestroyed(bidi, frame.js.id); } fn onFrameNavigate(ptr: *anyopaque, msg: *const Notification.FrameNavigate) !void { diff --git a/src/server/bidi/execute.zig b/src/server/bidi/execute.zig new file mode 100644 index 000000000..2d7d46f53 --- /dev/null +++ b/src/server/bidi/execute.zig @@ -0,0 +1,580 @@ +// 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 . + +// Unlike most WebDriver endpoints, we can't re-use the BiDi flow here. The +// flow is too different. WebDriver treats throws as command failures and +// serializs over JSON. BiDi treats throws as success with the error reported +// inside and serializes via RemoteValue. + +const std = @import("std"); +const lp = @import("lightpanda"); + +const NodeRegistry = @import("../../NodeRegistry.zig"); + +const js = @import("../../browser/js/js.zig"); +const Frame = @import("../../browser/Frame.zig"); +const Node = @import("../../browser/webapi/Node.zig"); +const NodeList = @import("../../browser/webapi/collections/NodeList.zig"); +const HTMLCollection = @import("../../browser/webapi/collections/HTMLCollection.zig"); + +const BiDi = @import("BiDi.zig"); +const http_command = @import("http_command.zig"); + +const log = lp.log; +const Allocator = std.mem.Allocator; + +pub const Script = struct { + script: []const u8, + args: []const std.json.Value = &.{}, +}; + +pub const Mode = enum { + sync, + async, +}; + +// POST /session/{id}/execute/sync, POST /session/{id}/execute/async +pub fn run(cmd: *BiDi.Command, p: Script, mode: Mode) !void { + const bidi = cmd.bidi; + const frame = bidi.user_context.session.currentFrame() orelse { + return cmd.sendError("no such window", "no frame"); + }; + + var ls: js.Local.Scope = undefined; + frame.js.localScope(&ls); + defer ls.deinit(); + const local = &ls.local; + + var try_catch: js.TryCatch = undefined; + try_catch.init(local); + defer try_catch.deinit(); + + // `script` is a function *body*, not an expression + const function = local.compileFunction(p.script, &.{}, &.{}) catch |err| { + if (err == error.ExecutionTerminated or err == error.OutOfMemory) { + return err; + } + return cmd.sendError("javascript error", exceptionText(cmd.arena, &try_catch, err)); + }; + + const extra = @intFromBool(mode == .async); + const arguments = try cmd.arena.alloc(js.Value, p.args.len + extra); + for (p.args, arguments[0..p.args.len]) |argument, *js_argument| { + js_argument.* = fromJson(local, &bidi.node_registry, argument) catch |err| switch (err) { + error.NoSuchElement => return cmd.sendError("no such element", "unknown element reference"), + error.StaleElement => return cmd.sendError("stale element reference", "element is no longer attached to the document"), + error.InvalidArgument => return cmd.sendError("invalid argument", "cannot deserialize an argument"), + else => return err, + }; + } + + var deferred: ?js.Promise = null; + if (mode == .async) { + // It looks like we're supposed to give it a plain callback (which we + // could, via `local.newCallback`), but a promise makes managing the + // lifetime easier (because it can only be settled once). + const pair = (try local.exec(async_bridge, "webdriver.executeAsync")).toArray(); + deferred = (try pair.get(0)).toPromise(); + arguments[p.args.len] = try pair.get(1); + } + + const undef = try local.zigValueToJs({}, .{}); + const returned = function.callWithThisRethrow(js.Value, undef, arguments) catch |err| { + if (err == error.ExecutionTerminated or err == error.OutOfMemory) { + return err; + } + return cmd.sendError("javascript error", exceptionText(cmd.arena, &try_catch, err)); + }; + + // An async script's return value is ignored, the resolved value is what answers. + const value = if (deferred) |promise| promise.toValue() else returned; + if (value.isPromise() == false) { + return sendResult(cmd, frame, local, value); + } + + // this isn't jus the async path, we're also here if the sync script returned + // a promise. This is another advantage of using a profile as our async + // paramater: it gives us a single thing to handle here (a promise) rather + // than a promise (from a sync return) and a callback (if we used local.newCallback) + + const promise = value.toPromise(); + promise.markAsHandled(); + local.runMicrotasks(); + + switch (promise.state()) { + .fulfilled => return sendResult(cmd, frame, local, promise.result()), + .rejected => return sendRejection(cmd, promise.result()), + .pending => { + const pnd = try Pending.create(cmd, frame); + _ = try promise.thenAndCatch( + local.newCallback(Pending.onFulfilled, pnd), + local.newCallback(Pending.onRejected, pnd), + ); + }, + } +} + +// Returns [promise, resolve]. The script gets `resolve`; we wait on the promise. +const async_bridge = "(function(){var r; var p = new Promise(function(res){r = res;}); return [p, r];})()"; + +fn sendResult(cmd: *BiDi.Command, frame: *Frame, local: *const js.Local, value: js.Value) !void { + const cloned = clone(cmd.arena, &cmd.bidi.node_registry, frame, local, value) catch |err| switch (err) { + error.OutOfMemory, error.ExecutionTerminated => return err, + else => return cmd.sendError("javascript error", cloneErrorMessage(err)), + }; + return cmd.sendResult(cloned); +} + +fn sendRejection(cmd: *BiDi.Command, reason: js.Value) !void { + return cmd.sendError("javascript error", rejectionText(cmd.arena, reason)); +} + +// The exception stringified ("Error: nope"), not just its message, which is +// what a driver's users expect to read. +fn exceptionText(arena: Allocator, try_catch: *const js.TryCatch, err: anyerror) []const u8 { + const caught = try_catch.caughtOrError(arena, err); + const fallback = caught.exception orelse @errorName(err); + const thrown = try_catch.exceptionValue() orelse return fallback; + return thrown.toStringSliceWithAlloc(arena) catch fallback; +} + +fn rejectionText(arena: Allocator, reason: js.Value) []const u8 { + return reason.toStringSliceWithAlloc(arena) catch "promise rejected"; +} + +// Script with a value that'll come later, i.e. a sync script that returned +// a promise (thus, when the promise is resolved/rejected) or for an async +// script that will resolve the promise. +pub const Pending = struct { + bidi: *BiDi, + to: BiDi.Reply, + js_context_id: usize, + deadline: ?u64, // null if there isn't one + answered: bool = false, + + fn create(cmd: *BiDi.Command, frame: *Frame) !*Pending { + const bidi = cmd.bidi; + const allocator = bidi.app.allocator; + const timeout = bidi.timeouts.script; + + const self = try allocator.create(Pending); + errdefer allocator.destroy(self); + + self.* = .{ + .bidi = bidi, + .to = cmd.reply(), + .js_context_id = frame.js.id, + .deadline = if (timeout) |ms| lp.datetime.milliTimestamp(.boot) + ms else null, + }; + try bidi.execute_pending.append(allocator, self); + errdefer _ = bidi.execute_pending.pop(); + + if (timeout) |ms| { + // timeout defaults to 30 seconds and is likely not going to be + // needed, never block done for this. + try frame.js.scheduler.add(bidi, onTimeout, ms, .{ + .name = "webdriver.scriptTimeout", + .blocks_done = false, + }); + } + + // from this point on, we own the reply + _ = cmd.takeReply(); + return self; + } + + fn unregister(self: *Pending) void { + const bidi = self.bidi; + for (bidi.execute_pending.items, 0..) |pending, i| { + if (pending == self) { + _ = bidi.execute_pending.swapRemove(i); + break; + } + } + bidi.app.allocator.destroy(self); + } + + fn onFulfilled(self: *Pending, value: js.Value, exec: *const js.Execution) void { + defer self.unregister(); + self.answer(exec.js.local.?, value); + } + + fn onRejected(self: *Pending, reason: js.Value, _: *const js.Execution) void { + defer self.unregister(); + if (self.answered) { + return; + } + const arena = self.scratch() orelse return; + defer arena.release(); + self.fail("javascript error", rejectionText(arena.allocator(), reason)); + } + + // The ctx for this is *BiDi, not *Pending, because the *Pending will clean + // itself up once the promise is resolved/rejected, but the scheduled timeout + // will live on. To make this work with a *Pending, the Scheduler would need + // to be able to remove a task. Don't think we've needed that before, and + // hard to justify just for this case. So, what we can do it just scan + // the bidi's list of pending's to see if any have timed out. + fn onTimeout(ctx: *anyopaque) !?u32 { + // Ab + const bidi: *BiDi = @ptrCast(@alignCast(ctx)); + const now = lp.datetime.milliTimestamp(.boot); + + var soonest: ?u64 = null; + var i = bidi.execute_pending.items.len; + while (i > 0) { + i -= 1; + const pending = bidi.execute_pending.items[i]; + const deadline = pending.deadline orelse continue; + if (deadline > now) { + soonest = if (soonest) |s| @min(s, deadline) else deadline; + continue; + } + // Most important thing is here: we answer but don't free. The + // promise could still be resolved at some point in the future! + pending.fail("script timeout", "the script did not complete within the script timeout"); + pending.deadline = null; + } + + if (soonest) |deadline| { + return @intCast(deadline - now); + } + return null; + } + + fn answer(self: *Pending, local: *const js.Local, value: js.Value) void { + if (self.answered) { + return; + } + + const bidi = self.bidi; + const frame = bidi.user_context.session.currentFrame() orelse { + return self.fail("no such window", "no frame"); + }; + + const arena = self.scratch() orelse return; + defer arena.release(); + + const cloned = clone(arena.allocator(), &bidi.node_registry, frame, local, value) catch |err| { + return self.fail("javascript error", cloneErrorMessage(err)); + }; + + self.answered = true; + bidi.replyResult(self.to, cloned) catch |err| { + log.err(.bidi, "execute result", .{ .err = err, .reply = self.to }); + }; + } + + fn fail(self: *Pending, code: []const u8, message: []const u8) void { + if (self.answered) { + return; + } + self.answered = true; + self.bidi.replyError(self.to, code, message) catch |err| { + log.err(.bidi, "execute error", .{ .err = err, .reply = self.to }); + }; + } + + fn scratch(self: *Pending) ?*lp.Arena { + return self.bidi.app.arena_pool.acquire(.small, "webdriver execute") catch |err| { + self.fail("unknown error", @errorName(err)); + return null; + }; + } + + pub fn realmReset(bidi: *BiDi) void { + for (bidi.execute_pending.items) |pending| { + pending.fail("javascript error", "the document was unloaded while the script was running"); + } + } + + // A frame, and with it the JS context holding our callbacks, is gone. + pub fn contextDestroyed(bidi: *BiDi, js_context_id: usize) void { + var i = bidi.execute_pending.items.len; + while (i > 0) { + i -= 1; + const pending = bidi.execute_pending.items[i]; + if (pending.js_context_id == js_context_id) { + _ = bidi.execute_pending.swapRemove(i); + bidi.app.allocator.destroy(pending); + } + } + } + + // Teardown: completions are still reachable, but their reply isn't. + pub fn cancelAll(bidi: *BiDi) void { + for (bidi.execute_pending.items) |pending| { + pending.answered = true; + } + } + + pub fn destroyAll(bidi: *BiDi) void { + while (bidi.execute_pending.pop()) |pending| { + bidi.app.allocator.destroy(pending); + } + } +}; + +const CloneError = error{ + CyclicReference, + TooDeep, + OutOfMemory, + ExecutionTerminated, + TypeError, + JsException, + MethodNotFound, + DeadFunctionHandle, + InvalidArgument, +}; + +fn cloneErrorMessage(err: anyerror) []const u8 { + return switch (err) { + error.CyclicReference => "cannot serialize a circular reference", + error.TooDeep => "the result is nested too deeply to serialize", + else => "cannot serialize the script's result", + }; +} + +// W3C's "JSON clone" of a script's result. Not a RemoteValue: a client reads +// it as plain JSON, with an element the one exception. +const Value = union(enum) { + null, + boolean: bool, + number: f64, + string: []const u8, + element: http_command.Reference, + array: []const Value, + object: []const Property, + + pub const Property = struct { + name: []const u8, + value: Value, + }; + + pub fn jsonStringify(self: *const Value, w: anytype) !void { + switch (self.*) { + .null => try w.write(null), + .boolean => |v| try w.write(v), + .string => |v| try w.write(v), + .element => |v| try w.write(v), + .number => |v| { + // JSON has no NaN or Infinity, and a whole number must not go + // out as 3e0 -- a client that rejects non-conforming JSON is + // within its rights. + if (std.math.isFinite(v) == false) { + return w.write(null); + } + const max_safe_integer = 9007199254740991; + if (@trunc(v) == v and @abs(v) <= max_safe_integer) { + return w.write(@as(i64, @intFromFloat(v))); + } + try w.write(v); + }, + .array => |values| { + try w.beginArray(); + for (values) |*value| { + try w.write(value); + } + try w.endArray(); + }, + .object => |properties| { + try w.beginObject(); + for (properties) |*property| { + try w.objectField(property.name); + try w.write(&property.value); + } + try w.endObject(); + }, + } + } +}; + +fn clone( + arena: Allocator, + registry: *NodeRegistry, + frame: *Frame, + local: *const js.Local, + value: js.Value, +) CloneError!Value { + var cloner: Cloner = .{ .arena = arena, .registry = registry, .frame = frame, .local = local }; + return cloner.run(value); +} + +const Cloner = struct { + arena: Allocator, + registry: *NodeRegistry, + frame: *Frame, + local: *const js.Local, + // cyclical dependencies are an error (vs RemoteValue which collapses it) + seen: std.ArrayList(js.Object) = .empty, //cyclicli + + const max_depth = 64; + + fn run(self: *Cloner, value: js.Value) CloneError!Value { + if (value.isNullOrUndefined()) { + return .null; + } + if (value.isBoolean()) { + return .{ .boolean = value.toBool() }; + } + if (value.isNumber()) { + return .{ .number = try value.toF64() }; + } + if (value.isString() != null) { + return .{ .string = try value.toStringSliceWithAlloc(self.arena) }; + } + if (value.isObject() == false) { + // a symbol or a bigint + return .null; + } + + const object = value.toObject(); + if (self.isSeen(object)) { + return error.CyclicReference; + } + if (self.seen.items.len == max_depth) { + return error.TooDeep; + } + try self.seen.append(self.arena, object); + defer _ = self.seen.pop(); + + if (value.taggedOpaque()) |tao| { + if (try self.platform(tao)) |cloned| { + return cloned; + } + // self.platform() only handles a few select types. Everything else + // goes through a more generic path , e.g. self.properties() + } + + if (value.isArray()) { + return .{ .array = try self.items(value.toArray()) }; + } + + if (try object.getFunction("toJSON") != null) { + return self.run(try object.callMethod(js.Value, "toJSON", .{})); + } + + return .{ .object = try self.properties(object) }; + } + + fn platform(self: *Cloner, tao: *const js.TaggedOpaque) !?Value { + if (tao.as(Node)) |node| { + // Non-elements will be serialized via properties() + const element = node.is(Node.Element) orelse return null; + return .{ .element = try self.reference(element.asNode()) }; + } + + if (tao.as(NodeList)) |list| { + const values = try self.arena.alloc(Value, try list.length(self.frame)); + for (values, 0..) |*item, i| { + const node = (try list.getAtIndex(i, self.frame)) orelse unreachable; + item.* = try self.run(try self.local.zigValueToJs(node, .{})); + } + return .{ .array = values }; + } + + if (tao.as(HTMLCollection)) |collection| { + const values = try self.arena.alloc(Value, collection.length(self.frame)); + for (values, 0..) |*item, i| { + const element = collection.getAtIndex(i, self.frame) orelse unreachable; + item.* = .{ .element = try self.reference(element.asNode()) }; + } + return .{ .array = values }; + } + + return null; + } + + fn reference(self: *Cloner, node: *Node) !http_command.Reference { + return .init(self.arena, self.registry, node); + } + + fn items(self: *Cloner, array: js.Array) CloneError![]const Value { + const values = try self.arena.alloc(Value, array.len()); + for (values, 0..) |*value, i| { + value.* = try self.run(try array.get(@intCast(i))); + } + return values; + } + + fn properties(self: *Cloner, object: js.Object) CloneError![]const Value.Property { + var it = try object.iterator(); + var list: std.ArrayList(Value.Property) = try .initCapacity(self.arena, it.count); + while (try it.next()) |entry| { + list.appendAssumeCapacity(.{ + .name = try self.arena.dupe(u8, entry.name), + .value = try self.run(entry.value), + }); + } + return list.items; + } + + fn isSeen(self: *const Cloner, object: js.Object) bool { + const candidate = object.toValue(); + for (self.seen.items) |ancestor| { + if (ancestor.toValue().strictEquals(candidate)) { + return true; + } + } + return false; + } +}; + +fn fromJson( + local: *const js.Local, + registry: *const NodeRegistry, + value: std.json.Value, +) !js.Value { + switch (value) { + .null => return local.zigValueToJs(null, .{}), + .bool => |v| return local.zigValueToJs(v, .{}), + .integer => |v| return local.newNumber(@floatFromInt(v)), + .float => |v| return local.newNumber(v), + .number_string => |v| return local.newNumber(std.fmt.parseFloat(f64, v) catch return error.InvalidArgument), + .string => |v| return local.zigValueToJs(v, .{}), + .array => |v| { + var array = local.newArray(@intCast(v.items.len)); + for (v.items, 0..) |item, i| { + if (try array.set(@intCast(i), try fromJson(local, registry, item), .{}) == false) { + return error.InvalidArgument; + } + } + return array.toValue(); + }, + .object => |fields| { + if (fields.get(http_command.element_key)) |id| { + const shared_id = switch (id) { + .string => |s| s, + else => return error.NoSuchElement, + }; + const element = try http_command.elementFromReference(registry, shared_id); + return local.zigValueToJs(element.asNode(), .{}); + } + + const object = local.newObject(); + var it = fields.iterator(); + while (it.next()) |entry| { + const item = try fromJson(local, registry, entry.value_ptr.*); + if (try object.set(entry.key_ptr.*, item, .{}) == false) { + return error.InvalidArgument; + } + } + return object.toValue(); + }, + } +} diff --git a/src/server/bidi/http_command.zig b/src/server/bidi/http_command.zig index adad75d4b..306053378 100644 --- a/src/server/bidi/http_command.zig +++ b/src/server/bidi/http_command.zig @@ -26,11 +26,13 @@ const lp = @import("lightpanda"); const js = @import("../../browser/js/js.zig"); const Frame = @import("../../browser/Frame.zig"); const Node = @import("../../browser/webapi/Node.zig"); +const NodeRegistry = @import("../../NodeRegistry.zig"); const Method = @import("../http.zig").Connection.Method; const BiDi = @import("BiDi.zig"); const input = @import("input.zig"); +const execute = @import("execute.zig"); const remote_value = @import("remote_value.zig"); const browsing_context = @import("browsing_context.zig"); @@ -63,14 +65,10 @@ pub const Command = union(enum) { get_element_rect: ElementId, is_element_enabled: ElementId, is_element_selected: ElementId, -}; - -pub const NavigateTo = struct { - url: [:0]const u8, -}; - -pub const PerformActions = struct { - actions: []const std.json.Value, + execute_script: execute.Script, + execute_async_script: execute.Script, + get_timeouts, + set_timeouts: SetTimeouts, }; // A command's path parameters are its leading fields (see `parse`); the rest @@ -228,6 +226,10 @@ const routes = [_]Route{ .init(.GET, "/element/{id}/attribute/{name}", .get_element_attribute), .init(.GET, "/element/{id}/property/{name}", .get_element_property), .init(.GET, "/element/{id}/css/{name}", .get_element_css_value), + .init(.POST, "/execute/sync", .execute_script), + .init(.POST, "/execute/async", .execute_async_script), + .init(.GET, "/timeouts", .get_timeouts), + .init(.POST, "/timeouts", .set_timeouts), }; pub const ParseError = error{ @@ -323,10 +325,17 @@ pub fn process(cmd: *BiDi.Command) !void { .get_element_rect => |p| return getElementRect(cmd, p), .is_element_enabled => |p| return isElementEnabled(cmd, p), .is_element_selected => |p| return isElementSelected(cmd, p), + .execute_script => |p| return executeScript(cmd, p, .sync), + .execute_async_script => |p| return executeScript(cmd, p, .async), + .get_timeouts => return getTimeouts(cmd), + .set_timeouts => |p| return setTimeouts(cmd, p), } } // POST /session/{id}/url. +pub const NavigateTo = struct { + url: [:0]const u8, +}; fn navigateTo(cmd: *BiDi.Command, p: NavigateTo) !void { const ctx = (try currentContext(cmd)) orelse return; return browsing_context.navigate(cmd, ctx, .{ .url = p.url, .wait = .complete }); @@ -379,6 +388,9 @@ fn takeScreenshot(cmd: *BiDi.Command) !void { } // POST /session/{id}/actions. +pub const PerformActions = struct { + actions: []const std.json.Value, +}; fn performActions(cmd: *BiDi.Command, p: PerformActions) !void { _ = (try currentContext(cmd)) orelse return; return input.perform(cmd, p.actions); @@ -398,7 +410,7 @@ fn findElement(cmd: *BiDi.Command, using: Using, value: []const u8, from: ?[]con if (nodes.len == 0) { return cmd.sendError("no such element", "no matching element"); } - return cmd.sendResult(try reference(cmd, nodes[0])); + return cmd.sendResult(try Reference.initFromCommand(cmd, nodes[0])); } // POST /session/{id}/elements, POST /session/{id}/element/{id}/elements @@ -409,7 +421,7 @@ fn findElements(cmd: *BiDi.Command, using: Using, value: []const u8, from: ?[]co const references = try cmd.arena.alloc(Reference, nodes.len); for (nodes, references) |node, *ref| { - ref.* = try reference(cmd, node); + ref.* = try Reference.initFromCommand(cmd, node); } return cmd.sendResult(references); } @@ -441,7 +453,7 @@ fn getActiveElement(cmd: *BiDi.Command) !void { const element = frame.window._document.getActiveElement() orelse { return cmd.sendError("no such element", "no active element"); }; - return cmd.sendResult(try reference(cmd, element.asNode())); + return cmd.sendResult(try Reference.initFromCommand(cmd, element.asNode())); } // GET /session/{id}/element/{id}/text. @@ -500,7 +512,7 @@ fn getElementProperty(cmd: *BiDi.Command, p: ElementName) !void { if (value.isObject()) { if (value.taggedOpaque()) |tagged| { if (tagged.as(Node)) |node| { - return cmd.sendResult(try reference(cmd, node)); + return cmd.sendResult(try Reference.initFromCommand(cmd, node)); } } } @@ -552,11 +564,65 @@ fn isElementSelected(cmd: *BiDi.Command, p: ElementId) !void { return cmd.sendResult(false); } +// POST /session/{id}/execute/sync, POST /session/{id}/execute/async +fn executeScript(cmd: *BiDi.Command, p: execute.Script, mode: execute.Mode) !void { + _ = (try currentContext(cmd)) orelse return; + return execute.run(cmd, p, mode); +} + +// GET /session/{id}/timeouts +fn getTimeouts(cmd: *BiDi.Command) !void { + return cmd.sendResult(cmd.bidi.timeouts); +} + +// POST /session/{id}/timeouts +pub const SetTimeouts = struct { + script: ScriptTimeout = .absent, + pageLoad: ?u32 = null, + implicit: ?u32 = null, + + pub const ScriptTimeout = union(enum) { + absent, // not sent, keep whatever we have + disabled, // explicit null == no timeout + ms: u32, + + pub fn jsonParse(arena: Allocator, source: anytype, opts: std.json.ParseOptions) !ScriptTimeout { + const value = try std.json.innerParse(?u32, arena, source, opts); + return if (value) |ms| .{ .ms = ms } else .disabled; + } + }; +}; +fn setTimeouts(cmd: *BiDi.Command, p: SetTimeouts) !void { + const timeouts = &cmd.bidi.timeouts; + + switch (p.script) { + .absent => {}, + .disabled => timeouts.script = null, + .ms => |ms| timeouts.script = ms, + } + if (p.pageLoad) |ms| { + timeouts.pageLoad = ms; + } + if (p.implicit) |ms| { + timeouts.implicit = ms; + } + return cmd.sendDone(); +} + // {"element-6066-…": ""}: a WebDriver element reference is the // node registry's id, the same one BiDi hands out. -const Reference = struct { +pub const Reference = struct { shared_id: []const u8, + pub fn init(arena: Allocator, registry: *NodeRegistry, node: *Node) !Reference { + const registered = try registry.register(node); + return .{ .shared_id = try std.fmt.allocPrint(arena, "{d}", .{registered.id}) }; + } + + fn initFromCommand(cmd: *BiDi.Command, node: *Node) !Reference { + return .init(cmd.arena, &cmd.bidi.node_registry, node); + } + pub fn jsonStringify(self: Reference, jws: anytype) !void { try jws.beginObject(); try jws.objectField(element_key); @@ -565,29 +631,37 @@ const Reference = struct { } }; -fn reference(cmd: *BiDi.Command, node: *Node) !Reference { - const registered = try cmd.bidi.node_registry.register(node); - return .{ .shared_id = try std.fmt.allocPrint(cmd.arena, "{d}", .{registered.id}) }; +pub const ReferenceError = error{ + // the id is unknown, or names something that isn't an element + NoSuchElement, + // the element is no longer in a document + StaleElement, +}; + +// A reference's element, or why it doesn't resolve. Shared with execute.zig, +// which resolves the references a script is called with. +pub fn elementFromReference(registry: *const NodeRegistry, id: []const u8) ReferenceError!*Node.Element { + // ids are dropped on navigation, so a stale one is unknown by then + const node = remote_value.nodeFromSharedId(registry, .{ .string = id }) catch return error.NoSuchElement; + const element = node.is(Node.Element) orelse return error.NoSuchElement; + if (node.isConnected() == false) { + return error.StaleElement; + } + return element; } // Answers the command and returns null when the reference doesn't resolve. fn requireElement(cmd: *BiDi.Command, id: []const u8) !?*Node.Element { - const node = remote_value.nodeFromSharedId(&cmd.bidi.node_registry, .{ .string = id }) catch { - // ids are dropped on navigation, so a stale one is unknown by then - try cmd.sendError("no such element", "unknown element reference"); - return null; + return elementFromReference(&cmd.bidi.node_registry, id) catch |err| switch (err) { + error.NoSuchElement => { + try cmd.sendError("no such element", "unknown element reference"); + return null; + }, + error.StaleElement => { + try cmd.sendError("stale element reference", "element is no longer attached to the document"); + return null; + }, }; - - const element = node.is(Node.Element) orelse { - try cmd.sendError("no such element", "not an element"); - return null; - }; - - if (node.isConnected() == false) { - try cmd.sendError("stale element reference", "element is no longer attached to the document"); - return null; - } - return element; } // HTML's boolean attributes: present means "true", absent means null, and @@ -672,6 +746,35 @@ test "bidi.http_command: parse" { try testing.expectEqual("data-x", command.get_element_attribute.name); } + { + const command = try parse(arena, .POST, "/execute/sync", "{\"script\":\"return 1\",\"args\":[1,\"a\"]}"); + try testing.expectEqual("return 1", command.execute_script.script); + try testing.expectEqual(2, command.execute_script.args.len); + } + + { + // args defaults to empty + const command = try parse(arena, .POST, "/execute/async", "{\"script\":\"\"}"); + try testing.expectEqual(0, command.execute_async_script.args.len); + } + + { + // a partial update leaves the fields it doesn't name alone + const command = try parse(arena, .POST, "/timeouts", "{\"implicit\":5}"); + try testing.expectEqual(5, command.set_timeouts.implicit.?); + try testing.expect(command.set_timeouts.script == .absent); + try testing.expect(command.set_timeouts.pageLoad == null); + } + + { + // null is a value for script, not its absence + try testing.expect((try parse(arena, .POST, "/timeouts", "{\"script\":null}")).set_timeouts.script == .disabled); + try testing.expectEqual(50, (try parse(arena, .POST, "/timeouts", "{\"script\":50}")).set_timeouts.script.ms); + } + + try testing.expect(try parse(arena, .GET, "/timeouts", "") == .get_timeouts); + try testing.expectError(error.InvalidArgument, parse(arena, .POST, "/execute/sync", "{}")); + // a literal segment wins over the parameter that would also match it try testing.expect(try parse(arena, .GET, "/element/active", "") == .get_active_element); try testing.expect(try parse(arena, .GET, "/element/7/text", "") == .get_element_text); From c98afa4bebd39334cfd26a2a81b27aa6e857eb75 Mon Sep 17 00:00:00 2001 From: Celine Debled Date: Tue, 22 Sep 2026 12:28:37 +0200 Subject: [PATCH 23/93] cdp: echo the sessionId in replies to browser-level commands --- src/server/cdp/CDP.zig | 16 ++++++---------- src/server/cdp/domains/browser.zig | 8 ++++---- src/server/cdp/domains/target.zig | 14 +++++++------- 3 files changed, 17 insertions(+), 21 deletions(-) diff --git a/src/server/cdp/CDP.zig b/src/server/cdp/CDP.zig index b7e0d560e..aa44fbe16 100644 --- a/src/server/cdp/CDP.zig +++ b/src/server/cdp/CDP.zig @@ -1397,14 +1397,12 @@ pub const Command = struct { return self.browser_context.?; } - const SendResultOpts = struct { - include_session_id: bool = true, - }; - pub fn sendResult(self: *Command, result: anytype, opts: SendResultOpts) !void { + const SendResultOpts = struct {}; + pub fn sendResult(self: *Command, result: anytype, _: SendResultOpts) !void { return self.sender.sendJSON(.{ .id = self.input.id, .result = if (comptime @typeInfo(@TypeOf(result)) == .null) struct {}{} else result, - .sessionId = if (opts.include_session_id) self.input.session_id else null, + .sessionId = self.input.session_id, }); } @@ -1413,14 +1411,12 @@ pub const Command = struct { return self.cdp.sendEvent(method, p, opts); } - const SendErrorOpts = struct { - include_session_id: bool = true, - }; - pub fn sendError(self: *Command, code: i32, message: []const u8, opts: SendErrorOpts) !void { + const SendErrorOpts = struct {}; + pub fn sendError(self: *Command, code: i32, message: []const u8, _: SendErrorOpts) !void { return self.sender.sendJSON(.{ .id = self.input.id, .@"error" = .{ .code = code, .message = message }, - .sessionId = if (opts.include_session_id) self.input.session_id else null, + .sessionId = self.input.session_id, }); } diff --git a/src/server/cdp/domains/browser.zig b/src/server/cdp/domains/browser.zig index 4ebc03408..1cf61c269 100644 --- a/src/server/cdp/domains/browser.zig +++ b/src/server/cdp/domains/browser.zig @@ -74,7 +74,7 @@ fn getVersion(cmd: *CDP.Command) !void { .revision = REVISION, .userAgent = CDP_USER_AGENT, .jsVersion = JS_VERSION, - }, .{ .include_session_id = false }); + }, .{}); } // https://chromedevtools.github.io/devtools-protocol/tot/Browser/#method-setDownloadBehavior @@ -195,7 +195,7 @@ fn grantPermissions(cmd: *CDP.Command) !void { try browser.setPermission(name, .granted); } - return cmd.sendResult(null, .{ .include_session_id = false }); + return cmd.sendResult(null, .{}); } // Set a single permission to an explicit state ("granted", "denied" or @@ -219,14 +219,14 @@ fn setPermission(cmd: *CDP.Command) !void { return error.InvalidPermissionSetting; }; try cmd.cdp.browser.setPermission(params.permission.name, state); - return cmd.sendResult(null, .{ .include_session_id = false }); + return cmd.sendResult(null, .{}); } // Clear all granted permissions; navigator.permissions.query() falls back to // the default "prompt". fn resetPermissions(cmd: *CDP.Command) !void { cmd.cdp.browser.clearPermissions(); - return cmd.sendResult(null, .{ .include_session_id = false }); + return cmd.sendResult(null, .{}); } const testing = @import("../testing.zig"); diff --git a/src/server/cdp/domains/target.zig b/src/server/cdp/domains/target.zig index 0e906a37c..d5162d0eb 100644 --- a/src/server/cdp/domains/target.zig +++ b/src/server/cdp/domains/target.zig @@ -68,13 +68,13 @@ fn getTargets(cmd: *CDP.Command) !void { const bc = cmd.browser_context orelse { return cmd.sendResult(.{ .targetInfos = [_]TargetInfo{}, - }, .{ .include_session_id = false }); + }, .{}); }; const target_id = &(bc.target_id orelse { return cmd.sendResult(.{ .targetInfos = [_]TargetInfo{}, - }, .{ .include_session_id = false }); + }, .{}); }); return cmd.sendResult(.{ @@ -86,7 +86,7 @@ fn getTargets(cmd: *CDP.Command) !void { .attached = true, .canAccessOpener = false, }}, - }, .{ .include_session_id = false }); + }, .{}); } fn getBrowserContexts(cmd: *CDP.Command) !void { @@ -99,7 +99,7 @@ fn getBrowserContexts(cmd: *CDP.Command) !void { return cmd.sendResult(.{ .browserContextIds = browser_context_ids, - }, .{ .include_session_id = false }); + }, .{}); } fn createBrowserContext(cmd: *CDP.Command) !void { @@ -316,7 +316,7 @@ fn closeTarget(cmd: *CDP.Command) !void { // can't be null if we have a target_id lp.assert(bc.session.hasPage(), "CDP.target.closeTarget null frame", .{}); - try cmd.sendResult(.{ .success = true }, .{ .include_session_id = false }); + try cmd.sendResult(.{ .success = true }, .{}); for (bc.attached_sessions.items) |session| { bc.fetchDisableForSession(session.id); @@ -382,7 +382,7 @@ fn getTargetInfo(cmd: *CDP.Command) !void { .attached = true, .canAccessOpener = false, }, - }, .{ .include_session_id = false }); + }, .{}); } return cmd.sendResult(.{ @@ -394,7 +394,7 @@ fn getTargetInfo(cmd: *CDP.Command) !void { .attached = true, .canAccessOpener = false, }, - }, .{ .include_session_id = false }); + }, .{}); } fn sendMessageToTarget(cmd: *CDP.Command) !void { From ce5f6195db0ec24a591665e340b0a75d1c8bc6e8 Mon Sep 17 00:00:00 2001 From: Celine Debled Date: Tue, 22 Sep 2026 12:28:37 +0200 Subject: [PATCH 24/93] cdp: test that replies echo the sessionId --- src/server/cdp/domains/browser.zig | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/server/cdp/domains/browser.zig b/src/server/cdp/domains/browser.zig index 1cf61c269..87d899e29 100644 --- a/src/server/cdp/domains/browser.zig +++ b/src/server/cdp/domains/browser.zig @@ -249,6 +249,16 @@ test "cdp.browser: getVersion" { }, .{ .id = 32, .index = 0, .session_id = null }); } +// Clients route replies by (sessionId, id): a reply must echo the sessionId of its command. +test "cdp.browser: replies echo the sessionId" { + var ctx = try testing.context(); + defer ctx.deinit(); + + _ = try ctx.loadBrowserContext(.{ .session_id = "SID-X" }); + try ctx.processMessage(.{ .id = 1, .method = "Browser.getVersion", .sessionId = "SID-X" }); + try ctx.expectSentResult(.{ .product = PRODUCT }, .{ .id = 1, .session_id = "SID-X" }); +} + test "cdp.browser: getWindowForTarget" { var ctx = try testing.context(); defer ctx.deinit(); From fdee7d558c123fb91226a611ea00db4d048ea4a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Tue, 22 Sep 2026 16:39:13 +0200 Subject: [PATCH 25/93] scroll: write first, then report whether it moved writeScroll held the map entry across the clamp, which reads styles and walks children, and it created an entry even for a write that changed nothing. It now clamps both axes against a plain lookup and only takes the entry when an offset actually moves. That makes the write itself the answer to "can this container move?", so the wheel walk asks by writing instead of recomputing the extent first, and canScrollAxis is gone. --- src/browser/frame/user_input.zig | 18 +++---- src/browser/webapi/Element.zig | 91 +++++++++++++------------------- 2 files changed, 44 insertions(+), 65 deletions(-) diff --git a/src/browser/frame/user_input.zig b/src/browser/frame/user_input.zig index 5e6b621ac..a8dd0308c 100644 --- a/src/browser/frame/user_input.zig +++ b/src/browser/frame/user_input.zig @@ -491,15 +491,11 @@ pub fn wheel(frame: *Frame, target: *Element, x: f64, y: f64, delta_x: f64, delt try scrollAxis(target, .height, deltaToScroll(delta_y), owner); } -/// One axis' delta goes to the nearest ancestor-or-self scroll container that -/// can still move along it, and to that one alone: a wheel latches to a single -/// scroller and a delta is never split across two, matching Chrome's -/// FindNodeToLatch (cc/input/input_handler.cc). A container whose -/// overscroll-behavior doesn't propagate takes the latch even when it can't -/// move, which ends the walk. The viewport terminates it otherwise. -/// -/// Each axis walks on its own, so a wheel may latch to a different scroller per -/// axis, unlike an absolute position. +/// A wheel latches to a single scroller and a delta is never split across two, +/// as in Chrome's FindNodeToLatch (cc/input/input_handler.cc): the whole delta +/// goes to the nearest ancestor-or-self container that can still move along +/// this axis. One whose overscroll-behavior doesn't propagate takes the latch +/// even when it can't move, which ends the walk. fn scrollAxis(target: *Element, comptime axis: Element.Axis, delta: i32, frame: *Frame) !void { if (delta == 0) { return; @@ -515,8 +511,8 @@ fn scrollAxis(target: *Element, comptime axis: Element.Axis, delta: i32, frame: .container => |c| c, .viewport => break, }; - if (container.canScrollAxis(axis, delta, frame)) { - return container.scrollByAxis(axis, delta, frame); + if (try container.scrollByAxis(axis, delta, frame)) { + return; } if (container.containsOverscroll(axes, frame)) { return; diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index d8546f79e..2d2507bc7 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -1578,7 +1578,7 @@ pub fn getScrollTop(self: *Element, frame: *Frame) u32 { } pub fn setScrollTop(self: *Element, value: i32, frame: *Frame) !void { - return self.writeScroll(.{ .to = .{ .left = null, .top = value } }, frame); + _ = try self.writeScroll(.{ .to = .{ .left = null, .top = value } }, frame); } pub fn getScrollLeft(self: *Element, frame: *Frame) u32 { @@ -1588,7 +1588,7 @@ pub fn getScrollLeft(self: *Element, frame: *Frame) u32 { } pub fn setScrollLeft(self: *Element, value: i32, frame: *Frame) !void { - return self.writeScroll(.{ .to = .{ .left = value, .top = null } }, frame); + _ = try self.writeScroll(.{ .to = .{ .left = value, .top = null } }, frame); } pub const ScrollAxes = struct { x: bool = false, y: bool = false }; @@ -1618,7 +1618,7 @@ pub fn scrollContainer(self: *Element, axes: ScrollAxes, frame: *Frame) ScrollTa } /// Whether the element's own overscroll-behavior keeps a scroll from chaining -/// out of it along any of `axes`. +/// out of it. pub fn containsOverscroll(self: *Element, axes: ScrollAxes, frame: *Frame) bool { const owner = self.ownerFrame(frame) orelse return false; const contains = owner._style_manager.overscrollContainAxes(self); @@ -1667,14 +1667,11 @@ pub fn getScrollWidth(self: *Element, frame: *Frame) f64 { return @max(width, self.contentAxis(frame, .width)); } -/// The furthest offset a scroll along `axis` may reach, or null when there is -/// no box to measure against. Without an explicit size the client and the -/// content measurements collapse onto the same sum, so nothing can overflow: -/// an element sized by a stylesheet or holding only text stays unbounded, as -/// every scroll write was before there was an extent at all. Refusing a scroll -/// we can't prove impossible is worse than allowing one too many. html and -/// body are out too: their artificial giant defaults would fabricate an extent -/// against the real viewport. +/// Null where we can't prove a limit, which leaves the offset unbounded: +/// without an explicit size the client and content measurements collapse onto +/// the same sum, and html and body carry giant defaults that would fabricate +/// an extent against the real viewport. Refusing a scroll we can't prove +/// impossible is worse than allowing one too many. fn scrollExtent(self: *Element, frame: *Frame, comptime axis: Axis) ?f64 { if (self.scrollsViewport() or !self.getElementAxis(frame, axis).explicit) { return null; @@ -2019,46 +2016,28 @@ pub const ScrollToOpts = union(enum) { pub fn scrollTo(self: *Element, opts: ?ScrollToOpts, y: ?i32, frame: *Frame) !void { const o = (opts orelse return).offsets(y); - return self.writeScroll(.{ .to = o }, frame); + _ = try self.writeScroll(.{ .to = o }, frame); } // scrollBy(): like scrollTo() but relative to the current position. pub fn scrollBy(self: *Element, opts: ?ScrollToOpts, y: ?i32, frame: *Frame) !void { const o = (opts orelse return).offsets(y); - return self.writeScroll(.{ .by = o }, frame); + _ = try self.writeScroll(.{ .by = o }, frame); } -/// Scrolls one axis by `delta`. -pub fn scrollByAxis(self: *Element, comptime axis: Axis, delta: i32, frame: *Frame) !void { +/// Reports whether the container moved: a wheel walks outward until one does. +pub fn scrollByAxis(self: *Element, comptime axis: Axis, delta: i32, frame: *Frame) !bool { return self.writeScroll(.{ .by = switch (axis) { .width => .{ .left = delta, .top = null }, .height => .{ .left = null, .top = delta }, } }, frame); } -/// Whether `delta` can move this container along `axis` at all. A wheel latches -/// to the nearest container for which this holds; an unmeasurable box has no -/// end to be at, so it always takes the delta. -pub fn canScrollAxis(self: *Element, comptime axis: Axis, delta: i32, frame: *Frame) bool { - const offset: i64 = switch (axis) { - .width => self.getScrollLeft(frame), - .height => self.getScrollTop(frame), - }; - if (delta < 0) { - return offset > 0; - } - const extent = self.scrollExtent(frame, axis) orelse return true; - const max: i64 = @floor(extent); - return offset < max; -} - -// Where a write puts the offsets: at an absolute position, or that much from -// wherever they are. const ScrollWrite = union(enum) { to: ScrollToOpts.Offsets, by: ScrollToOpts.Offsets, - // The absolute target for one axis, null when the write leaves it alone. + // Null leaves that axis alone. fn target(self: ScrollWrite, comptime axis: Axis, current: u32) ?i64 { const offsets = switch (self) { inline else => |o| o, @@ -2074,30 +2053,34 @@ const ScrollWrite = union(enum) { } }; -/// The single scroll write: clamps both axes, stores, and schedules the events -/// once for the pair. -fn writeScroll(self: *Element, write: ScrollWrite, frame: *Frame) !void { - const owner = self.ownerFrame(frame) orelse return; +/// Every scroll write goes through here. Reports whether anything moved; one +/// that lands where the offsets already are doesn't even take a map entry. +fn writeScroll(self: *Element, write: ScrollWrite, frame: *Frame) !bool { + const owner = self.ownerFrame(frame) orelse return false; + const current: ScrollPosition = owner.page.element_scroll_positions.get(self) orelse .{}; + + var x = current.x; + var y = current.y; + if (write.target(.width, current.x)) |target| { + x = self.clampScroll(frame, .width, target); + } + if (write.target(.height, current.y)) |target| { + y = self.clampScroll(frame, .height, target); + } + if (x == current.x and y == current.y) { + return false; + } + const gop = try owner.page.element_scroll_positions.getOrPut(owner.page.frame_arena, self); if (!gop.found_existing) { gop.value_ptr.* = .{}; } - const old_x = gop.value_ptr.x; - const old_y = gop.value_ptr.y; - - if (write.target(.width, old_x)) |target| { - gop.value_ptr.x = self.clampScroll(frame, .width, target); - } - if (write.target(.height, old_y)) |target| { - gop.value_ptr.y = self.clampScroll(frame, .height, target); - } - - if (gop.value_ptr.x != old_x or gop.value_ptr.y != old_y) { - try self.scheduleScrollEvents(gop.value_ptr, owner); - } + gop.value_ptr.x = x; + gop.value_ptr.y = y; + try self.scheduleScrollEvents(gop.value_ptr, owner); + return true; } -/// `target` brought into [0, scrollExtent]. fn clampScroll(self: *Element, frame: *Frame, comptime axis: Axis, target: i64) u32 { var clamped = target; if (clamped < 0) { @@ -2112,8 +2095,8 @@ fn clampScroll(self: *Element, frame: *Frame, comptime axis: Axis, target: i64) // Scrolling an element fires a scroll event and then a scrollend event, // asynchronously and throttled, mirroring Window.scrollTo. Scrolls of the // scrolling element (the root) are fired at the document instead. -// `frame` is the element's owner frame, `pos` its entry in that frame's -// positions (both resolved by writeScroll). +// `frame` is the element's owner frame and `pos` its entry there, both +// resolved by writeScroll. fn scheduleScrollEvents(self: *Element, pos: *ScrollPosition, frame: *Frame) !void { const task_pending = pos.state != .done; pos.state = .scroll; From 93c551bed6e96d211112182d4a46f14f084ec6bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Tue, 22 Sep 2026 16:39:13 +0200 Subject: [PATCH 26/93] css: share the axis shorthand table with the CSSOM The cascade expanded overscroll-behavior into longhands but CSSStyleDeclaration didn't, so setting the shorthand left overscrollBehaviorX reading empty and a style= block round-tripped through the object lost it. CssParser.axis_shorthands is now the one list, with axisShorthand and axisLonghand as the lookups both sides use: the CSSOM's overflow-only special cases (set, apply, remove, priority, serialize) became that lookup, and OverflowPair became AxisPair. Verified against Chrome 153: `overscroll-behavior: contain auto` reads back per axis, collapses to `contain` when both match, and serializes as one declaration. --- src/browser/StyleManager.zig | 18 ++--- src/browser/css/Parser.zig | 47 +++++++++++ src/browser/tests/element/styles.html | 19 +++++ .../webapi/css/CSSStyleDeclaration.zig | 79 +++++++++---------- 4 files changed, 107 insertions(+), 56 deletions(-) diff --git a/src/browser/StyleManager.zig b/src/browser/StyleManager.zig index d3f529d9c..a5ed6f4e7 100644 --- a/src/browser/StyleManager.zig +++ b/src/browser/StyleManager.zig @@ -1303,12 +1303,6 @@ fn foldDeclarations(block: []const u8, customs: ?*CustomSink) !TrackedProperties return slots.props(); } -// The ` []` shorthands the cascade expands into the tracked longhands. -const axis_shorthands = [_]struct { name: []const u8, x: []const u8, y: []const u8 }{ - .{ .name = "overflow", .x = "overflow-x", .y = "overflow-y" }, - .{ .name = "overscroll-behavior", .x = "overscroll-behavior-x", .y = "overscroll-behavior-y" }, -}; - /// One block's winning value per tracked property, folded in declaration /// order. const Slots = struct { @@ -1332,13 +1326,11 @@ const Slots = struct { slots: [property_names.len]Slot = @splat(.{}), fn apply(self: *Slots, name: []const u8, value: []const u8, important: bool) void { - for (axis_shorthands) |shorthand| { - if (std.ascii.eqlIgnoreCase(name, shorthand.name)) { - const values = CssParser.splitAxisPair(value) orelse return; - self.apply(shorthand.x, values.x, important); - self.apply(shorthand.y, values.y, important); - return; - } + if (CssParser.axisShorthand(name)) |shorthand| { + const values = CssParser.splitAxisPair(value) orelse return; + self.apply(shorthand.x, values.x, important); + self.apply(shorthand.y, values.y, important); + return; } for (property_names, &self.slots) |tracked, *slot| { if (std.ascii.eqlIgnoreCase(name, tracked)) { diff --git a/src/browser/css/Parser.zig b/src/browser/css/Parser.zig index 8cda3dcc2..5e7f5c12e 100644 --- a/src/browser/css/Parser.zig +++ b/src/browser/css/Parser.zig @@ -27,6 +27,53 @@ pub const Declaration = struct { pub const AxisPair = struct { x: []const u8, y: []const u8 }; +pub const AxisShorthand = struct { + name: []const u8, + x: []const u8, + y: []const u8, +}; + +// The ` []` shorthands whose longhands the style cascade tracks. Both the +// CSSOM object and the cascade store these expanded: setting one sets both +// longhands, reading or serializing it recombines them. +pub const axis_shorthands = [_]AxisShorthand{ + .{ .name = "overflow", .x = "overflow-x", .y = "overflow-y" }, + .{ .name = "overscroll-behavior", .x = "overscroll-behavior-x", .y = "overscroll-behavior-y" }, +}; + +/// The axis shorthand `name` names, if it names one. +pub fn axisShorthand(name: []const u8) ?AxisShorthand { + for (axis_shorthands) |shorthand| { + if (std.ascii.eqlIgnoreCase(name, shorthand.name)) { + return shorthand; + } + } + return null; +} + +pub const AxisLonghand = struct { + shorthand: AxisShorthand, + is_x: bool, + + /// The longhand on the other axis. + pub fn partner(self: AxisLonghand) []const u8 { + return if (self.is_x) self.shorthand.y else self.shorthand.x; + } +}; + +/// The axis shorthand `name` is a longhand of, if it is one. +pub fn axisLonghand(name: []const u8) ?AxisLonghand { + for (axis_shorthands) |shorthand| { + if (std.ascii.eqlIgnoreCase(name, shorthand.x)) { + return .{ .shorthand = shorthand, .is_x = true }; + } + if (std.ascii.eqlIgnoreCase(name, shorthand.y)) { + return .{ .shorthand = shorthand, .is_x = false }; + } + } + return null; +} + /// An ` []` axis shorthand such as `overflow` or `overscroll-behavior`; /// a single value applies to both axes. More than two values is invalid and /// null, as is an empty declaration. diff --git a/src/browser/tests/element/styles.html b/src/browser/tests/element/styles.html index 043c3c02c..1c30d525d 100644 --- a/src/browser/tests/element/styles.html +++ b/src/browser/tests/element/styles.html @@ -113,6 +113,25 @@ } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/Sanitizer.zig b/src/browser/webapi/Sanitizer.zig new file mode 100644 index 000000000..3650356af --- /dev/null +++ b/src/browser/webapi/Sanitizer.zig @@ -0,0 +1,1023 @@ +// 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 . + +// THE ONE THING YOU NEED TO KNOW ABOUT THIS API: +// It's a configuration for generating safe HTML. It can either be in the shape +// of "deny these things" or "allow these things". That's mutually exclusive! You +// can have _allow_elements != null OR _remove_elements != null, but not both. +// Methods need to work with both shapes. E.g. if we're removing something and +// _allow_elements != null, then we remove it from _allow_elements. BUT, if +// _allow_elements == null, then we add it to _remove_elements. This applies +// to every category, e.g _allow_attributes vs _remove_attributes. + +const std = @import("std"); +const lp = @import("lightpanda"); + +const js = @import("../js/js.zig"); +const Page = @import("../Page.zig"); + +const defaults = @import("sanitizer_defaults.zig"); + +const String = lp.String; +const Execution = js.Execution; +const Allocator = std.mem.Allocator; + +const Sanitizer = @This(); + +// intern common namespaces +pub const Namespace = union(enum) { + none, // always distinct from any other value + xhtml, + svg, + mathml, + xlink, + xml, + xmlns, + other: []const u8, + + const lookup = std.StaticStringMap(Namespace).initComptime(.{ + .{ defaults.xhtml_ns, .xhtml }, + .{ defaults.svg_ns, .svg }, + .{ defaults.mathml_ns, .mathml }, + .{ defaults.xlink_ns, .xlink }, + .{ defaults.xml_ns, .xml }, + .{ defaults.xmlns_ns, .xmlns }, + }); + + fn intern(namespace_: ?[]const u8) Namespace { + const namespace = namespace_ orelse return .none; + if (namespace.len == 0) { + return .none; + } + return lookup.get(namespace) orelse .{ .other = namespace }; + } + + pub fn uri(self: Namespace) ?[]const u8 { + switch (self) { + .none => return null, + .other => |value| return value, + inline else => |_, tag| { + for (lookup.values(), 0..) |value, i| { + if (value == tag) { + return lookup.keys()[i]; + } + } else unreachable; // has to be in the lookup + }, + } + } + + fn eql(a: Namespace, b: Namespace) bool { + const a_uri = switch (a) { + .other => |value| value, + else => return std.meta.activeTag(a) == std.meta.activeTag(b), + }; + const b_uri = switch (b) { + .other => |value| value, + else => return false, + }; + return std.mem.eql(u8, a_uri, b_uri); + } +}; + +pub const Name = struct { + name: String, // attribute or element name + namespace: Namespace, + + fn lessThan(_: void, a: Name, b: Name) bool { + const a_ns = a.namespace.uri() orelse { + return b.namespace != .none or std.mem.lessThan(u8, a.name.str(), b.name.str()); + }; + const b_ns = b.namespace.uri() orelse return false; + return switch (std.mem.order(u8, a_ns, b_ns)) { + .lt => true, + .gt => false, + .eq => std.mem.lessThan(u8, a.name.str(), b.name.str()), + }; + } + + fn eql(a: Name, b: Name) bool { + return a.namespace.eql(b.namespace) and a.name.eql(b.name); + } + + fn isDataAttribute(self: Name) bool { + return self.namespace == .none and std.mem.startsWith(u8, self.name.str(), "data-"); + } +}; + +const NameContext = struct { + pub fn hash(_: NameContext, key: Name) u32 { + var hasher = std.hash.Wyhash.init(0); + hasher.update(key.name.str()); + hasher.update(&.{@intFromEnum(std.meta.activeTag(key.namespace))}); + if (key.namespace == .other) { + hasher.update(key.namespace.other); + } + return @truncate(hasher.final()); + } + + pub fn eql(_: NameContext, a: Name, b: Name, _: usize) bool { + return a.eql(b); + } +}; + +const NameSet = std.array_hash_map.Custom(Name, void, NameContext, true); +const NameMap = std.array_hash_map.Custom(Name, NameSet, NameContext, true); +const TargetSet = std.array_hash_map.String(void); + +_rc: lp.RC = .{}, +_owned_arena: *lp.Arena, +_arena: Allocator, + +_allow_elements: ?NameSet = null, +_remove_elements: ?NameSet = null, +_replace_elements: ?NameSet = null, +_allow_attributes: ?NameSet = null, +_remove_attributes: ?NameSet = null, +_allow_processing_instructions: ?TargetSet = null, +_remove_processing_instructions: ?TargetSet = null, +_element_allow_attributes: NameMap = .empty, +_element_remove_attributes: NameMap = .empty, + +// tri-states, need to capture absent +_comments: ?bool = null, +_data_attributes: ?bool = null, +_javascript_urls: ?bool = null, + +const ElementNamespace = struct { + name: []const u8, + namespace: js.Nullable([]const u8) = .{ .value = defaults.xhtml_ns }, +}; + +const ElementNamespaceWithAttributes = struct { + attributes: ?[]const SanitizerAttribute = null, + name: []const u8, + namespace: js.Nullable([]const u8) = .{ .value = defaults.xhtml_ns }, + removeAttributes: ?[]const SanitizerAttribute = null, +}; + +const AttributeNamespace = struct { + name: []const u8, + namespace: js.Nullable([]const u8) = .{ .value = null }, +}; + +const ProcessingInstruction = struct { + target: []const u8, +}; + +const SanitizerElement = union(enum) { + dictionary: ElementNamespace, + string: []const u8, +}; + +const SanitizerElementWithAttributes = union(enum) { + dictionary: ElementNamespaceWithAttributes, + string: []const u8, +}; + +const SanitizerAttribute = union(enum) { + dictionary: AttributeNamespace, + string: []const u8, +}; + +const SanitizerPI = union(enum) { + dictionary: ProcessingInstruction, + string: []const u8, +}; + +const Config = struct { + attributes: ?[]const SanitizerAttribute = null, + comments: ?js.Value = null, + dataAttributes: ?js.Value = null, + elements: ?[]const SanitizerElementWithAttributes = null, + javascriptURLs: ?js.Value = null, + processingInstructions: ?[]const SanitizerPI = null, + removeAttributes: ?[]const SanitizerAttribute = null, + removeElements: ?[]const SanitizerElement = null, + removeProcessingInstructions: ?[]const SanitizerPI = null, + replaceWithChildrenElements: ?[]const SanitizerElement = null, + + fn boolean(value: ?js.Value, dflt: bool) bool { + // we use ?js.Value for our booleans to tell the difference between not + // provided vs null. Not provided -> default. Null -> false + const v = value orelse return dflt; + return v.toBool(); + } +}; + +// ?js.Value because not provided, undefined and null are all handled differently +pub fn init(configuration_: ?js.Value, exec: *const Execution) !*Sanitizer { + const arena = try exec.getPinnedArena(.small, "Sanitizer"); + errdefer arena.release(); + + const self = try arena.create(Sanitizer); + self.* = .{ ._owned_arena = arena, ._arena = arena.allocator() }; + + blk: { + const configuration = configuration_ orelse { + try self.setFromDefault(); + break :blk; + }; + + if (configuration.isUndefined()) { + try self.setFromDefault(); + break :blk; + } + + if (configuration.isString()) |preset| { + if ((try preset.toSSO(false)).eql(comptime .wrap("default")) == false) { + return exec.js.typeError("invalid Sanitizer preset"); + } + try self.setFromDefault(); + break :blk; + } + + const config: Config = if (configuration.isNull()) .{} else try configuration.toZig(Config); + if (try self.setFromConfig(config) == false) { + return exec.js.typeError("invalid Sanitizer configuration"); + } + } + + arena.report(); + return self; +} + +pub fn deinit(self: *Sanitizer, _: *Page) void { + self._owned_arena.release(); +} + +pub fn acquireRef(self: *Sanitizer) void { + self._rc.acquire(); +} + +pub fn releaseRef(self: *Sanitizer, page: *Page) void { + self._rc.release(self, page); +} + +// `new Sanitizer("default")`, uses the built-in safe defaults +fn setFromDefault(self: *Sanitizer) !void { + const arena = self._arena; + + var elements: NameSet = .empty; + try elements.ensureTotalCapacity(arena, defaults.default_elements.len); + for (defaults.default_elements) |element| { + const name = staticName(.{ .name = element.name, .namespace = element.namespace }); + elements.putAssumeCapacity(name, {}); + + // Every default element carries an attribute list, empty or not. + var attributes: NameSet = .empty; + try attributes.ensureTotalCapacity(arena, element.attributes.len); + for (element.attributes) |attribute| { + attributes.putAssumeCapacity(staticName(attribute), {}); + } + try self._element_allow_attributes.put(arena, name, attributes); + } + self._allow_elements = elements; + + var attributes: NameSet = .empty; + try attributes.ensureTotalCapacity(arena, defaults.default_attributes.len); + for (defaults.default_attributes) |attribute| { + attributes.putAssumeCapacity(staticName(attribute), {}); + } + self._allow_attributes = attributes; + + self._allow_processing_instructions = .empty; + self._comments = false; + self._data_attributes = false; + self._javascript_urls = false; +} + +fn setFromConfig(self: *Sanitizer, config: Config) !bool { + var all_new = true; + + const arena = self._arena; + if (config.elements) |elements| { + var set: NameSet = .empty; + for (elements) |element| { + const name = try self.ownName(canonicalElementWithAttributes(element)); + all_new = try insertNew(&set, arena, name) and all_new; + + switch (element) { + .string => {}, + .dictionary => |dictionary| { + if (dictionary.attributes) |attributes| { + all_new = try self.putElementAttributes(&self._element_allow_attributes, name, attributes) and all_new; + } + if (dictionary.removeAttributes) |attributes| { + all_new = try self.putElementAttributes(&self._element_remove_attributes, name, attributes) and all_new; + } + }, + } + // canonical form: an element with neither list has an empty remove-list + if (self._element_allow_attributes.contains(name) == false and self._element_remove_attributes.contains(name) == false) { + try self._element_remove_attributes.put(arena, name, .empty); + } + } + self._allow_elements = set; + } + if (config.removeElements) |elements| { + self._remove_elements = try self.elementSet(elements, &all_new); + } + if (config.replaceWithChildrenElements) |elements| { + self._replace_elements = try self.elementSet(elements, &all_new); + } + if (config.attributes) |attributes| { + self._allow_attributes = try self.attributeSet(attributes, &all_new); + } + if (config.removeAttributes) |attributes| { + self._remove_attributes = try self.attributeSet(attributes, &all_new); + } + if (config.processingInstructions) |pis| { + self._allow_processing_instructions = try self.targetSet(pis, &all_new); + } + if (config.removeProcessingInstructions) |pis| { + self._remove_processing_instructions = try self.targetSet(pis, &all_new); + } + + self._comments = Config.boolean(config.comments, true); + if (self._allow_attributes != null or config.dataAttributes != null) { + self._data_attributes = Config.boolean(config.dataAttributes, true); + } + self._javascript_urls = Config.boolean(config.javascriptURLs, true); + + if (config.elements == null and config.removeElements == null) { + self._remove_elements = .empty; + } + if (config.attributes == null and config.removeAttributes == null) { + self._remove_attributes = .empty; + } + if (self._allow_processing_instructions == null and self._remove_processing_instructions == null) { + self._remove_processing_instructions = .empty; + } + + return all_new and self.isValid(); +} + +fn elementSet(self: *Sanitizer, elements: []const SanitizerElement, all_new: *bool) !NameSet { + var set: NameSet = .empty; + for (elements) |element| { + const name = try self.ownName(canonicalElement(element)); + all_new.* = try insertNew(&set, self._arena, name) and all_new.*; + } + return set; +} + +fn attributeSet(self: *Sanitizer, attributes: []const SanitizerAttribute, all_new: *bool) !NameSet { + var set: NameSet = .empty; + for (attributes) |attribute| { + const name = try self.ownName(canonicalAttribute(attribute)); + all_new.* = try insertNew(&set, self._arena, name) and all_new.*; + } + return set; +} + +fn targetSet(self: *Sanitizer, pis: []const SanitizerPI, all_new: *bool) !TargetSet { + var set: TargetSet = .empty; + for (pis) |pi| { + const target = try self.own(canonicalTarget(pi)); + const gop = try set.getOrPut(self._arena, target); + all_new.* = gop.found_existing == false and all_new.*; + gop.value_ptr.* = {}; + } + return set; +} + +fn putElementAttributes(self: *Sanitizer, map: *NameMap, element: Name, attributes: []const SanitizerAttribute) !bool { + var all_new = true; + var set: NameSet = .empty; + for (attributes) |attribute| { + const name = try self.ownName(canonicalAttribute(attribute)); + all_new = try insertNew(&set, self._arena, name) and all_new; + } + const gop = try map.getOrPut(self._arena, element); + // A duplicated element name is already fatal; don't let it merge lists. + all_new = gop.found_existing == false and all_new; + gop.value_ptr.* = set; + return all_new; +} + +fn insertNew(set: *NameSet, arena: Allocator, name: Name) !bool { + const gop = try set.getOrPut(arena, name); + gop.value_ptr.* = {}; + return gop.found_existing == false; +} + +fn canonicalElement(element: SanitizerElement) defaults.Name { + return switch (element) { + .string => |name| .{ .name = name, .namespace = .xhtml }, + .dictionary => |d| .{ .name = d.name, .namespace = .intern(d.namespace.value) }, + }; +} + +fn canonicalElementWithAttributes(element: SanitizerElementWithAttributes) defaults.Name { + return switch (element) { + .string => |name| .{ .name = name, .namespace = .xhtml }, + .dictionary => |d| .{ .name = d.name, .namespace = .intern(d.namespace.value) }, + }; +} + +fn canonicalAttribute(attribute: SanitizerAttribute) defaults.Name { + return switch (attribute) { + .string => |name| .{ .name = name, .namespace = .none }, + .dictionary => |d| .{ .name = d.name, .namespace = .intern(d.namespace.value) }, + }; +} + +fn canonicalTarget(pi: SanitizerPI) []const u8 { + return switch (pi) { + .string => |target| target, + .dictionary => |d| d.target, + }; +} + +fn staticName(name: defaults.Name) Name { + // no allocation required, comes from sanitizer_defaults, which are all literals + return .{ .name = .wrap(name.name), .namespace = name.namespace }; +} + +// Take ownership of the name into our arena +fn ownName(self: *Sanitizer, name: defaults.Name) !Name { + return .{ + .name = try .init(self._arena, name.name, .{}), + .namespace = switch (name.namespace) { + .other => |uri| .{ .other = try self.own(uri) }, + else => name.namespace, + }, + }; +} + +fn own(self: *Sanitizer, value: []const u8) ![]const u8 { + return String.intern(value) orelse self._arena.dupe(u8, value); +} + +// - + +const JsName = struct { + name: String, + namespace: ?[]const u8, +}; + +const JsTarget = struct { + target: []const u8, +}; + +pub fn get(self: *const Sanitizer, exec: *const Execution) !js.Object { + const local = exec.js.local.?; + const arena = exec.call_arena; + const config = local.newObject(); + + if (self._allow_elements) |elements| { + const names = try sortedNames(elements, arena); + const array = local.newArray(@intCast(names.len)); + for (names, 0..) |*name, i| { + // name.name == String, need name by ref, else we end up with + // dangling pointer on this stack when we set. + const element = local.newObject(); + _ = try element.set("name", name.name, .{}); + _ = try element.set("namespace", name.namespace.uri(), .{}); + + const allowed = self._element_allow_attributes.getPtr(name.*); + const removed = self._element_remove_attributes.getPtr(name.*); + if (allowed) |set| { + _ = try element.set("attributes", try nameArray(set.*, arena, local), .{}); + } + if (removed) |set| { + _ = try element.set("removeAttributes", try nameArray(set.*, arena, local), .{}); + } + _ = try array.set(@intCast(i), element, .{}); + } + _ = try config.set("elements", array, .{}); + } + if (self._remove_elements) |elements| { + _ = try config.set("removeElements", try nameArray(elements, arena, local), .{}); + } + if (self._replace_elements) |elements| { + _ = try config.set("replaceWithChildrenElements", try nameArray(elements, arena, local), .{}); + } + if (self._allow_attributes) |attributes| { + _ = try config.set("attributes", try nameArray(attributes, arena, local), .{}); + } + if (self._remove_attributes) |attributes| { + _ = try config.set("removeAttributes", try nameArray(attributes, arena, local), .{}); + } + if (self._allow_processing_instructions) |pis| { + _ = try config.set("processingInstructions", try sortedTargets(pis, arena, local), .{}); + } + if (self._remove_processing_instructions) |pis| { + _ = try config.set("removeProcessingInstructions", try sortedTargets(pis, arena, local), .{}); + } + if (self._comments) |comments| { + _ = try config.set("comments", comments, .{}); + } + if (self._data_attributes) |data_attributes| { + _ = try config.set("dataAttributes", data_attributes, .{}); + } + if (self._javascript_urls) |javascript_urls| { + _ = try config.set("javascriptURLs", javascript_urls, .{}); + } + return config; +} + +fn sortedNames(set: NameSet, arena: Allocator) ![]Name { + const names = try arena.dupe(Name, set.keys()); + std.mem.sort(Name, names, {}, Name.lessThan); + return names; +} + +fn nameArray(set: NameSet, arena: Allocator, local: *const js.Local) !js.Array { + const names = try sortedNames(set, arena); + const array = local.newArray(@intCast(names.len)); + for (names, 0..) |*name, i| { + _ = try array.set(@intCast(i), JsName{ .name = name.name, .namespace = name.namespace.uri() }, .{}); + } + return array; +} + +fn sortedTargets(set: TargetSet, arena: Allocator, local: *const js.Local) !js.Array { + const targets = try arena.dupe([]const u8, set.keys()); + std.mem.sort([]const u8, targets, {}, struct { + fn lessThan(_: void, a: []const u8, b: []const u8) bool { + return std.mem.lessThan(u8, a, b); + } + }.lessThan); + + const array = local.newArray(@intCast(targets.len)); + for (targets, 0..) |target, i| { + _ = try array.set(@intCast(i), JsTarget{ .target = target }, .{}); + } + return array; +} + +fn allowElement(self: *Sanitizer, element: SanitizerElementWithAttributes) !bool { + const name = try self.ownName(canonicalElementWithAttributes(element)); + const dictionary = switch (element) { + .string => return self.allowName(name, null, null), + .dictionary => |d| d, + }; + + var allowed: NameSet = .empty; + if (dictionary.attributes) |attributes| { + for (attributes) |attribute| { + _ = try insertNew(&allowed, self._arena, try self.ownName(canonicalAttribute(attribute))); + } + } + var removed: NameSet = .empty; + if (dictionary.removeAttributes) |attributes| { + for (attributes) |attribute| { + _ = try insertNew(&removed, self._arena, try self.ownName(canonicalAttribute(attribute))); + } + } + return self.allowName( + name, + if (dictionary.attributes == null) null else &allowed, + if (dictionary.removeAttributes == null) null else &removed, + ); +} + +fn removeElement(self: *Sanitizer, element: SanitizerElement) !bool { + return self.removeName(try self.ownName(canonicalElement(element))); +} + +fn replaceElementWithChildren(self: *Sanitizer, element: SanitizerElement) !bool { + return self.replaceName(try self.ownName(canonicalElement(element))); +} + +fn allowAttribute(self: *Sanitizer, attribute: SanitizerAttribute) !bool { + return self.allowAttributeName(try self.ownName(canonicalAttribute(attribute))); +} + +fn removeAttribute(self: *Sanitizer, attribute: SanitizerAttribute) !bool { + return self.removeAttributeName(try self.ownName(canonicalAttribute(attribute))); +} + +fn allowProcessingInstruction(self: *Sanitizer, pi: SanitizerPI) !bool { + const target = try self.own(canonicalTarget(pi)); + if (self._allow_processing_instructions) |*allowed| { + if (allowed.contains(target)) { + return false; + } + try allowed.put(self._arena, target, {}); + return true; + } + const removed = &self._remove_processing_instructions.?; + return removed.swapRemove(target); +} + +fn removeProcessingInstruction(self: *Sanitizer, pi: SanitizerPI) !bool { + const target = try self.own(canonicalTarget(pi)); + if (self._allow_processing_instructions) |*allowed| { + return allowed.swapRemove(target); + } + const removed = &self._remove_processing_instructions.?; + if (removed.contains(target)) { + return false; + } + try removed.put(self._arena, target, {}); + return true; +} + +fn setComments(self: *Sanitizer, allow: bool) bool { + if (self._comments == allow) { + return false; + } + self._comments = allow; + return true; +} + +fn setDataAttributes(self: *Sanitizer, allow: bool) bool { + const allowed = &(self._allow_attributes orelse return false); + if (self._data_attributes == allow) { + return false; + } + if (allow) { + // any data-* attribte named individually must now be dropped + for (self._element_allow_attributes.values()) |*set| { + removeDataAttributes(set); + } + removeDataAttributes(allowed); + } + self._data_attributes = allow; + return true; +} + +fn setJavascriptURLs(self: *Sanitizer, allow: bool) bool { + if (self._javascript_urls == allow) { + return false; + } + self._javascript_urls = allow; + return true; +} + +// The baseline is a remove list, removeName and removeAttributeName work with +// both an allow-config and remove-config, so here, we don't need to worry about +// which config shape we have. +fn removeUnsafe(self: *Sanitizer) !bool { + var modified = false; + for (defaults.baseline_remove_elements) |element| { + modified = try self.removeName(staticName(element)) or modified; + } + for (defaults.event_handler_attributes) |attribute| { + modified = try self.removeAttributeName(staticName(.{ .name = attribute, .namespace = .none })) or modified; + } + if (self._javascript_urls == true) { + self._javascript_urls = false; + modified = true; + } + return modified; +} + +fn allowName(self: *Sanitizer, name: Name, allowed_: ?*NameSet, removed_: ?*NameSet) !bool { + const allowed = allowed_; + var removed = removed_; + + const elements = &(self._allow_elements orelse { + // A remove-list config has no per-element lists to put these in. + if (allowed != null or (removed != null and removed.?.count() != 0)) { + return false; + } + var modified = self.takeReplace(name); + const remove_elements = &self._remove_elements.?; + if (remove_elements.swapRemove(name)) { + modified = true; + } + return modified; + }); + + const modified = self.takeReplace(name); + + if (self._allow_attributes) |global_allowed| { + if (allowed) |set| { + removeAll(set, global_allowed); + if (self._data_attributes == true) { + removeDataAttributes(set); + } + } + if (removed) |set| { + retainAll(set, global_allowed); + } + } else { + const global_removed = self._remove_attributes.?; + if (allowed) |set| { + if (removed) |other| { + removeAll(set, other.*); + removed = null; + } + removeAll(set, global_removed); + } + if (removed) |set| { + removeAll(set, global_removed); + } + } + + // canonical form: an element with neither list has an empty remove-list + var empty: NameSet = .empty; + if (allowed == null and removed == null) { + removed = ∅ + } + + if (elements.contains(name) == false) { + try elements.put(self._arena, name, {}); + try self.setElementAttributes(name, allowed, removed); + return true; + } + + // Already allowed: only report a change if the attribute lists differ. + if (sameSet(self._element_allow_attributes.getPtr(name), allowed) and + sameSet(self._element_remove_attributes.getPtr(name), removed)) + { + return modified; + } + try self.setElementAttributes(name, allowed, removed); + return true; +} + +fn setElementAttributes(self: *Sanitizer, name: Name, allowed: ?*NameSet, removed: ?*NameSet) !void { + _ = self._element_allow_attributes.swapRemove(name); + if (allowed) |set| { + try self._element_allow_attributes.put(self._arena, name, set.*); + } + _ = self._element_remove_attributes.swapRemove(name); + if (removed) |set| { + try self._element_remove_attributes.put(self._arena, name, set.*); + } +} + +fn removeName(self: *Sanitizer, name: Name) !bool { + var modified = self.takeReplace(name); + if (self._allow_elements) |*elements| { + if (elements.swapRemove(name)) { + modified = true; + } + _ = self._element_allow_attributes.swapRemove(name); + _ = self._element_remove_attributes.swapRemove(name); + return modified; + } + const elements = &self._remove_elements.?; + const gop = try elements.getOrPut(self._arena, name); + gop.value_ptr.* = {}; + return modified or gop.found_existing == false; +} + +fn replaceName(self: *Sanitizer, name: Name) !bool { + for (defaults.non_replaceable_elements) |element| { + if (staticName(element).eql(name)) { + return false; + } + } + if (self._replace_elements) |elements| { + if (elements.contains(name)) { + return false; + } + } + if (self._remove_elements) |*elements| { + _ = elements.swapRemove(name); + } + if (self._allow_elements) |*elements| { + _ = elements.swapRemove(name); + _ = self._element_allow_attributes.swapRemove(name); + _ = self._element_remove_attributes.swapRemove(name); + } + if (self._replace_elements == null) { + self._replace_elements = .empty; + } + try self._replace_elements.?.put(self._arena, name, {}); + return true; +} + +fn allowAttributeName(self: *Sanitizer, name: Name) !bool { + const allowed = &(self._allow_attributes orelse { + const removed = &self._remove_attributes.?; + return removed.swapRemove(name); + }); + + // Already covered by the blanket data-attribute allowance. + if (self._data_attributes == true and name.isDataAttribute()) { + return false; + } + if (allowed.contains(name)) { + return false; + } + // A global allowance subsumes any per-element one. + for (self._element_allow_attributes.values()) |*set| { + _ = set.swapRemove(name); + } + try allowed.put(self._arena, name, {}); + return true; +} + +fn removeAttributeName(self: *Sanitizer, name: Name) !bool { + if (self._allow_attributes) |*allowed| { + var modified = allowed.swapRemove(name); + for (self._element_allow_attributes.values()) |*set| { + if (set.swapRemove(name)) { + modified = true; + } + } + // Only meaningful against a global allow-list, which just lost `name`. + for (self._element_remove_attributes.values()) |*set| { + _ = set.swapRemove(name); + } + return modified; + } + + const removed = &self._remove_attributes.?; + if (removed.contains(name)) { + return false; + } + for (self._element_allow_attributes.values()) |*set| { + _ = set.swapRemove(name); + } + for (self._element_remove_attributes.values()) |*set| { + _ = set.swapRemove(name); + } + try removed.put(self._arena, name, {}); + return true; +} + +fn takeReplace(self: *Sanitizer, name: Name) bool { + const elements = &(self._replace_elements orelse return false); + return elements.swapRemove(name); +} + +fn removeAll(target: *NameSet, other: NameSet) void { + for (other.keys()) |key| { + _ = target.swapRemove(key); + } +} + +fn retainAll(target: *NameSet, other: NameSet) void { + var i = target.count(); + while (i > 0) { + i -= 1; + if (other.contains(target.keys()[i]) == false) { + target.swapRemoveAt(i); + } + } +} + +fn removeDataAttributes(target: *NameSet) void { + var i = target.count(); + while (i > 0) { + i -= 1; + if (target.keys()[i].isDataAttribute()) { + target.swapRemoveAt(i); + } + } +} + +fn sameSet(current: ?*NameSet, new: ?*NameSet) bool { + const a = current orelse return new == null; + const b = new orelse return false; + if (a.count() != b.count()) { + return false; + } + for (a.keys()) |key| { + if (b.contains(key) == false) { + return false; + } + } + return true; +} + +// The sets validate themselves, what we need to do here is apply cross-set +// validation, e.g. either allow or remove +fn isValid(self: *const Sanitizer) bool { + if (self._allow_elements != null and self._remove_elements != null) { + return false; + } + if (self._allow_attributes != null and self._remove_attributes != null) { + return false; + } + if (self._allow_processing_instructions != null and self._remove_processing_instructions != null) { + return false; + } + + if (self._replace_elements) |replaced| { + for (defaults.non_replaceable_elements) |element| { + if (replaced.contains(staticName(element))) { + return false; + } + } + if (intersects(self._allow_elements, replaced) or intersects(self._remove_elements, replaced)) { + return false; + } + } + + if (self._allow_attributes) |allowed| { + if (self._allow_elements) |elements| { + for (elements.keys()) |element| { + if (self._element_allow_attributes.getPtr(element)) |per_element| { + if (intersects(allowed, per_element.*)) { + return false; + } + if (self._data_attributes == true and hasDataAttribute(per_element.*)) { + return false; + } + } + if (self._element_remove_attributes.getPtr(element)) |per_element| { + if (isSubset(per_element.*, allowed) == false) { + return false; + } + } + } + } + if (self._data_attributes == true and hasDataAttribute(allowed)) { + return false; + } + return true; + } + + const removed = self._remove_attributes.?; + if (self._allow_elements) |elements| { + for (elements.keys()) |element| { + const allow_per_element = self._element_allow_attributes.getPtr(element); + const remove_per_element = self._element_remove_attributes.getPtr(element); + if (allow_per_element != null and remove_per_element != null) { + return false; + } + if (allow_per_element) |per_element| { + if (intersects(removed, per_element.*)) { + return false; + } + } + if (remove_per_element) |per_element| { + if (intersects(removed, per_element.*)) { + return false; + } + } + } + } + return self._data_attributes == null; +} + +fn intersects(a_: ?NameSet, b: NameSet) bool { + const a = a_ orelse return false; + for (a.keys()) |key| { + if (b.contains(key)) { + return true; + } + } + return false; +} + +fn isSubset(subset: NameSet, superset: NameSet) bool { + for (subset.keys()) |key| { + if (superset.contains(key) == false) { + return false; + } + } + return true; +} + +fn hasDataAttribute(set: NameSet) bool { + for (set.keys()) |key| { + if (key.isDataAttribute()) { + return true; + } + } + return false; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Sanitizer); + + pub const Meta = struct { + pub const name = "Sanitizer"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(Sanitizer.init, .{}); + pub const get = bridge.function(Sanitizer.get, .{}); + pub const allowElement = bridge.function(Sanitizer.allowElement, .{}); + pub const removeElement = bridge.function(Sanitizer.removeElement, .{}); + pub const replaceElementWithChildren = bridge.function(Sanitizer.replaceElementWithChildren, .{}); + pub const allowAttribute = bridge.function(Sanitizer.allowAttribute, .{}); + pub const removeAttribute = bridge.function(Sanitizer.removeAttribute, .{}); + pub const allowProcessingInstruction = bridge.function(Sanitizer.allowProcessingInstruction, .{}); + pub const removeProcessingInstruction = bridge.function(Sanitizer.removeProcessingInstruction, .{}); + pub const setComments = bridge.function(Sanitizer.setComments, .{}); + pub const setDataAttributes = bridge.function(Sanitizer.setDataAttributes, .{}); + pub const setJavascriptURLs = bridge.function(Sanitizer.setJavascriptURLs, .{}); + pub const removeUnsafe = bridge.function(Sanitizer.removeUnsafe, .{}); +}; + +const testing = @import("../../testing.zig"); +test "WebApi: Sanitizer" { + testing.expectLog(&.{.js}); + try testing.htmlRunner("sanitizer.html", .{}); +} diff --git a/src/browser/webapi/sanitizer_defaults.zig b/src/browser/webapi/sanitizer_defaults.zig new file mode 100644 index 000000000..652ff17ef --- /dev/null +++ b/src/browser/webapi/sanitizer_defaults.zig @@ -0,0 +1,429 @@ +// 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 . + +// default configuration for Sanitizer + +const std = @import("std"); + +const global_event_handlers = @import("global_event_handlers.zig"); + +const Namespace = @import("Sanitizer.zig").Namespace; + +pub const xhtml_ns = "http://www.w3.org/1999/xhtml"; +pub const svg_ns = "http://www.w3.org/2000/svg"; +pub const mathml_ns = "http://www.w3.org/1998/Math/MathML"; +pub const xlink_ns = "http://www.w3.org/1999/xlink"; +pub const xml_ns = "http://www.w3.org/XML/1998/namespace"; +pub const xmlns_ns = "http://www.w3.org/2000/xmlns/"; + +// A name as a table writes it, and as one arrives from JS: still a plain slice, +// because a `Sanitizer.Name` holds an `lp.String`, which cannot be built at +// comptime past 12 bytes -- and `animateTransform` and friends are longer. +// `Sanitizer.staticName` / `ownName` turn one of these into a `Name`. +pub const Name = struct { + name: []const u8, + namespace: Namespace, +}; + +pub const Element = struct { + name: []const u8, + namespace: Namespace, + attributes: []const Name = &.{}, +}; + +// https://html.spec.whatwg.org/#built-in-non-replaceable-elements-list +pub const non_replaceable_elements: []const Name = &.{ + .{ .name = "html", .namespace = .xhtml }, + .{ .name = "svg", .namespace = .svg }, + .{ .name = "math", .namespace = .mathml }, +}; + +// https://wicg.github.io/sanitizer-api/#built-in-safe-default-configuration +pub const default_elements: []const Element = &.{ + .{ .name = "math", .namespace = .mathml }, + .{ .name = "merror", .namespace = .mathml }, + .{ .name = "mfrac", .namespace = .mathml }, + .{ .name = "mi", .namespace = .mathml }, + .{ .name = "mmultiscripts", .namespace = .mathml }, + .{ .name = "mn", .namespace = .mathml }, + .{ .name = "mo", .namespace = .mathml, .attributes = &.{ .{ .name = "fence", .namespace = .none }, .{ .name = "form", .namespace = .none }, .{ .name = "largeop", .namespace = .none }, .{ .name = "lspace", .namespace = .none }, .{ .name = "maxsize", .namespace = .none }, .{ .name = "minsize", .namespace = .none }, .{ .name = "movablelimits", .namespace = .none }, .{ .name = "rspace", .namespace = .none }, .{ .name = "separator", .namespace = .none }, .{ .name = "stretchy", .namespace = .none }, .{ .name = "symmetric", .namespace = .none } } }, + .{ .name = "mover", .namespace = .mathml, .attributes = &.{.{ .name = "accent", .namespace = .none }} }, + .{ .name = "mpadded", .namespace = .mathml, .attributes = &.{ .{ .name = "depth", .namespace = .none }, .{ .name = "height", .namespace = .none }, .{ .name = "lspace", .namespace = .none }, .{ .name = "voffset", .namespace = .none }, .{ .name = "width", .namespace = .none } } }, + .{ .name = "mphantom", .namespace = .mathml }, + .{ .name = "mprescripts", .namespace = .mathml }, + .{ .name = "mroot", .namespace = .mathml }, + .{ .name = "mrow", .namespace = .mathml }, + .{ .name = "ms", .namespace = .mathml }, + .{ .name = "mspace", .namespace = .mathml, .attributes = &.{ .{ .name = "depth", .namespace = .none }, .{ .name = "height", .namespace = .none }, .{ .name = "width", .namespace = .none } } }, + .{ .name = "msqrt", .namespace = .mathml }, + .{ .name = "mstyle", .namespace = .mathml }, + .{ .name = "msub", .namespace = .mathml }, + .{ .name = "msubsup", .namespace = .mathml }, + .{ .name = "msup", .namespace = .mathml }, + .{ .name = "mtable", .namespace = .mathml }, + .{ .name = "mtd", .namespace = .mathml, .attributes = &.{ .{ .name = "columnspan", .namespace = .none }, .{ .name = "rowspan", .namespace = .none } } }, + .{ .name = "mtext", .namespace = .mathml }, + .{ .name = "mtr", .namespace = .mathml }, + .{ .name = "munder", .namespace = .mathml, .attributes = &.{.{ .name = "accentunder", .namespace = .none }} }, + .{ .name = "munderover", .namespace = .mathml, .attributes = &.{ .{ .name = "accent", .namespace = .none }, .{ .name = "accentunder", .namespace = .none } } }, + .{ .name = "semantics", .namespace = .mathml }, + .{ .name = "a", .namespace = .xhtml, .attributes = &.{ .{ .name = "href", .namespace = .none }, .{ .name = "hreflang", .namespace = .none }, .{ .name = "type", .namespace = .none } } }, + .{ .name = "abbr", .namespace = .xhtml }, + .{ .name = "address", .namespace = .xhtml }, + .{ .name = "article", .namespace = .xhtml }, + .{ .name = "aside", .namespace = .xhtml }, + .{ .name = "b", .namespace = .xhtml }, + .{ .name = "bdi", .namespace = .xhtml }, + .{ .name = "bdo", .namespace = .xhtml }, + .{ .name = "blockquote", .namespace = .xhtml, .attributes = &.{.{ .name = "cite", .namespace = .none }} }, + .{ .name = "body", .namespace = .xhtml }, + .{ .name = "br", .namespace = .xhtml }, + .{ .name = "caption", .namespace = .xhtml }, + .{ .name = "cite", .namespace = .xhtml }, + .{ .name = "code", .namespace = .xhtml }, + .{ .name = "col", .namespace = .xhtml, .attributes = &.{.{ .name = "span", .namespace = .none }} }, + .{ .name = "colgroup", .namespace = .xhtml, .attributes = &.{.{ .name = "span", .namespace = .none }} }, + .{ .name = "data", .namespace = .xhtml, .attributes = &.{.{ .name = "value", .namespace = .none }} }, + .{ .name = "dd", .namespace = .xhtml }, + .{ .name = "del", .namespace = .xhtml, .attributes = &.{ .{ .name = "cite", .namespace = .none }, .{ .name = "datetime", .namespace = .none } } }, + .{ .name = "dfn", .namespace = .xhtml }, + .{ .name = "div", .namespace = .xhtml }, + .{ .name = "dl", .namespace = .xhtml }, + .{ .name = "dt", .namespace = .xhtml }, + .{ .name = "em", .namespace = .xhtml }, + .{ .name = "figcaption", .namespace = .xhtml }, + .{ .name = "figure", .namespace = .xhtml }, + .{ .name = "footer", .namespace = .xhtml }, + .{ .name = "h1", .namespace = .xhtml }, + .{ .name = "h2", .namespace = .xhtml }, + .{ .name = "h3", .namespace = .xhtml }, + .{ .name = "h4", .namespace = .xhtml }, + .{ .name = "h5", .namespace = .xhtml }, + .{ .name = "h6", .namespace = .xhtml }, + .{ .name = "head", .namespace = .xhtml }, + .{ .name = "header", .namespace = .xhtml }, + .{ .name = "hgroup", .namespace = .xhtml }, + .{ .name = "hr", .namespace = .xhtml }, + .{ .name = "html", .namespace = .xhtml }, + .{ .name = "i", .namespace = .xhtml }, + .{ .name = "ins", .namespace = .xhtml, .attributes = &.{ .{ .name = "cite", .namespace = .none }, .{ .name = "datetime", .namespace = .none } } }, + .{ .name = "kbd", .namespace = .xhtml }, + .{ .name = "li", .namespace = .xhtml, .attributes = &.{.{ .name = "value", .namespace = .none }} }, + .{ .name = "main", .namespace = .xhtml }, + .{ .name = "mark", .namespace = .xhtml }, + .{ .name = "menu", .namespace = .xhtml }, + .{ .name = "nav", .namespace = .xhtml }, + .{ .name = "ol", .namespace = .xhtml, .attributes = &.{ .{ .name = "reversed", .namespace = .none }, .{ .name = "start", .namespace = .none }, .{ .name = "type", .namespace = .none } } }, + .{ .name = "p", .namespace = .xhtml }, + .{ .name = "pre", .namespace = .xhtml }, + .{ .name = "q", .namespace = .xhtml }, + .{ .name = "rp", .namespace = .xhtml }, + .{ .name = "rt", .namespace = .xhtml }, + .{ .name = "ruby", .namespace = .xhtml }, + .{ .name = "s", .namespace = .xhtml }, + .{ .name = "samp", .namespace = .xhtml }, + .{ .name = "search", .namespace = .xhtml }, + .{ .name = "section", .namespace = .xhtml }, + .{ .name = "small", .namespace = .xhtml }, + .{ .name = "span", .namespace = .xhtml }, + .{ .name = "strong", .namespace = .xhtml }, + .{ .name = "sub", .namespace = .xhtml }, + .{ .name = "sup", .namespace = .xhtml }, + .{ .name = "table", .namespace = .xhtml }, + .{ .name = "tbody", .namespace = .xhtml }, + .{ .name = "td", .namespace = .xhtml, .attributes = &.{ .{ .name = "colspan", .namespace = .none }, .{ .name = "headers", .namespace = .none }, .{ .name = "rowspan", .namespace = .none } } }, + .{ .name = "tfoot", .namespace = .xhtml }, + .{ .name = "th", .namespace = .xhtml, .attributes = &.{ .{ .name = "abbr", .namespace = .none }, .{ .name = "colspan", .namespace = .none }, .{ .name = "headers", .namespace = .none }, .{ .name = "rowspan", .namespace = .none }, .{ .name = "scope", .namespace = .none } } }, + .{ .name = "thead", .namespace = .xhtml }, + .{ .name = "time", .namespace = .xhtml, .attributes = &.{.{ .name = "datetime", .namespace = .none }} }, + .{ .name = "title", .namespace = .xhtml }, + .{ .name = "tr", .namespace = .xhtml }, + .{ .name = "u", .namespace = .xhtml }, + .{ .name = "ul", .namespace = .xhtml }, + .{ .name = "var", .namespace = .xhtml }, + .{ .name = "wbr", .namespace = .xhtml }, + .{ .name = "a", .namespace = .svg, .attributes = &.{ .{ .name = "href", .namespace = .none }, .{ .name = "hreflang", .namespace = .none }, .{ .name = "type", .namespace = .none } } }, + .{ .name = "circle", .namespace = .svg, .attributes = &.{ .{ .name = "cx", .namespace = .none }, .{ .name = "cy", .namespace = .none }, .{ .name = "pathLength", .namespace = .none }, .{ .name = "r", .namespace = .none } } }, + .{ .name = "defs", .namespace = .svg }, + .{ .name = "desc", .namespace = .svg }, + .{ .name = "ellipse", .namespace = .svg, .attributes = &.{ .{ .name = "cx", .namespace = .none }, .{ .name = "cy", .namespace = .none }, .{ .name = "pathLength", .namespace = .none }, .{ .name = "rx", .namespace = .none }, .{ .name = "ry", .namespace = .none } } }, + .{ .name = "foreignObject", .namespace = .svg, .attributes = &.{ .{ .name = "height", .namespace = .none }, .{ .name = "width", .namespace = .none }, .{ .name = "x", .namespace = .none }, .{ .name = "y", .namespace = .none } } }, + .{ .name = "g", .namespace = .svg }, + .{ .name = "line", .namespace = .svg, .attributes = &.{ .{ .name = "pathLength", .namespace = .none }, .{ .name = "x1", .namespace = .none }, .{ .name = "x2", .namespace = .none }, .{ .name = "y1", .namespace = .none }, .{ .name = "y2", .namespace = .none } } }, + .{ .name = "marker", .namespace = .svg, .attributes = &.{ .{ .name = "markerHeight", .namespace = .none }, .{ .name = "markerUnits", .namespace = .none }, .{ .name = "markerWidth", .namespace = .none }, .{ .name = "orient", .namespace = .none }, .{ .name = "preserveAspectRatio", .namespace = .none }, .{ .name = "refX", .namespace = .none }, .{ .name = "refY", .namespace = .none }, .{ .name = "viewBox", .namespace = .none } } }, + .{ .name = "metadata", .namespace = .svg }, + .{ .name = "path", .namespace = .svg, .attributes = &.{ .{ .name = "d", .namespace = .none }, .{ .name = "pathLength", .namespace = .none } } }, + .{ .name = "polygon", .namespace = .svg, .attributes = &.{ .{ .name = "pathLength", .namespace = .none }, .{ .name = "points", .namespace = .none } } }, + .{ .name = "polyline", .namespace = .svg, .attributes = &.{ .{ .name = "pathLength", .namespace = .none }, .{ .name = "points", .namespace = .none } } }, + .{ .name = "rect", .namespace = .svg, .attributes = &.{ .{ .name = "height", .namespace = .none }, .{ .name = "pathLength", .namespace = .none }, .{ .name = "rx", .namespace = .none }, .{ .name = "ry", .namespace = .none }, .{ .name = "width", .namespace = .none }, .{ .name = "x", .namespace = .none }, .{ .name = "y", .namespace = .none } } }, + .{ .name = "svg", .namespace = .svg, .attributes = &.{ .{ .name = "height", .namespace = .none }, .{ .name = "preserveAspectRatio", .namespace = .none }, .{ .name = "viewBox", .namespace = .none }, .{ .name = "width", .namespace = .none }, .{ .name = "x", .namespace = .none }, .{ .name = "y", .namespace = .none } } }, + .{ .name = "text", .namespace = .svg, .attributes = &.{ .{ .name = "dx", .namespace = .none }, .{ .name = "dy", .namespace = .none }, .{ .name = "lengthAdjust", .namespace = .none }, .{ .name = "rotate", .namespace = .none }, .{ .name = "textLength", .namespace = .none }, .{ .name = "x", .namespace = .none }, .{ .name = "y", .namespace = .none } } }, + .{ .name = "textPath", .namespace = .svg, .attributes = &.{ .{ .name = "lengthAdjust", .namespace = .none }, .{ .name = "method", .namespace = .none }, .{ .name = "path", .namespace = .none }, .{ .name = "side", .namespace = .none }, .{ .name = "spacing", .namespace = .none }, .{ .name = "startOffset", .namespace = .none }, .{ .name = "textLength", .namespace = .none } } }, + .{ .name = "title", .namespace = .svg }, + .{ .name = "tspan", .namespace = .svg, .attributes = &.{ .{ .name = "dx", .namespace = .none }, .{ .name = "dy", .namespace = .none }, .{ .name = "lengthAdjust", .namespace = .none }, .{ .name = "rotate", .namespace = .none }, .{ .name = "textLength", .namespace = .none }, .{ .name = "x", .namespace = .none }, .{ .name = "y", .namespace = .none } } }, +}; + +pub const default_attributes: []const Name = &.{ + .{ .name = "alignment-baseline", .namespace = .none }, + .{ .name = "baseline-shift", .namespace = .none }, + .{ .name = "clip-path", .namespace = .none }, + .{ .name = "clip-rule", .namespace = .none }, + .{ .name = "color", .namespace = .none }, + .{ .name = "color-interpolation", .namespace = .none }, + .{ .name = "cursor", .namespace = .none }, + .{ .name = "dir", .namespace = .none }, + .{ .name = "direction", .namespace = .none }, + .{ .name = "display", .namespace = .none }, + .{ .name = "displaystyle", .namespace = .none }, + .{ .name = "dominant-baseline", .namespace = .none }, + .{ .name = "fill", .namespace = .none }, + .{ .name = "fill-opacity", .namespace = .none }, + .{ .name = "fill-rule", .namespace = .none }, + .{ .name = "font-family", .namespace = .none }, + .{ .name = "font-size", .namespace = .none }, + .{ .name = "font-size-adjust", .namespace = .none }, + .{ .name = "font-stretch", .namespace = .none }, + .{ .name = "font-style", .namespace = .none }, + .{ .name = "font-variant", .namespace = .none }, + .{ .name = "font-weight", .namespace = .none }, + .{ .name = "lang", .namespace = .none }, + .{ .name = "letter-spacing", .namespace = .none }, + .{ .name = "marker-end", .namespace = .none }, + .{ .name = "marker-mid", .namespace = .none }, + .{ .name = "marker-start", .namespace = .none }, + .{ .name = "mathbackground", .namespace = .none }, + .{ .name = "mathcolor", .namespace = .none }, + .{ .name = "mathsize", .namespace = .none }, + .{ .name = "opacity", .namespace = .none }, + .{ .name = "paint-order", .namespace = .none }, + .{ .name = "pointer-events", .namespace = .none }, + .{ .name = "scriptlevel", .namespace = .none }, + .{ .name = "shape-rendering", .namespace = .none }, + .{ .name = "stop-color", .namespace = .none }, + .{ .name = "stop-opacity", .namespace = .none }, + .{ .name = "stroke", .namespace = .none }, + .{ .name = "stroke-dasharray", .namespace = .none }, + .{ .name = "stroke-dashoffset", .namespace = .none }, + .{ .name = "stroke-linecap", .namespace = .none }, + .{ .name = "stroke-linejoin", .namespace = .none }, + .{ .name = "stroke-miterlimit", .namespace = .none }, + .{ .name = "stroke-opacity", .namespace = .none }, + .{ .name = "stroke-width", .namespace = .none }, + .{ .name = "text-anchor", .namespace = .none }, + .{ .name = "text-decoration", .namespace = .none }, + .{ .name = "text-overflow", .namespace = .none }, + .{ .name = "text-rendering", .namespace = .none }, + .{ .name = "title", .namespace = .none }, + .{ .name = "transform", .namespace = .none }, + .{ .name = "transform-origin", .namespace = .none }, + .{ .name = "unicode-bidi", .namespace = .none }, + .{ .name = "vector-effect", .namespace = .none }, + .{ .name = "visibility", .namespace = .none }, + .{ .name = "white-space", .namespace = .none }, + .{ .name = "word-spacing", .namespace = .none }, + .{ .name = "writing-mode", .namespace = .none }, +}; + +// https://html.spec.whatwg.org/#built-in-safe-baseline-configuration +// Every HTML element the spec marks "Sanitization: Unsafe" (base, embed, +// iframe, object, script), plus the obsolete frame and SVG's script and use. +pub const baseline_remove_elements: []const Name = &.{ + .{ .name = "base", .namespace = .xhtml }, + .{ .name = "embed", .namespace = .xhtml }, + .{ .name = "frame", .namespace = .xhtml }, + .{ .name = "iframe", .namespace = .xhtml }, + .{ .name = "object", .namespace = .xhtml }, + .{ .name = "script", .namespace = .xhtml }, + .{ .name = "script", .namespace = .svg }, + .{ .name = "use", .namespace = .svg }, +}; + +// The baseline's own removeAttributes list is empty; `remove unsafe` instead +// walks every "event handler content attribute". We fold lightpanda's own +// handler set into HTML's list so that a handler added to `Handler` -- which is +// what an `on*` content attribute is compiled against -- can never be left +// behind by removeUnsafe(). +pub const event_handler_attributes: []const []const u8 = blk: { + @setEvalBranchQuota(200_000); + const handlers = std.meta.fieldNames(global_event_handlers.Handler); + var all: [html_event_handler_attributes.len + handlers.len][]const u8 = undefined; + for (html_event_handler_attributes, 0..) |name, i| { + all[i] = name; + } + for (handlers, 0..) |name, i| { + all[html_event_handler_attributes.len + i] = name; + } + std.mem.sort([]const u8, &all, {}, struct { + fn lessThan(_: void, a: []const u8, b: []const u8) bool { + return std.mem.lessThan(u8, a, b); + } + }.lessThan); + + var unique: [all.len][]const u8 = undefined; + var len: usize = 0; + for (all) |name| { + if (len == 0 or std.mem.eql(u8, unique[len - 1], name) == false) { + unique[len] = name; + len += 1; + } + } + const final = unique[0..len].*; + break :blk &final; +}; + +const html_event_handler_attributes: []const []const u8 = &.{ + "onabort", + "onactivate", + "onafterprint", + "onanimationcancel", + "onanimationend", + "onanimationiteration", + "onanimationstart", + "onautofill", + "onauxclick", + "onbeforecopy", + "onbeforecut", + "onbeforefilter", + "onbeforeinput", + "onbeforepaste", + "onbeforeprint", + "onbeforetoggle", + "onbeforeunload", + "onbegin", + "onblur", + "oncancel", + "oncanplay", + "oncanplaythrough", + "onchange", + "onclick", + "onclose", + "oncommand", + "oncontentvisibilityautostatechange", + "oncontextlost", + "oncontextmenu", + "oncontextrestored", + "oncopy", + "oncuechange", + "oncut", + "ondblclick", + "ondrag", + "ondragend", + "ondragenter", + "ondragleave", + "ondragover", + "ondragstart", + "ondrop", + "ondurationchange", + "onemptied", + "onend", + "onended", + "onerror", + "onfocus", + "onfocusin", + "onfocusout", + "onformdata", + "ongotpointercapture", + "onhashchange", + "oninput", + "oninstallresult", + "oninvalid", + "onkeydown", + "onkeypress", + "onkeyup", + "onlanguagechange", + "onload", + "onloadeddata", + "onloadedmetadata", + "onloadstart", + "onlocation", + "onlostpointercapture", + "onmessage", + "onmessageerror", + "onmousedown", + "onmouseenter", + "onmouseleave", + "onmousemove", + "onmouseout", + "onmouseover", + "onmouseup", + "onmousewheel", + "onmove", + "onoffline", + "ononline", + "onorientationchange", + "onpagehide", + "onpageshow", + "onpaste", + "onpause", + "onplay", + "onplaying", + "onpointercancel", + "onpointerdown", + "onpointerenter", + "onpointerleave", + "onpointermove", + "onpointerout", + "onpointerover", + "onpointerrawupdate", + "onpointerup", + "onpopstate", + "onprogress", + "onpromptaction", + "onpromptdismiss", + "onratechange", + "onrepeat", + "onreset", + "onresize", + "onscroll", + "onscrollend", + "onscrollsnapchange", + "onscrollsnapchanging", + "onsearch", + "onsecuritypolicyviolation", + "onseeked", + "onseeking", + "onselect", + "onselectionchange", + "onselectstart", + "onshow", + "onslotchange", + "onstalled", + "onstream", + "onstorage", + "onsubmit", + "onsuspend", + "ontimeupdate", + "ontimezonechange", + "ontoggle", + "ontouchcancel", + "ontouchend", + "ontouchmove", + "ontouchstart", + "ontransitionend", + "onunload", + "onvalidationstatuschange", + "onvolumechange", + "onwaiting", + "onwebkitanimationend", + "onwebkitanimationiteration", + "onwebkitanimationstart", + "onwebkitfullscreenchange", + "onwebkitfullscreenerror", + "onwebkittransitionend", + "onwheel", +}; From 80640f720462c0300a04280bf09ad6ad36356dca Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 23 Sep 2026 12:54:19 +0800 Subject: [PATCH 41/93] http: add support for x-frame-options Brings /x-frame-options/ from 43/157 to 157/157. --- src/browser/Frame.zig | 13 ++ src/browser/frame/framing.zig | 167 +++++++++++++++++++++ src/browser/webapi/element/html/IFrame.zig | 11 +- 3 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 src/browser/frame/framing.zig diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 38e078594..d537c2df6 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -71,6 +71,7 @@ const GlobalScope = @import("global_scope.zig").GlobalScope; const GlobalEventHandlersLookup = @import("webapi/global_event_handlers.zig").Lookup; +const framing = @import("frame/framing.zig"); pub const parse = @import("frame/parse.zig"); pub const preload = @import("frame/preload.zig"); pub const resource_load = @import("frame/resource_load.zig"); @@ -1444,6 +1445,15 @@ fn frameHeaderDoneCallback(transfer: *HttpClient.Transfer) !HttpClient.Transfer. self.url = try self.arena.dupeZ(u8, response_url); self.origin = try URL.getOrigin(self.arena, self.url); } + + if (self.parent != null and framing.allowed(self, transfer) == false) { + log.warn(.frame, "x-frame-options blocked", .{ .url = self.url }); + // give this an opaque origin so that any request to the error page + // is treated as being cross-origin + self.origin = null; + try self.js.setOrigin(null); + return error.XFrameOptionsDenied; + } try self.js.setOrigin(self.origin); // After any redirect, drop the original method/body/header so a later @@ -2041,6 +2051,9 @@ pub fn iframeAddedCallback(self: *Frame, iframe: *IFrame) !void { try Frame.init(new_frame, frame_id, self.page, .{ .parent = self }); errdefer new_frame.deinit(); + // until the navigate commits, the iframe is about:blank and inherits the parent's origin + try new_frame.js.setOrigin(self.origin); + const delays_load = iframe.isLazyLoading() == false; new_frame._delays_parent_load = delays_load; if (delays_load) { diff --git a/src/browser/frame/framing.zig b/src/browser/frame/framing.zig new file mode 100644 index 000000000..f16f3ad82 --- /dev/null +++ b/src/browser/frame/framing.zig @@ -0,0 +1,167 @@ +// 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 Frame = @import("../Frame.zig"); +const HttpClient = @import("../../network/HttpClient.zig"); + +// https://html.spec.whatwg.org/multipage/document-lifecycle.html#the-x-frame-options-header +pub fn allowed(frame: *const Frame, transfer: *HttpClient.Transfer) bool { + var options: XFrameOptions = .{}; + var it = transfer.responseHeaderIterator(); + while (it.next()) |hdr| { + if (std.ascii.eqlIgnoreCase(hdr.name, "content-security-policy")) { + if (hasFrameAncestors(hdr.value)) { + // has priority over any x-frame-options + return true; + } + } else if (std.ascii.eqlIgnoreCase(hdr.name, "x-frame-options")) { + options.add(hdr.value); + } + } + + switch (options.policy()) { + .allow => return true, + .deny => return false, + .same_origin => { + const origin = frame.origin orelse return false; + var ancestor = frame.parent; + while (ancestor) |a| : (ancestor = a.parent) { + // with a same-origin value, every ancestor has to be + // the same origin + const ancestor_origin = a.origin orelse return false; + if (std.mem.eql(u8, origin, ancestor_origin) == false) { + return false; + } + } + return true; + }, + } +} + +fn hasFrameAncestors(csp: []const u8) bool { + const name = "frame-ancestors"; + var pos: usize = 0; + while (std.ascii.indexOfIgnoreCasePos(csp, pos, name)) |start| { + pos = start + name.len; + + // A directive name starts a policy (',') or a directive (';'), so + // `script-src frame-ancestors` (a host source) doesn't count. + const before = std.mem.trimEnd(u8, csp[0..start], HTTP_WHITESPACE); + if (before.len != 0) { + const last = before[before.len - 1]; + if (last != ';' and last != ',') { + continue; + } + } + if (pos == csp.len or std.mem.indexOfScalar(u8, HTTP_WHITESPACE ++ ";,", csp[pos]) != null) { + return true; + } + } + return false; +} + +const HTTP_WHITESPACE = " \t\r\n"; + +const XFrameOptions = struct { + first: ?[]const u8 = null, + conflict: bool = false, + has_keyword: bool = false, + + const Policy = enum { allow, deny, same_origin }; + + fn add(self: *XFrameOptions, value: []const u8) void { + var it = std.mem.splitScalar(u8, value, ','); + while (it.next()) |token| { + const v = std.mem.trim(u8, token, HTTP_WHITESPACE); + if (keyword(v) != null) { + self.has_keyword = true; + } + if (self.first) |first| { + // we care about the first value and if any subsequent values are different + if (std.ascii.eqlIgnoreCase(first, v) == false) { + self.conflict = true; + } + } else { + self.first = v; + } + } + } + + fn policy(self: *const XFrameOptions) Policy { + const first = self.first orelse return .allow; + if (self.conflict) { + // conflict is a fail, unless they all had meaningless values + return if (self.has_keyword) .deny else .allow; + } + return switch (keyword(first) orelse return .allow) { + .deny => .deny, + .sameorigin => .same_origin, + .allowall => .allow, + }; + } + + fn keyword(value: []const u8) ?enum { deny, sameorigin, allowall } { + if (std.ascii.eqlIgnoreCase(value, "deny")) { + return .deny; + } + if (std.ascii.eqlIgnoreCase(value, "sameorigin")) { + return .sameorigin; + } + if (std.ascii.eqlIgnoreCase(value, "allowall")) { + return .allowall; + } + return null; + } +}; + +const testing = @import("../../testing.zig"); +test "framing: XFrameOptions" { + const expectPolicy = struct { + fn expectPolicy(expected: XFrameOptions.Policy, values: []const []const u8) !void { + var xfo: XFrameOptions = .{}; + for (values) |v| xfo.add(v); + try testing.expectEqual(expected, xfo.policy()); + } + }.expectPolicy; + + try expectPolicy(.allow, &.{}); + try expectPolicy(.allow, &.{""}); + try expectPolicy(.allow, &.{"INVALID"}); + try expectPolicy(.allow, &.{"ALLOWALL"}); + try expectPolicy(.allow, &.{"\x0bDENY"}); + try expectPolicy(.allow, &.{ "INVALID", "" }); + try expectPolicy(.deny, &.{" denY "}); + try expectPolicy(.deny, &.{ "DENY", "deny" }); + try expectPolicy(.deny, &.{",SAMEORIGIN,,DENY,"}); + try expectPolicy(.deny, &.{ "SAMEORIGIN", "DENY" }); + try expectPolicy(.deny, &.{"ALLOWALL,"}); + try expectPolicy(.deny, &.{ "INVALID", "allowAll" }); + try expectPolicy(.same_origin, &.{ "SAMEORIGIN", "sameOrigin" }); + + try testing.expect(hasFrameAncestors("default-src 'self'; frame-ancestors 'self'")); + try testing.expect(hasFrameAncestors("default-src 'self', FRAME-ANCESTORS")); + try testing.expect(hasFrameAncestors("default-src 'self'") == false); + try testing.expect(hasFrameAncestors("frame-ancestors-x 'self'") == false); + try testing.expect(hasFrameAncestors("frame-ancestors")); + try testing.expect(hasFrameAncestors("frame-ancestors;")); + try testing.expect(hasFrameAncestors("script-src frame-ancestors") == false); + try testing.expect(hasFrameAncestors("x-frame-ancestors 'self'") == false); + try testing.expect(hasFrameAncestors("script-src frame-ancestors; frame-ancestors 'none'")); +} diff --git a/src/browser/webapi/element/html/IFrame.zig b/src/browser/webapi/element/html/IFrame.zig index b9526195a..3cd5a4ef0 100644 --- a/src/browser/webapi/element/html/IFrame.zig +++ b/src/browser/webapi/element/html/IFrame.zig @@ -126,7 +126,16 @@ pub const JsApi = struct { pub const srcdoc = bridge.accessor(IFrame.getSrcdoc, IFrame.setSrcdoc, .{ .ce_reactions = true }); pub const name = reflect.string("name"); pub const contentWindow = bridge.accessor(IFrame.getContentWindow, null, .{}); - pub const contentDocument = bridge.accessor(IFrame.getContentDocument, null, .{}); + pub const contentDocument = bridge.accessor(struct { + fn wrap(self: *const IFrame, frame: *Frame) ?*Document { + // specific JS implementation which is origin-aware. + const window = self._window orelse return null; + if (window._frame.js.origin != frame.js.origin) { + return null; + } + return window._document; + } + }.wrap, null, .{}); pub const sandbox = bridge.accessor(IFrame.getSandbox, null, .{ .null_as_undefined = true }); }; From 3262e78aecfeba0bbf548b4636f7e89f3f386668 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 23 Sep 2026 14:52:33 +0800 Subject: [PATCH 42/93] webapi: give every indexer implementation a query handler If an explicit query handler isn't provided, we bridge creates a generic one that wraps the getter. --- src/browser/js/Snapshot.zig | 5 +- src/browser/js/bridge.zig | 58 +++++++++++++++---- .../tests/collections/indexed_properties.html | 54 +++++++++++++++++ src/browser/tests/element/html/select.html | 38 ++++++++++++ .../webapi/collections/HTMLCollection.zig | 5 ++ .../collections/HTMLOptionsCollection.zig | 37 +++++++++++- 6 files changed, 182 insertions(+), 15 deletions(-) create mode 100644 src/browser/tests/collections/indexed_properties.html diff --git a/src/browser/js/Snapshot.zig b/src/browser/js/Snapshot.zig index c7a33ae95..2e344e7b8 100644 --- a/src/browser/js/Snapshot.zig +++ b/src/browser/js/Snapshot.zig @@ -275,10 +275,11 @@ fn createSnapshotContext( .data = null, .flags = v8.kOnlyInterceptStrings | v8.kNonMasking, }); + const window_index = @import("../webapi/Window.zig").JsApi.index; v8.v8__ObjectTemplate__SetIndexedHandler(global_template, &.{ - .getter = @import("../webapi/Window.zig").JsApi.index.getter, + .getter = window_index.getter, .setter = null, - .query = null, + .query = window_index.query, .deleter = null, .enumerator = null, .definer = null, diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 1098779c7..099e57d02 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -398,20 +398,25 @@ pub const Indexed = struct { }.wrap; } - if (@typeInfo(@TypeOf(query)) != .null) { - indexed.query = struct { - fn wrap(idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 { - const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; - var caller: Caller = undefined; - if (!caller.init(v8_isolate)) { - return js.Intercepted.no; - } - defer caller.deinit(); + const query_func = if (@typeInfo(@TypeOf(query)) != .null) + query + else + // Generate a Query handler by wrapping getter. With no setter, this + // gets the ReadOnly attribute + GetterQuery(getter, if (@typeInfo(@TypeOf(setter)) == .null) v8.ReadOnly else v8.None).query; - return caller.getIndexQuery(T, query, idx, handle.?); + indexed.query = struct { + fn wrap(idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 { + const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; + var caller: Caller = undefined; + if (!caller.init(v8_isolate)) { + return js.Intercepted.no; } - }.wrap; - } + defer caller.deinit(); + + return caller.getIndexQuery(T, query_func, idx, handle.?); + } + }.wrap; if (@typeInfo(@TypeOf(definer)) != .null) { indexed.definer = struct { @@ -433,6 +438,35 @@ pub const Indexed = struct { } }; +// Default index query if one isn't provided. Uses the getter to determine the result +fn GetterQuery(comptime getter: anytype, comptime attrs: u32) type { + const params = @typeInfo(@TypeOf(getter)).@"fn".params; + const Self = params[0].type.?; + const Index = params[1].type.?; + return struct { + const query = if (params.len == 3) withGlobal else plain; + + fn plain(self: Self, idx: Index) !u32 { + return attributes(getter(self, idx)); + } + + fn withGlobal(self: Self, idx: Index, global: params[2].type.?) !u32 { + return attributes(getter(self, idx, global)); + } + + fn attributes(ret: anytype) !u32 { + const value = switch (@typeInfo(@TypeOf(ret))) { + .error_union => try ret, + else => ret, + }; + if (@typeInfo(@TypeOf(value)) == .optional and value == null) { + return error.NotHandled; + } + return attrs; + } + }; +} + pub const NamedIndexed = struct { getter: *const fn (c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32, setter: ?*const fn (c_name: ?*const v8.Name, c_value: ?*const v8.Value, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null, diff --git a/src/browser/tests/collections/indexed_properties.html b/src/browser/tests/collections/indexed_properties.html new file mode 100644 index 000000000..3bc7a5d04 --- /dev/null +++ b/src/browser/tests/collections/indexed_properties.html @@ -0,0 +1,54 @@ + + +
+
+ + + + + + + diff --git a/src/browser/tests/element/html/select.html b/src/browser/tests/element/html/select.html index 603d9a259..811a48350 100644 --- a/src/browser/tests/element/html/select.html +++ b/src/browser/tests/element/html/select.html @@ -275,6 +275,44 @@ } + + diff --git a/src/browser/webapi/element/html/Option.zig b/src/browser/webapi/element/html/Option.zig index 609744fd6..42a5afce0 100644 --- a/src/browser/webapi/element/html/Option.zig +++ b/src/browser/webapi/element/html/Option.zig @@ -79,7 +79,7 @@ pub fn getSelected(self: *const Option) bool { return self._selected; } -fn setSelected(self: *Option, selected: bool, frame: *Frame) !void { +pub fn setSelected(self: *Option, selected: bool, frame: *Frame) !void { self.setSelectedness(selected); frame.domChanged(); } @@ -96,7 +96,7 @@ fn setSelectedness(self: *Option, selected: bool) void { } /// The toggles the option, any other selects it + const selected = if (select.getMultiple()) option.getSelected() == false else true; + if (option.getSelected() == selected) { + break :blk; + } + try option.setSelected(selected, frame); + try lp.actions.dispatchInputAndChangeEvents(select_element, frame); + } else if (element.isDisabled() == false) { + Frame.user_input.updateHoverTarget(frame, element, .{ .with_pointer = true }); + try Frame.user_input.triggerClick(frame, element, .{}); + } + return browsing_context.answerAfterNavigation(cmd, ctx, frame); +} + +// POST /session/{id}/element/{id}/clear +fn elementClear(cmd: *BiDi.Command, p: ElementId) !void { + const frame = (try currentFrame(cmd)) orelse return; + const element = (try requireElement(cmd, p.id, frame)) orelse return; + + if (isEditable(element) == false) { + return cmd.sendError("invalid element state", "element is not editable"); + } + if ((try requireInteractable(cmd, element, frame)) == false) { + return; + } + + if (element.is(Html.Input)) |input_element| { + try clearControl(input_element, frame); + } else if (element.is(Html.TextArea)) |textarea| { + try clearControl(textarea, frame); + } else { + try element.asNode().setTextContent("", frame); + } + return cmd.sendDone(); +} + +fn clearControl(ctl: anytype, frame: *Frame) !void { + if (ctl.getValue().len == 0) { + return; + } + + const element = ctl.asElement(); + // give it focus + try element.focus(frame); + // clear it + try ctl.setValue("", frame); + try lp.actions.dispatchInputAndChangeEvents(element, frame); + // remove focus + try element.blur(frame); +} + +fn isEditable(element: *Node.Element) bool { + if (element.is(Html.Input)) |input_element| { + switch (input_element._input_type) { + .hidden, .checkbox, .radio, .submit, .reset, .button, .image => return false, + else => {}, + } + } else if (element.is(Html.TextArea) == null) { + return element.isEditingHost(); + } + return element.isDisabled() == false and element.getAttributeSafe(comptime .wrap("readonly")) == null; +} + +// POST /session/{id}/element/{id}/value. +pub const SendKeys = struct { + id: []const u8, + text: []const u8, +}; +fn elementSendKeys(cmd: *BiDi.Command, p: SendKeys) !void { + const ctx = (try currentContext(cmd)) orelse return; + const frame = (try currentFrame(cmd)) orelse return; + const element = (try requireElement(cmd, p.id, frame)) orelse return; + + if (element.is(Html.Input)) |input_element| { + if (input_element._input_type == .file) { + return cmd.sendError("unsupported operation", "file upload is not supported"); + } + } + element.scrollIntoView(null, frame); + + const document = frame.window._document; + if (document.getActiveElement() != element) { + try element.focus(frame); + if (document.getActiveElement() != element) { + return cmd.sendError("element not interactable", "element can't be focused"); + } + if (element.is(Html.Input)) |input_element| { + try caretToEnd(input_element, frame); + } else if (element.is(Html.TextArea)) |textarea| { + try caretToEnd(textarea, frame); + } + } + + input.typeText(frame, p.text) catch |err| switch (err) { + error.InvalidUtf8 => return cmd.sendError("invalid argument", "text is not valid UTF-8"), + else => return err, + }; + return browsing_context.answerAfterNavigation(cmd, ctx, frame); +} + +fn caretToEnd(ctl: anytype, frame: *Frame) !void { + if (ctl.selectionAvailable() == false) { + return; + } + const len: u32 = @intCast(ctl.getValue().len); + try ctl.setSelectionRange(len, len, null, frame); +} + +fn requireInteractable(cmd: *BiDi.Command, element: *Node.Element, frame: *Frame) !bool { + element.scrollIntoView(null, frame); + if (element.checkVisibility(.{}, frame)) { + return true; + } + try cmd.sendError("element not interactable", "element is not displayed"); + return false; +} + // POST /session/{id}/execute/sync, POST /session/{id}/execute/async fn executeScript(cmd: *BiDi.Command, p: execute.Script, mode: execute.Mode) !void { _ = (try currentContext(cmd)) orelse return; @@ -638,27 +793,26 @@ pub const ReferenceError = error{ StaleElement, }; -// A reference's element, or why it doesn't resolve. Shared with execute.zig, -// which resolves the references a script is called with. -pub fn elementFromReference(registry: *const NodeRegistry, id: []const u8) ReferenceError!*Node.Element { - // ids are dropped on navigation, so a stale one is unknown by then +pub fn elementFromReference(registry: *const NodeRegistry, id: []const u8, frame: *const Frame) ReferenceError!*Node.Element { + // ids are dropped on navigation const node = remote_value.nodeFromSharedId(registry, .{ .string = id }) catch return error.NoSuchElement; const element = node.is(Node.Element) orelse return error.NoSuchElement; - if (node.isConnected() == false) { + if (node.isConnected() == false or node.getDocument(frame) != frame.window._document) { + // disconnected or one from a document different than the current one return error.StaleElement; } return element; } // Answers the command and returns null when the reference doesn't resolve. -fn requireElement(cmd: *BiDi.Command, id: []const u8) !?*Node.Element { - return elementFromReference(&cmd.bidi.node_registry, id) catch |err| switch (err) { +fn requireElement(cmd: *BiDi.Command, id: []const u8, frame: *const Frame) !?*Node.Element { + return elementFromReference(&cmd.bidi.node_registry, id, frame) catch |err| switch (err) { error.NoSuchElement => { try cmd.sendError("no such element", "unknown element reference"); return null; }, error.StaleElement => { - try cmd.sendError("stale element reference", "element is no longer attached to the document"); + try cmd.sendError("stale element reference", "element is not in the current document"); return null; }, }; @@ -772,6 +926,16 @@ test "bidi.http_command: parse" { try testing.expectEqual(50, (try parse(arena, .POST, "/timeouts", "{\"script\":50}")).set_timeouts.script.ms); } + { + // Selenium sends the legacy `value` array next to `text` + const command = try parse(arena, .POST, "/element/7/value", "{\"text\":\"ab\",\"value\":[\"a\",\"b\"]}"); + try testing.expectEqual("7", command.element_send_keys.id); + try testing.expectEqual("ab", command.element_send_keys.text); + } + try testing.expectEqual("7", (try parse(arena, .POST, "/element/7/click", "{}")).element_click.id); + try testing.expectEqual("7", (try parse(arena, .POST, "/element/7/clear", "{}")).element_clear.id); + try testing.expectError(error.InvalidArgument, parse(arena, .POST, "/element/7/value", "{}")); + try testing.expect(try parse(arena, .GET, "/timeouts", "") == .get_timeouts); try testing.expectError(error.InvalidArgument, parse(arena, .POST, "/execute/sync", "{}")); diff --git a/src/server/bidi/input.zig b/src/server/bidi/input.zig index f4565cd3e..da63d1332 100644 --- a/src/server/bidi/input.zig +++ b/src/server/bidi/input.zig @@ -609,6 +609,56 @@ fn dispatch(bidi: *BiDi, frame: *Frame, source: *Source, action: *const Action) } } +// WebDriver's Element Send Keys, into whatever has focus. Every code point is a +// press and release, except a modifier, which stays down until it's typed +// again, U+E000 releases everything, or the text ends. It's a keyboard of its +// own: the actions' held keys don't apply. +pub fn typeText(frame: *Frame, text: []const u8) !void { + const view = std.unicode.Utf8View.init(text) catch return error.InvalidUtf8; + + var modifiers: Modifiers = .{}; + // there are 8 modifier code points, and each is held at most once + var held_buf: [8]u21 = undefined; + var held: std.ArrayList(u21) = .initBuffer(&held_buf); + + var it = view.iterator(); + while (it.nextCodepoint()) |c| { + // like chromedriver, a newline is the Enter key + const cp: u21 = if (c == '\n' or c == '\r') 0xE006 else c; + if (cp == 0xE000) { + try releaseHeld(frame, &held, &modifiers); + continue; + } + + const info = keyInfo(cp, modifiers.shift); + if (info.modifier == null) { + try dispatchKey(frame, "keydown", &info, &modifiers); + try dispatchKey(frame, "keyup", &info, &modifiers); + continue; + } + + if (std.mem.indexOfScalar(u21, held.items, cp)) |i| { + _ = held.orderedRemove(i); + setModifier(&modifiers, info.modifier, false); + try dispatchKey(frame, "keyup", &info, &modifiers); + } else { + held.appendAssumeCapacity(cp); + setModifier(&modifiers, info.modifier, true); + try dispatchKey(frame, "keydown", &info, &modifiers); + } + } + try releaseHeld(frame, &held, &modifiers); +} + +// in reverse press order +fn releaseHeld(frame: *Frame, held: *std.ArrayList(u21), modifiers: *Modifiers) !void { + while (held.pop()) |cp| { + const info = keyInfo(cp, modifiers.shift); + setModifier(modifiers, info.modifier, false); + try dispatchKey(frame, "keyup", &info, modifiers); + } +} + const Point = struct { x: f64, y: f64 }; fn resolveOrigin(bidi: *BiDi, frame: *Frame, source: *const Source, origin: Origin, x: f64, y: f64) !Point { From 12968613504fbed0f8d5720b9eb72a991c586feb Mon Sep 17 00:00:00 2001 From: Scott Taylor Date: Tue, 22 Sep 2026 12:35:19 -0400 Subject: [PATCH 45/93] cdp: notify target closure when disposing browser contexts --- src/server/cdp/domains/target.zig | 119 +++++++++++++++++++++++++++--- 1 file changed, 109 insertions(+), 10 deletions(-) diff --git a/src/server/cdp/domains/target.zig b/src/server/cdp/domains/target.zig index d5162d0eb..96f25a610 100644 --- a/src/server/cdp/domains/target.zig +++ b/src/server/cdp/domains/target.zig @@ -138,9 +138,18 @@ fn disposeBrowserContext(cmd: *CDP.Command) !void { browserContextId: []const u8, })) orelse return error.InvalidParams; - if (cmd.cdp.disposeBrowserContext(params.browserContextId) == false) { + const bc = cmd.browser_context orelse { + return cmd.sendError(-32602, "No browser context with the given id found", .{}); + }; + if (!std.mem.eql(u8, bc.id, params.browserContextId)) { return cmd.sendError(-32602, "No browser context with the given id found", .{}); } + + // Disposing a context closes its target too. Clients use these events to + // settle page.close() and remove the page from their target/session maps; + // replying to disposeBrowserContext alone leaves them waiting forever. + try detachTarget(cmd, bc); + _ = cmd.cdp.disposeBrowserContext(params.browserContextId); try cmd.sendResult(null, .{}); } @@ -317,7 +326,21 @@ fn closeTarget(cmd: *CDP.Command) !void { lp.assert(bc.session.hasPage(), "CDP.target.closeTarget null frame", .{}); try cmd.sendResult(.{ .success = true }, .{}); + try detachTarget(cmd, bc); + if (bc.page_handle) |handle| { + handle.close(); + bc.page_handle = null; + } + for (bc.isolated_worlds.items) |world| { + world.deinit(); + } + bc.isolated_worlds.clearRetainingCapacity(); + bc.target_id = null; +} + +fn detachTarget(cmd: *CDP.Command, bc: *CDP.BrowserContext) !void { + const target_id = bc.target_id orelse return; for (bc.attached_sessions.items) |session| { bc.fetchDisableForSession(session.id); try cmd.sendEvent("Inspector.detached", .{ @@ -349,15 +372,7 @@ fn closeTarget(cmd: *CDP.Command) !void { bc.session_id = null; } - if (bc.page_handle) |handle| { - handle.close(); - bc.page_handle = null; - } - for (bc.isolated_worlds.items) |world| { - world.deinit(); - } - bc.isolated_worlds.clearRetainingCapacity(); - bc.target_id = null; + try cmd.sendEvent("Target.targetDestroyed", .{ .targetId = target_id }, .{}); } fn getTargetInfo(cmd: *CDP.Command) !void { @@ -645,6 +660,90 @@ test "cdp.target: disposeBrowserContext" { } } +test "cdp.target: disposeBrowserContext detaches target sessions" { + var ctx = try testing.context(); + defer ctx.deinit(); + + try ctx.processMessage(.{ .id = 1, .method = "Target.setAutoAttach", .params = .{ .autoAttach = true, .waitForDebuggerOnStart = false } }); + try ctx.processMessage(.{ .id = 2, .method = "Target.createTarget", .params = .{ .url = "about:blank" } }); + const bc = &ctx.cdp().browser_context.?; + const target_id = bc.target_id.?; + const context_id = try testing.arena_allocator.dupe(u8, bc.id); + const primary_id = try testing.arena_allocator.dupe(u8, bc.session_id.?); + try ctx.processMessage(.{ .id = 3, .method = "Target.attachToBrowserTarget" }); + try ctx.processMessage(.{ + .id = 4, + .method = "Target.attachToTarget", + .sessionId = "BSID-1", + .params = .{ .targetId = target_id }, + }); + const auxiliary_id = try testing.arena_allocator.dupe(u8, bc.attached_sessions.items[0].id); + + try ctx.processMessage(.{ + .id = 5, + .method = "Target.disposeBrowserContext", + .params = .{ .browserContextId = context_id }, + }); + // Playwright waits for detachedFromTarget to resolve Page.closedPromise, + // even if Target.disposeBrowserContext itself has already replied. + try ctx.expectSentEvent("Target.detachedFromTarget", .{ + .targetId = target_id, + .sessionId = auxiliary_id, + .reason = "Render process gone.", + }, .{ .session_id = "BSID-1" }); + try ctx.expectSentEvent("Target.detachedFromTarget", .{ + .targetId = target_id, + .sessionId = primary_id, + .reason = "Render process gone.", + }, .{}); + try ctx.expectSentEvent("Target.targetDestroyed", .{ .targetId = target_id }, .{}); + try ctx.expectSentResult(null, .{ .id = 5 }); + try testing.expectEqual(null, ctx.cdp().browser_context); + + // The connection remains usable; stale target/session IDs are not reused. + try ctx.processMessage(.{ .id = 6, .method = "Target.createTarget", .params = .{ .url = "about:blank" } }); + try testing.expect(!std.mem.eql(u8, &target_id, &ctx.cdp().browser_context.?.target_id.?)); + try testing.expect(!std.mem.eql(u8, primary_id, ctx.cdp().browser_context.?.session_id.?)); +} + +test "cdp.target: disposeBrowserContext destroys an unattached target" { + var ctx = try testing.context(); + defer ctx.deinit(); + + try ctx.processMessage(.{ .id = 1, .method = "Target.createTarget", .params = .{ .url = "about:blank" } }); + const bc = &ctx.cdp().browser_context.?; + const target_id = bc.target_id.?; + const context_id = try testing.arena_allocator.dupe(u8, bc.id); + + // An invalid context ID must leave the live target untouched. + try ctx.processMessage(.{ .id = 2, .method = "Target.disposeBrowserContext", .params = .{ .browserContextId = "BID-UNKNOWN" } }); + try ctx.expectSentError(-32602, "No browser context with the given id found", .{ .id = 2 }); + try ctx.expectSentCount(3); + try testing.expectEqual(&target_id, bc.target_id.?); + + try ctx.processMessage(.{ .id = 3, .method = "Target.disposeBrowserContext", .params = .{ .browserContextId = context_id } }); + try ctx.expectSentEvent("Target.targetDestroyed", .{ .targetId = target_id }, .{ .index = 3 }); + try ctx.expectSentResult(null, .{ .id = 3, .index = 4 }); + try ctx.expectSentCount(5); +} + +test "cdp.target: disposeBrowserContext after closeTarget does not repeat events" { + var ctx = try testing.context(); + defer ctx.deinit(); + + try ctx.processMessage(.{ .id = 1, .method = "Target.createTarget", .params = .{ .url = "about:blank" } }); + const bc = &ctx.cdp().browser_context.?; + const target_id = bc.target_id.?; + const context_id = try testing.arena_allocator.dupe(u8, bc.id); + + try ctx.processMessage(.{ .id = 2, .method = "Target.closeTarget", .params = .{ .targetId = target_id } }); + try ctx.expectSentEvent("Target.targetDestroyed", .{ .targetId = target_id }, .{ .index = 3 }); + try ctx.expectSentCount(4); + try ctx.processMessage(.{ .id = 3, .method = "Target.disposeBrowserContext", .params = .{ .browserContextId = context_id } }); + try ctx.expectSentResult(null, .{ .id = 3, .index = 4 }); + try ctx.expectSentCount(5); +} + // Issue #2472: CDP target IDs (`FID-{d:0>10}`) must stay unique for the // lifetime of a CDP connection. Before the fix, `Session.frame_id_gen` // reset to 0 on `tearDownActivePage` AND fresh sessions also started From 1bc6bcc3d1cc9e9502fe51845962860c6c494c94 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 23 Sep 2026 17:08:03 +0800 Subject: [PATCH 46/93] dedupe close path --- src/server/cdp/CDP.zig | 54 ++++++++++++++++++++++++---- src/server/cdp/domains/page.zig | 59 +++++++++++++++---------------- src/server/cdp/domains/target.zig | 58 +++--------------------------- src/server/cdp/testing.zig | 4 +-- 4 files changed, 82 insertions(+), 93 deletions(-) diff --git a/src/server/cdp/CDP.zig b/src/server/cdp/CDP.zig index aa44fbe16..cf1dd2e84 100644 --- a/src/server/cdp/CDP.zig +++ b/src/server/cdp/CDP.zig @@ -364,16 +364,12 @@ pub fn createBrowserContext(self: *CDP) ![]const u8 { return id; } -pub fn disposeBrowserContext(self: *CDP, browser_context_id: []const u8) bool { - const bc = &(self.browser_context orelse return false); - if (std.mem.eql(u8, bc.id, browser_context_id) == false) { - return false; - } +pub fn disposeBrowserContext(self: *CDP) void { + const bc = &(self.browser_context orelse return); bc.deinit(); self.browser.closeSession(); self.browser_context = null; _ = self.browser_context_arena.reset(.{ .retain_with_limit = 1024 * 16 }); - return true; } const SendEventOpts = struct { @@ -840,6 +836,52 @@ pub const BrowserContext = struct { } } + // Shared by Target.closeTarget, Page.close and Target.disposeBrowserContext. + // Drivers settle page.close() on detachedFromTarget and drop the target + // on targetDestroyed, so both are sent even when nothing was attached. + pub fn closeTarget(self: *BrowserContext) !void { + const target_id = self.target_id orelse return; + const cdp = self.cdp; + for (self.attached_sessions.items) |session| { + self.fetchDisableForSession(session.id); + try cdp.sendEvent("Inspector.detached", .{ + .reason = "Render process gone.", + }, .{ .session_id = session.id }); + try cdp.sendEvent("Target.detachedFromTarget", .{ + .targetId = target_id, + .sessionId = session.id, + .reason = "Render process gone.", + }, .{ .session_id = session.parent_id }); + } + self.attached_sessions.clearRetainingCapacity(); + + // could be null, created but never attached + if (self.session_id) |session_id| { + self.fetchDisableForSession(session_id); + try cdp.sendEvent("Inspector.detached", .{ + .reason = "Render process gone.", + }, .{ .session_id = session_id }); + try cdp.sendEvent("Target.detachedFromTarget", .{ + .targetId = target_id, + .sessionId = session_id, + .reason = "Render process gone.", + }, .{}); + self.session_id = null; + } + + try cdp.sendEvent("Target.targetDestroyed", .{ .targetId = target_id }, .{}); + + if (self.page_handle) |handle| { + handle.close(); + self.page_handle = null; + } + for (self.isolated_worlds.items) |world| { + world.deinit(); + } + self.isolated_worlds.clearRetainingCapacity(); + self.target_id = null; + } + pub fn fetchDisableForSession(self: *BrowserContext, session_id: []const u8) void { const active_session_id = self.fetch_session_id orelse return; if (std.mem.eql(u8, active_session_id, session_id)) { diff --git a/src/server/cdp/domains/page.zig b/src/server/cdp/domains/page.zig index ea0de57d8..71fa88d29 100644 --- a/src/server/cdp/domains/page.zig +++ b/src/server/cdp/domains/page.zig @@ -238,41 +238,15 @@ fn removeScriptToEvaluateOnNewDocument(cmd: *CDP.Command) !void { fn close(cmd: *CDP.Command) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - const target_id = bc.target_id orelse return error.TargetNotLoaded; + if (bc.target_id == null) { + return error.TargetNotLoaded; + } // can't be null if we have a target_id lp.assert(bc.session.hasPage(), "CDP.frame.close null frame", .{}); - try cmd.sendResult(.{}, .{}); - - // Following code is similar to target.closeTarget - // - // could be null, created but never attached - if (bc.session_id) |session_id| { - // Inspector.detached event - try cmd.sendEvent("Inspector.detached", .{ - .reason = "Render process gone.", - }, .{ .session_id = session_id }); - - // detachedFromTarget event - try cmd.sendEvent("Target.detachedFromTarget", .{ - .targetId = target_id, - .sessionId = session_id, - .reason = "Render process gone.", - }, .{}); - - bc.session_id = null; - } - - if (bc.page_handle) |handle| { - handle.close(); - } - bc.page_handle = null; - for (bc.isolated_worlds.items) |world| { - world.deinit(); - } - bc.isolated_worlds.clearRetainingCapacity(); - bc.target_id = null; + try cmd.sendResult(null, .{}); + try bc.closeTarget(); } fn createIsolatedWorld(cmd: *CDP.Command) !void { @@ -1234,6 +1208,29 @@ fn getLayoutMetrics(cmd: *CDP.Command) !void { } const testing = @import("../testing.zig"); + +test "cdp.page: close detaches the target like Target.closeTarget" { + var ctx = try testing.context(); + defer ctx.deinit(); + + try ctx.processMessage(.{ .id = 1, .method = "Target.setAutoAttach", .params = .{ .autoAttach = true, .waitForDebuggerOnStart = false } }); + try ctx.processMessage(.{ .id = 2, .method = "Target.createTarget", .params = .{ .url = "about:blank" } }); + const bc = &ctx.cdp().browser_context.?; + const target_id = bc.target_id.?; + const session_id = try testing.arena_allocator.dupe(u8, bc.session_id.?); + + try ctx.processMessage(.{ .id = 3, .method = "Page.close", .sessionId = session_id }); + try ctx.expectSentResult(null, .{ .id = 3, .session_id = session_id }); + try ctx.expectSentEvent("Inspector.detached", .{ .reason = "Render process gone." }, .{ .session_id = session_id }); + try ctx.expectSentEvent("Target.detachedFromTarget", .{ + .targetId = target_id, + .sessionId = session_id, + .reason = "Render process gone.", + }, .{}); + try ctx.expectSentEvent("Target.targetDestroyed", .{ .targetId = target_id }, .{}); + try testing.expectEqual(null, bc.target_id); + try testing.expectEqual(null, bc.session_id); +} test "cdp.frame: setup no-ops" { var ctx = try testing.context(); defer ctx.deinit(); diff --git a/src/server/cdp/domains/target.zig b/src/server/cdp/domains/target.zig index 96f25a610..0457dc19f 100644 --- a/src/server/cdp/domains/target.zig +++ b/src/server/cdp/domains/target.zig @@ -141,15 +141,13 @@ fn disposeBrowserContext(cmd: *CDP.Command) !void { const bc = cmd.browser_context orelse { return cmd.sendError(-32602, "No browser context with the given id found", .{}); }; - if (!std.mem.eql(u8, bc.id, params.browserContextId)) { + if (std.mem.eql(u8, bc.id, params.browserContextId) == false) { return cmd.sendError(-32602, "No browser context with the given id found", .{}); } - // Disposing a context closes its target too. Clients use these events to - // settle page.close() and remove the page from their target/session maps; - // replying to disposeBrowserContext alone leaves them waiting forever. - try detachTarget(cmd, bc); - _ = cmd.cdp.disposeBrowserContext(params.browserContextId); + // Disposing a context closes its target; drivers wait on those events. + try bc.closeTarget(); + cmd.cdp.disposeBrowserContext(); try cmd.sendResult(null, .{}); } @@ -326,53 +324,7 @@ fn closeTarget(cmd: *CDP.Command) !void { lp.assert(bc.session.hasPage(), "CDP.target.closeTarget null frame", .{}); try cmd.sendResult(.{ .success = true }, .{}); - try detachTarget(cmd, bc); - - if (bc.page_handle) |handle| { - handle.close(); - bc.page_handle = null; - } - for (bc.isolated_worlds.items) |world| { - world.deinit(); - } - bc.isolated_worlds.clearRetainingCapacity(); - bc.target_id = null; -} - -fn detachTarget(cmd: *CDP.Command, bc: *CDP.BrowserContext) !void { - const target_id = bc.target_id orelse return; - for (bc.attached_sessions.items) |session| { - bc.fetchDisableForSession(session.id); - try cmd.sendEvent("Inspector.detached", .{ - .reason = "Render process gone.", - }, .{ .session_id = session.id }); - try cmd.sendEvent("Target.detachedFromTarget", .{ - .targetId = target_id, - .sessionId = session.id, - .reason = "Render process gone.", - }, .{ .session_id = session.parent_id }); - } - bc.attached_sessions.clearRetainingCapacity(); - - // could be null, created but never attached - if (bc.session_id) |session_id| { - bc.fetchDisableForSession(session_id); - // Inspector.detached event - try cmd.sendEvent("Inspector.detached", .{ - .reason = "Render process gone.", - }, .{ .session_id = session_id }); - - // detachedFromTarget event - try cmd.sendEvent("Target.detachedFromTarget", .{ - .targetId = target_id, - .sessionId = session_id, - .reason = "Render process gone.", - }, .{}); - - bc.session_id = null; - } - - try cmd.sendEvent("Target.targetDestroyed", .{ .targetId = target_id }, .{}); + try bc.closeTarget(); } fn getTargetInfo(cmd: *CDP.Command) !void { diff --git a/src/server/cdp/testing.zig b/src/server/cdp/testing.zig index 57bfc54d0..fff827169 100644 --- a/src/server/cdp/testing.zig +++ b/src/server/cdp/testing.zig @@ -81,9 +81,7 @@ pub const TestContext = struct { }; pub fn loadBrowserContext(self: *TestContext, opts: BrowserContextOpts) !*CDP.BrowserContext { var c = self.cdp(); - if (c.browser_context) |bc| { - _ = c.disposeBrowserContext(bc.id); - } + c.disposeBrowserContext(); _ = try c.createBrowserContext(); var bc = &c.browser_context.?; From 79376d00faa2f53e6d1a117fbd1ed0ae11f9e0a3 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 23 Sep 2026 17:34:10 +0800 Subject: [PATCH 47/93] xml: validate xml declaration And now, because we do this, we no longer have to have the parser skip anything that looks like a ProcessingInstruction. --- src/browser/frame/parse.zig | 8 -------- src/browser/tests/domparser.html | 13 ++++++++++++ src/rust/html5ever/lib.rs | 35 ++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/src/browser/frame/parse.zig b/src/browser/frame/parse.zig index c4251e3a8..946fb0978 100644 --- a/src/browser/frame/parse.zig +++ b/src/browser/frame/parse.zig @@ -123,13 +123,5 @@ pub fn xmlDocument(frame: *Frame, xml: []const u8) !?*Document.XMLDocument { if (parser.err != null or parser.xml_error or doc_node.firstChild() == null) { return null; } - - // If first node is a `ProcessingInstruction` (e.g. the - // declaration), skip it. - const first_child = doc_node.firstChild().?; - if (first_child.getNodeType() == 7) { - _ = try doc_node.removeChild(first_child, frame); - } - return doc; } diff --git a/src/browser/tests/domparser.html b/src/browser/tests/domparser.html index 759f55f30..2a67c0062 100644 --- a/src/browser/tests/domparser.html +++ b/src/browser/tests/domparser.html @@ -521,6 +521,11 @@ '�', '', // invalid processing instruction target '', + '', // only 1.x versions are accepted + '', + '', + '', + '', // version is required ]) { testing.expectEqual(bad + ' -> error', bad + (isError(p.parseFromString(bad, 'text/xml')) ? ' -> error' : ' -> ok')); } @@ -529,6 +534,8 @@ for (const good of [ '', '\n\n', + "", + '', '\uFEFF', ']]>', '', @@ -548,6 +555,12 @@ testing.expectEqual('svg', svg.doctype.name); testing.expectEqual('-//W3C//DTD SVG 1.1//EN', svg.doctype.publicId); testing.expectEqual('svg', svg.documentElement.localName); + + // the XML declaration creates no node, a leading processing instruction does + const decl = p.parseFromString('', 'text/xml'); + testing.expectEqual(2, decl.childNodes.length); + testing.expectEqual('xml-stylesheet', decl.firstChild.target); + testing.expectEqual('xml-stylesheet', p.parseFromString('', 'text/xml').firstChild.target); } diff --git a/src/rust/html5ever/lib.rs b/src/rust/html5ever/lib.rs index ef6209495..a0ee8df1c 100644 --- a/src/rust/html5ever/lib.rs +++ b/src/rust/html5ever/lib.rs @@ -874,6 +874,17 @@ impl<'arena> xml5ever::tokenizer::TokenSink for UnclosedTagSink<'arena> { .parse_error(std::borrow::Cow::Borrowed("Unclosed element at EOF")); } } + // The XML declaration isn't a processing instruction, so no node + // is created for it. xml5ever doesn't validate it. + Token::ProcessingInstruction(pi) if &*pi.target == "xml" => { + if !is_valid_xml_declaration(&pi.data) { + use xml5ever::tree_builder::TreeSink; + self.tb + .sink + .parse_error(std::borrow::Cow::Borrowed("Invalid XML declaration")); + } + return xml5ever::tokenizer::ProcessResult::Continue; + } _ => {} } self.tb.process_token(token) @@ -884,6 +895,30 @@ impl<'arena> xml5ever::tokenizer::TokenSink for UnclosedTagSink<'arena> { } } +// The declaration must start with `version="1.x"`: browsers accept any 1.x +// (XML 1.0 5th edition's VersionNum is `1.[0-9]+`) and reject everything else. +fn is_valid_xml_declaration(data: &str) -> bool { + fn trim(s: &str) -> &str { + s.trim_start_matches([' ', '\t', '\r', '\n']) + } + let Some(rest) = trim(data).strip_prefix("version").map(trim) else { + return false; + }; + let Some(rest) = rest.strip_prefix('=').map(trim) else { + return false; + }; + let Some(quote) = rest.chars().next().filter(|c| *c == '"' || *c == '\'') else { + return false; + }; + let Some((version, _)) = rest[1..].split_once(quote) else { + return false; + }; + match version.strip_prefix("1.") { + Some(minor) => !minor.is_empty() && minor.bytes().all(|b| b.is_ascii_digit()), + None => false, + } +} + // xml5ever::driver::XmlParser, minus the tree-builder-typed tokenizer so the // UnclosedTagSink can sit in between. struct XmlDocumentParser<'arena> { From 5e16a9bc54ad43e89fdd048d50d1e3e802bb6804 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Wed, 23 Sep 2026 12:12:57 +0200 Subject: [PATCH 48/93] ci: add missing instruction for cgroup install --- .github/workflows/e2e-test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 74438fc65..f0c3f1810 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -274,6 +274,8 @@ jobs: # $ sudo chmod o+w /sys/fs/cgroup/cgroup.procs # $ sudo mkdir -p /sys/fs/cgroup/actions-runner # $ sudo chown -R actions-runner:actions-runner /sys/fs/cgroup/actions-runner + # $ echo +memory | sudo tee /sys/fs/cgroup/actions-runner/cgroup.subtree_control + # /sys/fs/cgroup is reset on reboot: run these again after a host restart. CG_ROOT: /sys/fs/cgroup CG: actions-runner/lpd_${{ github.run_id }}_${{ github.run_attempt }} From d5ba648b7eb78cf0fb5769c98555483c2b6361ef Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 23 Sep 2026 18:20:38 +0800 Subject: [PATCH 49/93] webapi: add support for the inert property Improves the /inert/ WPT category --- src/browser/Frame.zig | 25 +++++++ src/browser/tests/element/inert.html | 102 +++++++++++++++++++++++++++ src/browser/webapi/Element.zig | 9 ++- src/browser/webapi/Node.zig | 21 ++++++ src/browser/webapi/Range.zig | 28 ++++++-- src/browser/webapi/Selection.zig | 2 +- src/browser/webapi/element/Html.zig | 13 ++++ 7 files changed, 191 insertions(+), 9 deletions(-) create mode 100644 src/browser/tests/element/inert.html diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 38e078594..f968d0041 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -146,6 +146,8 @@ _queued_events_1: std.ArrayList(QueuedEvent) = .empty, _queued_events_2: std.ArrayList(QueuedEvent) = .empty, _queued_events: *std.ArrayList(QueuedEvent) = undefined, +_focus_fixup_pending: bool = false, + _style_manager: StyleManager, _script_manager: ScriptManager, @@ -2356,6 +2358,25 @@ pub fn queueElementEvent(self: *Frame, element: *Element.Html, kind: QueuedEvent } } +// An element that becomes inert can't stay focused. Fire its blur on the next tick +fn scheduleFocusFixup(self: *Frame) !void { + if (self._focus_fixup_pending or self.document._active_element == null) { + return; + } + try self.js.scheduler.add(self, struct { + fn run(ctx: *anyopaque) !?u32 { + const f: *Frame = @ptrCast(@alignCast(ctx)); + f._focus_fixup_pending = false; + const active = f.document._active_element orelse return null; + if (active.asNode().isInert(f)) { + try active.blur(f); + } + return null; + } + }.run, 5, .{ .name = "frame.focusFixup" }); + self._focus_fixup_pending = true; +} + const HashChangeCallback = struct { frame: *Frame, old_url: []const u8, @@ -3135,6 +3156,10 @@ pub fn attributeChange(self: *Frame, element: *Element, name: String, value: Str } else if (name.eql(comptime .wrap("style"))) { element._flags.has_inline_style = true; self.styleAttributeChanged(element, value.str()); + } else if (name.eql(comptime .wrap("inert"))) { + self.scheduleFocusFixup() catch |err| { + log.err(.frame, "scheduleFocusFixup", .{ .err = err, .type = self._type, .url = self.url }); + }; } } diff --git a/src/browser/tests/element/inert.html b/src/browser/tests/element/inert.html new file mode 100644 index 000000000..408b887cd --- /dev/null +++ b/src/browser/tests/element/inert.html @@ -0,0 +1,102 @@ + + + + +
+ + hidden +
+
+

shown

+ hidden + math +
+ + + + + + + + + diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index fded393c2..4283c67ba 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -1233,12 +1233,17 @@ pub fn focusTabIndex(self: *Element) ?i32 { return null; } -// A focusable area that can take focus right now: connected and being rendered. +// A focusable area that can take focus right now: connected, not inert and +// being rendered. pub fn isFocusable(self: *Element, frame: *Frame) bool { if (self.focusTabIndex() == null) { return false; } - if (self.asNode().isConnected() == false) { + const node = self.asNode(); + if (node.isConnected() == false) { + return false; + } + if (node.isInert(frame)) { return false; } return self.isVisible(frame); diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index d75d7bfbc..364178b6d 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -1699,6 +1699,27 @@ pub fn assignedSlot(self: *Node, frame: *const Frame) ?*Element.Html.Slot { return frame.page._assigned_slots.get(self); } +// An inert element applies to all its chidren, so walk up to see if we have +// an inert parent +pub fn isInert(self: *Node, frame: *const Frame) bool { + var current: ?*Node = self; + while (current) |node| { + if (node.is(Element)) |el| { + if (el._namespace == .html and el.hasAttributeSafe(comptime .wrap("inert"))) { + return true; + } + } + if (node.assignedSlot(frame)) |slot| { + current = slot.asNode(); + } else if (node.is(ShadowRoot)) |shadow| { + current = shadow._host.asNode(); + } else { + current = node._parent; + } + } + return false; +} + pub const JsApi = struct { pub const bridge = js.Bridge(Node); diff --git a/src/browser/webapi/Range.zig b/src/browser/webapi/Range.zig index 349837467..22b1b316f 100644 --- a/src/browser/webapi/Range.zig +++ b/src/browser/webapi/Range.zig @@ -719,11 +719,19 @@ fn createContextualFragment(self: *const Range, html: []const u8, frame: *Frame) pub fn toString(self: *const Range, frame: *Frame) ![]const u8 { // Simplified implementation: just extract text content var buf = std.Io.Writer.Allocating.init(frame.local_arena); - try self.writeTextContent(&buf.writer); + try self.writeTextContent(&buf.writer, null); return buf.written(); } -fn writeTextContent(self: *const Range, writer: *std.Io.Writer) !void { +// Selection.toString is almost like Range.toString, except it does not include +// the text that an inert element hides. +pub fn toSelectionString(self: *const Range, frame: *Frame) ![]const u8 { + var buf = std.Io.Writer.Allocating.init(frame.local_arena); + try self.writeTextContent(&buf.writer, frame); + return buf.written(); +} + +fn writeTextContent(self: *const Range, writer: *std.Io.Writer, skip_inert: ?*const Frame) !void { if (self._proto.getCollapsed()) return; const start_node = self._proto._start_container; @@ -734,7 +742,7 @@ fn writeTextContent(self: *const Range, writer: *std.Io.Writer) !void { // Same text node — just substring if (start_node == end_node) { if (start_node.is(Node.CData)) |cdata| { - if (!isCommentOrPI(cdata)) { + if (includeText(cdata, skip_inert)) { const data = cdata.getData().str(); const s = byteOffset(data, start_offset); const e = byteOffset(data, end_offset); @@ -748,7 +756,7 @@ fn writeTextContent(self: *const Range, writer: *std.Io.Writer) !void { // Partial start: if start container is a text node, write from offset to end if (start_node.is(Node.CData)) |cdata| { - if (!isCommentOrPI(cdata)) { + if (includeText(cdata, skip_inert)) { const data = cdata.getData().str(); const s = byteOffset(data, start_offset); try writer.writeAll(data[s..]); @@ -775,7 +783,7 @@ fn writeTextContent(self: *const Range, writer: *std.Io.Writer) !void { if (n == we) break; } if (n.is(Node.CData)) |cdata| { - if (!isCommentOrPI(cdata)) { + if (includeText(cdata, skip_inert)) { try writer.writeAll(cdata.getData().str()); } } @@ -786,7 +794,7 @@ fn writeTextContent(self: *const Range, writer: *std.Io.Writer) !void { // Partial end: if end container is a different text node, write from start to offset if (start_node != end_node) { if (end_node.is(Node.CData)) |cdata| { - if (!isCommentOrPI(cdata)) { + if (includeText(cdata, skip_inert)) { const data = cdata.getData().str(); const e = byteOffset(data, end_offset); try writer.writeAll(data[0..e]); @@ -795,6 +803,14 @@ fn writeTextContent(self: *const Range, writer: *std.Io.Writer) !void { } } +fn includeText(cdata: *Node.CData, skip_inert: ?*const Frame) bool { + if (isCommentOrPI(cdata)) { + return false; + } + const frame = skip_inert orelse return true; + return cdata.asNode().isInert(frame) == false; +} + fn isCommentOrPI(cdata: *Node.CData) bool { return cdata.is(Node.CData.Comment) != null or cdata.is(Node.CData.ProcessingInstruction) != null; } diff --git a/src/browser/webapi/Selection.zig b/src/browser/webapi/Selection.zig index 41cb692bf..7124f1655 100644 --- a/src/browser/webapi/Selection.zig +++ b/src/browser/webapi/Selection.zig @@ -714,7 +714,7 @@ pub fn collapse(self: *Selection, _node: ?*Node, _offset: ?u32, frame: *Frame) ! pub fn toString(self: *const Selection, frame: *Frame) ![]const u8 { const range = self._range orelse return ""; - return try range.toString(frame); + return try range.toSelectionString(frame); } fn setRange(self: *Selection, new_range: ?*Range, frame: *Frame) void { diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index 51637d7e2..a671386f0 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -452,6 +452,18 @@ pub fn setHidden(self: *HtmlElement, hidden: bool, frame: *Frame) !void { } } +pub fn getInert(self: *HtmlElement) bool { + return self.asElement().hasAttributeSafe(comptime .wrap("inert")); +} + +pub fn setInert(self: *HtmlElement, inert: bool, frame: *Frame) !void { + if (inert) { + try self.asElement().setAttributeSafe(comptime .wrap("inert"), .wrap(""), frame); + } else { + try self.asElement().removeAttribute(comptime .wrap("inert"), frame); + } +} + // The translate IDL attribute reflects the element's translation mode: // translate="yes"/"" enables it, "no" disables it, anything else (or no // attribute) inherits from the parent, defaulting to enabled. @@ -1862,6 +1874,7 @@ pub const JsApi = struct { pub const dir = reflect.enumerated("dir", &.{ "ltr", "rtl", "auto" }, .{}); pub const draggable = bridge.accessor(HtmlElement.getDraggable, HtmlElement.setDraggable, .{ .ce_reactions = true }); pub const hidden = bridge.accessor(HtmlElement.getHidden, HtmlElement.setHidden, .{ .ce_reactions = true }); + pub const inert = bridge.accessor(HtmlElement.getInert, HtmlElement.setInert, .{ .ce_reactions = true }); pub const translate = bridge.accessor(HtmlElement.getTranslate, HtmlElement.setTranslate, .{ .ce_reactions = true }); pub const accessKeyLabel = bridge.accessor(HtmlElement.getAccessKeyLabel, null, .{}); pub const popover = bridge.accessor(HtmlElement.getPopover, HtmlElement.setPopover, .{ .ce_reactions = true }); From d157157721942f4dfef3b148111b8be5c47bfeab Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 23 Sep 2026 20:02:47 +0800 Subject: [PATCH 50/93] perf: reduce delay in running v8 background jobs In Runner, when we're `in_cdp` we reduce the HttpClient poll time to 10ms when a v8 tells us it has a background task. This is to help ensure we don't linger too long in HttpClient's poll when v8 has work. But, 10ms can still be long, especially in a WPT test that's running thousands of WASM compilation in serial. So rather than having a flat 10ms wait, Runner will now wait 0-10ms, based on (a) whether the last pump had any tasks and (b) the number of _ticks since there was task. This is potentially a temporary solution to having v8 wake the HttpClient when a task is ready. --- src/browser/Browser.zig | 5 +++-- src/browser/Runner.zig | 14 ++++++++++++-- src/browser/js/Env.zig | 8 ++++++-- src/browser/js/Local.zig | 2 +- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/browser/Browser.zig b/src/browser/Browser.zig index d69b604d6..5f1257efe 100644 --- a/src/browser/Browser.zig +++ b/src/browser/Browser.zig @@ -262,14 +262,15 @@ pub fn runMicrotasks(self: *Browser) void { self.env.runMicrotasks(); } -pub fn runMacrotasks(self: *Browser) !void { +pub fn runMacrotasks(self: *Browser) !bool { const env = &self.env; try self.env.runMacrotasks(); - env.pumpMessageLoop(); + const ran_platform_task = env.pumpMessageLoop(); // either of the above could have queued more microtasks env.runMicrotasks(); + return ran_platform_task; } pub fn hasBackgroundTasks(self: *Browser) bool { diff --git a/src/browser/Runner.zig b/src/browser/Runner.zig index 5264accb9..c2fb72857 100644 --- a/src/browser/Runner.zig +++ b/src/browser/Runner.zig @@ -35,6 +35,7 @@ const Runner = @This(); session: *Session, browser: *Browser, http_client: *HttpClient, +background_poll_ms: u32 = 0, pub const Opts = struct {}; @@ -223,8 +224,9 @@ fn _tick(self: *Runner, comptime is_cdp: bool, timeout_ms: u32, conditions: []Wa const has_runnable_page = hasRunnablePage(session); + var ran_platform_task = false; if (has_runnable_page) { - try browser.runMacrotasks(); + ran_platform_task = try browser.runMacrotasks(); } const activity = http_client.activity(); @@ -318,8 +320,16 @@ fn _tick(self: *Runner, comptime is_cdp: bool, timeout_ms: u32, conditions: []Wa break :blk 200; } if (browser.hasBackgroundTasks()) { + // if our last runMacrotasks() ran something and we now have + // a background, then don't linger in the http client waiting + // for I/O, instead, hurry back to run more tasks. + // Else, backoff to 10ms between runs. + // TODO: this is a temporary solution to ensuring background + // tasks are run promptly.The better solution is to have v8 + // wakeup the http client when there's work to do. + self.background_poll_ms = if (ran_platform_task) 0 else @min(10, @max(1, self.background_poll_ms * 2)); // msToNextTask could be less than this, but 10ms drift is ok - break :blk 10; + break :blk self.background_poll_ms; } break :blk browser.msToNextTask() orelse 200; }; diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index 40118c7fc..799ed3b9f 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -567,14 +567,18 @@ pub fn msToNextTask(self: *Env) ?u64 { return if (next_task == std.math.maxInt(u64)) null else next_task; } -pub fn pumpMessageLoop(self: *const Env) void { +pub fn pumpMessageLoop(self: *const Env) bool { var hs: v8.HandleScope = undefined; v8.v8__HandleScope__CONSTRUCT(&hs, self.isolate.handle); defer v8.v8__HandleScope__DESTRUCT(&hs); const isolate = self.isolate.handle; const platform = self.platform.handle; - while (v8.v8__Platform__PumpMessageLoop(platform, isolate, false)) {} + var ran = false; + while (v8.v8__Platform__PumpMessageLoop(platform, isolate, false)) { + ran = true; + } + return ran; } pub fn hasBackgroundTasks(self: *const Env) bool { diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index 1dc926183..f56bc9a99 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -121,7 +121,7 @@ pub fn newCallback( pub fn runMacrotasks(self: *const Local) void { const env = self.ctx.env; - env.pumpMessageLoop(); + _ = env.pumpMessageLoop(); env.runMicrotasks(); // macrotasks can cause microtasks to queue } From 7a4d2fec712b6231e093dd8803f5f5572d88e343 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Mon, 14 Sep 2026 17:00:20 +0300 Subject: [PATCH 51/93] changes for