Merge branch 'main' into agent-fixes

# Conflicts:
#	build.zig.zon
#	src/SemanticTree.zig
This commit is contained in:
Adrià Arrufat committed 2026-09-25 10:44:05 +02:00
commit b5896c259b
111 files changed
+11728 -6068

No files matched your search

+1 -1
View File
@@ -13,7 +13,7 @@ inputs:
zig-v8:
description: 'zig v8 version to install'
required: false
default: 'v0.5.6'
default: 'v0.5.7'
v8:
description: 'v8 version to install'
required: false
+1 -1
View File
@@ -13,7 +13,7 @@ inputs:
zig-v8:
description: 'zig-v8 release tag the prebuilt lib came from'
required: false
default: 'v0.5.6'
default: 'v0.5.7'
runs:
using: "composite"
+2
View File
@@ -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 }}
+1 -1
View File
@@ -4,7 +4,7 @@ FROM debian:stable-slim
ARG MINISIG=0.12
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
ARG V8=14.9.207.35
ARG ZIG_V8=v0.5.6
ARG ZIG_V8=v0.5.7
ARG TARGETPLATFORM
RUN apt-get update -yq && \
+2 -2
View File
@@ -5,8 +5,8 @@
.minimum_zig_version = "0.16.0",
.dependencies = .{
.v8 = .{
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/0cc0b28d18f021c560d6f84b7e27e5a7ade9f8c8.tar.gz",
.hash = "v8-0.0.0-xddH6_g0AwBnqBwDOD22Mks8ODHFsH6SffwcpFoUSq3X",
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/ebd2fabfb001294ed43ef65286c7db17245416e9.tar.gz",
.hash = "v8-0.0.0-xddH66lEAwB-5Ec92QAp19-aZ6-oB8UnnUm-w3fBUEaX",
},
// .v8 = .{ .path = "../zig-v8-fork" },
.brotli = .{
+2684 -2687
View File
File diff suppressed because it is too large. Load diff
+6
View File
@@ -29,6 +29,8 @@
# OUT scratch directory (default /tmp/orderfile-regen)
# RAMDIR tmpfs the binary is benched from (default /dev/shm)
set -euo pipefail
# profile() runs in a command substitution, which otherwise drops set -e.
shopt -s inherit_errexit
DEMO_DIR=${DEMO_DIR:-../demo}
RUNS=${RUNS:-100}
@@ -92,8 +94,12 @@ profile() {
set_fault_around 4096
"$ram" serve --insecure-disable-tls-host-verification > /dev/null 2>&1 &
local pid=$!
# This subshell's own trap: the script's cleanup doesn't know this pid.
trap "kill $pid 2> /dev/null || true" EXIT
sleep 1
(cd "$DEMO_DIR" && RUNS=$RUNS node puppeteer/cdp.js > "$OUT/bench.out")
# A bench that dies early still leaves a resident set, just the wrong one.
grep -q '^total runs' "$OUT/bench.out" || { echo "bench did not complete, see $OUT/bench.out" >&2; exit 1; }
sleep 2
python3 "$TOOLS/pagemap.py" "$pid" "$resident" >&2
local hwm
+1061 -1171
View File
File diff suppressed because it is too large. Load diff
+16
View File
@@ -253,6 +253,8 @@ 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 = "cors_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 },
@@ -565,6 +567,20 @@ 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 corsStoreEntryLimit(self: *const Config) u32 {
return switch (self.mode) {
inline .serve, .fetch, .mcp, .agent => |opts| opts.cors_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,
+3 -1
View File
@@ -92,7 +92,8 @@ http_navigation_delay_ms: Histogram(&.{
}) = .{},
robots_status: CounterEnum("category", @import("network/http.zig").StatusCategory) = .{},
robots_access: CounterEnum("result", enum { allow, deny }) = .{},
cors_check: CounterEnum("result", enum { same_origin, no_cors, simple, preflight }) = .{},
robots_evictions: Counter = .{},
cors_check: CounterEnum("result", enum { same_origin, no_cors, simple, preflight, cached }) = .{},
cors_preflight: CounterEnum("result", enum { allowed, blocked }) = .{},
cors_response: CounterEnum("result", enum { allowed, blocked }) = .{},
adblock_verdicts: CounterEnum("verdict", @import("network/adblock/AdBlocker.zig").Verdict) = .{},
@@ -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",
+91 -35
View File
@@ -81,7 +81,7 @@ fn visitAll(self: @This(), visitor: anytype) error{WriteFailed}!void {
.listener_targets = listener_targets,
.label_index = &label_index,
};
self.walk(&ctx, self.dom_node, null, visitor, 1, 0) catch |err| {
self.walk(&ctx, visitor) catch |err| {
log.err(.app, "semantic tree walk failed", .{ .err = err });
return error.WriteFailed;
};
@@ -123,15 +123,60 @@ const WalkContext = struct {
label_index: *Label.LabelByForIndex,
};
fn walk(
// A node whose children are still being walked
const Open = struct {
next_child: ?*Node,
// for the children's xpath index
tag_counts: std.StringArrayHashMapUnmanaged(usize) = .empty,
name: ?[]const u8, // The children's parent_name
xpath_len: usize,
visited: bool,
};
fn walk(self: @This(), ctx: *WalkContext, visitor: anytype) !void {
var stack: std.ArrayList(Open) = .empty;
defer stack.deinit(self.arena);
try self.visitNode(ctx, &stack, self.dom_node, null, visitor, 1);
while (stack.items.len > 0) {
// Everything read from `top` is read before visitNode, which can grow (move) the stack.
const top = &stack.items[stack.items.len - 1];
if (top.next_child) |child| {
top.next_child = child._next;
var tag: []const u8 = "text()";
if (child.is(Element)) |el| {
tag = el.getTagNameLower();
}
const gop = try top.tag_counts.getOrPut(self.arena, tag);
if (!gop.found_existing) {
gop.value_ptr.* = 0;
}
gop.value_ptr.* += 1;
try self.visitNode(ctx, &stack, child, top.name, visitor, gop.value_ptr.*);
continue;
}
const done = stack.pop().?;
if (done.visited) {
try visitor.leave();
}
ctx.xpath_buffer.shrinkRetainingCapacity(done.xpath_len);
}
}
// Every ancestor of `node` below the root is open, so the stack's length is its depth.
fn visitNode(
self: @This(),
ctx: *WalkContext,
stack: *std.ArrayList(Open),
node: *Node,
parent_name: ?[]const u8,
visitor: anytype,
index: usize,
current_depth: u32,
) !void {
const current_depth = stack.items.len;
if (current_depth > self.max_depth) return;
// 1. Skip non-content nodes
@@ -209,8 +254,6 @@ fn walk(
try appendXPathSegment(node, ctx.xpath_buffer, self.arena, index);
const xpath = ctx.xpath_buffer.items;
var name = try axn.getName(self.frame, self.arena, ctx.label_index);
const has_explicit_label = if (node.is(Element)) |el|
el.getAttributeInterned("aria-label") != null or el.getAttributeInterned("title") != null
else
@@ -218,12 +261,14 @@ fn walk(
const structural = isStructuralRole(role);
// Filter out computed concatenated names for generic containers without explicit labels.
// No computed concatenated names for generic containers without explicit labels.
// This prevents token bloat and ensures their StaticText children aren't incorrectly pruned.
// We ignore interactivity because a generic wrapper with an event listener still shouldn't hoist all text.
if (name != null and structural and !has_explicit_label) {
name = null;
}
// Not computing it also keeps a deep chain of containers from being O(depth²).
const name = if (structural and !has_explicit_label)
null
else
try axn.getName(self.frame, self.arena, ctx.label_index);
var should_visit = true;
if (self.interactive_only) {
@@ -278,32 +323,12 @@ fn walk(
did_visit = false;
}
if (should_walk_children) {
// If we are printing this node normally OR skipping it and unrolling its children,
// we walk the children iterator.
var it = node.childrenIterator();
var tag_counts: std.StringArrayHashMapUnmanaged(usize) = .empty;
while (it.next()) |child| {
var tag: []const u8 = "text()";
if (child.is(Element)) |el| {
tag = el.getTagNameLower();
}
const gop = try tag_counts.getOrPut(self.arena, tag);
if (!gop.found_existing) {
gop.value_ptr.* = 0;
}
gop.value_ptr.* += 1;
try self.walk(ctx, child, name, visitor, gop.value_ptr.*, current_depth + 1);
}
}
if (did_visit) {
try visitor.leave();
}
ctx.xpath_buffer.shrinkRetainingCapacity(initial_xpath_len);
try stack.append(self.arena, .{
.next_child = if (should_walk_children) node._first_child else null,
.name = name,
.xpath_len = initial_xpath_len,
.visited = did_visit,
});
}
fn extractSelectOptions(node: *Node, frame: *Frame, arena: std.mem.Allocator) ![]OptionData {
@@ -780,3 +805,34 @@ test "SemanticTree max_depth" {
try testing.expect(std.mem.indexOf(u8, text_str, "other") == null);
}
test "SemanticTree: deep nesting doesn't overflow the native stack" {
var registry: NodeRegistry = .init(testing.allocator);
defer registry.deinit();
const frame = try testing.createFrame();
defer testing.test_session.closeAllPages();
// The link's name comes from its content: the whole chain. The <g>s are
// pruned, so the JSON only nests link > text. SVG, as an HTML element's
// pointer-events lookup walks its ancestors: O(depth²).
const depth = 50_000;
const doc = frame.window._document;
var top = try doc.createTextNode("deep");
for (0..depth) |_| {
const parent = (try doc.createElementNS("http://www.w3.org/2000/svg", "g", frame)).asNode();
_ = try parent.appendChild(top, frame);
top = parent;
}
const link = try doc.createElement("a", null, frame);
try link.setAttribute(.wrap("href"), .wrap("#"), frame);
_ = try link.asNode().appendChild(top, frame);
const st: Self = try .init(testing.arena_allocator, link.asNode(), &registry, frame, .{});
const json_str = try std.json.Stringify.valueAlloc(testing.allocator, st, .{});
defer testing.allocator.free(json_str);
try testing.expect(std.mem.indexOf(u8, json_str, "\"role\":\"link\",\"name\":\"deep\"") != null);
try testing.expectEqual(depth, std.mem.count(u8, json_str, "/g[1]"));
try testing.expect(std.mem.endsWith(u8, json_str, "/text()[1]\",\"nodeType\":3,\"nodeValue\":\"deep\",\"children\":[]}]}"));
}
+1 -1
View File
@@ -155,7 +155,7 @@ fn deviceLogin(allocator: std.mem.Allocator, interrupt: ?*zenai.http.Interrupt)
const code_res = try post(a, interrupt, device_code_url, "application/json", "{\"client_id\":\"" ++ client_id ++ "\"}");
if (code_res.status != .ok) {
log.warn(.app, "codex device-code request failed", .{ .status = @intFromEnum(code_res.status), .body = code_res.body });
log.warn(.app, "codex device-code failed", .{ .status = @intFromEnum(code_res.status), .body = code_res.body });
return error.DeviceCodeRequestFailed;
}
const dc = try std.json.parseFromSliceLeaky(DeviceCode, a, code_res.body, .{ .ignore_unknown_fields = true });
+3 -2
View File
@@ -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 {
+61 -6
View File
@@ -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");
@@ -133,6 +134,10 @@ _event_target_attr_listeners: GlobalEventHandlersLookup = .empty,
// File objects (reference counted via their Blob proto); released at teardown.
_file_lists: std.ArrayList(*FileList) = .empty,
// List of Documents which called document.open() and potentially need to have
// the parser freed.
_script_created_parser_docs: std.ArrayList(*Document) = .empty,
// Every matchMedia() result of this document, so a viewport change can fire
// their `change`.
_media_query_lists: std.ArrayList(*MediaQueryList) = .empty,
@@ -146,6 +151,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,
@@ -456,6 +463,16 @@ pub fn deinit(self: *Frame) void {
self._parse_state.deinit(self);
for (self._script_created_parser_docs.items) |doc| {
const parser = &(doc._script_created_parser orelse continue);
if (parser.parser.frame != self) {
// The document was closed and re-opened on another frame
continue;
}
parser.deinit();
doc._script_created_parser = null;
}
// Unregister CookieStore from session notifications before the JS
// context (and thus the scheduler) is destroyed, otherwise a late
// mutation could schedule a callback that never runs.
@@ -1444,6 +1461,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
@@ -1487,7 +1513,7 @@ fn frameHeaderDoneCallback(transfer: *HttpClient.Transfer) !HttpClient.Transfer.
.name = try self.arena.dupe(u8, hdr.name),
.value = try self.arena.dupe(u8, hdr.value),
});
if (std.ascii.eqlIgnoreCase(hdr.name, "referrer-policy")) {
if (std.mem.eql(u8, hdr.name, "referrer-policy")) {
if (referrer.parseHeader(hdr.value)) |rp| {
self.referrer_policy = rp;
}
@@ -1533,7 +1559,7 @@ fn maybeStartDownload(self: *Frame, transfer: *HttpClient.Transfer) !bool {
const disposition: HttpClient.Header = blk: {
var it = transfer.responseHeaderIterator();
while (it.next()) |hdr| {
if (std.ascii.eqlIgnoreCase(hdr.name, "content-disposition")) {
if (std.mem.eql(u8, hdr.name, "content-disposition")) {
break :blk hdr;
}
}
@@ -2041,6 +2067,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) {
@@ -2356,6 +2385,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 +3183,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 });
};
}
}
@@ -3282,10 +3334,13 @@ fn nodeIsReady(self: *Frame, comptime from_parser: bool, node: *Node) !void {
// Scripts, iframes, links and styles activate on becoming connected;
// appending them to a detached parent does nothing (they run/load later
// if the subtree gets inserted into the document).
if (comptime from_parser == false) {
switch (node._type) {
.element => if (!node.isConnected()) return,
else => {},
if (node._type == .element) {
if (comptime from_parser) {
if (node.getDocument(self)._frame == null) {
return;
}
} else if (!node.isConnected()) {
return;
}
}
+4
View File
@@ -83,6 +83,10 @@ pub const Slotted = struct {
assigned: []const *Node,
fallback: Children,
pub fn init(children_: Children) Slotted {
return .{ .tree = children_.tree, .assigned = &.{}, .fallback = children_ };
}
pub fn next(self: *Slotted) ?Child {
while (self.assigned.len > 0) {
const node = self.assigned[0];
+12 -2
View File
@@ -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;
};
+55 -6
View File
@@ -199,7 +199,7 @@ fn waitForPreload(self: *ScriptManager, url: [:0]const u8) ?*Script {
_ = client.tickSync(200) catch return null;
continue;
},
.done => |script| {
.done, .failed => |script| {
// Preload scripts are single-use. We return it and it becomes
// the caller's responsibility to free.
_ = self.preloaded_scripts.remove(url);
@@ -318,7 +318,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
if (mode != .normal) {
var preloaded = self.takePreload(remote_url);
if (preloaded == null and kind == .module) {
preloaded = self.base.takeModuleHint(remote_url);
preloaded = try self.base.takeModuleHint(remote_url);
}
if (preloaded) |pre| {
if (comptime lp.IS_DEBUG) {
@@ -545,6 +545,7 @@ const PreloadedScript = struct {
const State = union(enum) {
loading: *Script,
done: *Script,
failed: *Script,
};
pub fn deinit(self: PreloadedScript) void {
@@ -583,10 +584,12 @@ const PreloadedScript = struct {
log.warn(.http, "script fetch error", .{ .err = err, .req = script.url, .extra = "preload", .status = script.status });
}
script.status = 0; // status == 0 is correctly treated as an error throughout
script.complete = true;
const self: *ScriptManager = @fieldParentPtr("base", script.manager);
_ = self.preloaded_scripts.remove(script.url);
self.preloaded_scripts.getPtr(script.url).?.state = .{ .failed = script };
script.queueHintEvent(.@"error");
script.deinit();
}
// Owner-driven teardown killed this preload fetch via Transfer.kill, which
@@ -726,6 +729,52 @@ test "ScriptManager: preload whose submit fails synchronously releases its arena
const url = "http://127.0.0.1:9582/fails-at-submit.js";
// A fetch was started (and failed), so the hint's error event fires.
try testing.expectEqual(true, try sm.preloadScript(null, url));
// errorCallback consumed the entry; nothing dangles in the map.
try testing.expectEqual(false, sm.preloaded_scripts.contains(url));
// The failed entry stays for a <script> to consume; reset() frees it.
try testing.expect(sm.preloaded_scripts.getPtr(url).?.state == .failed);
}
// A failed preload used to be dropped, so the <script> consuming it fetched
// again: a blocked script logged "blocked url" and counted in
// adblock_verdicts twice. Unblocking before the <script>s and import() run
// makes a refetch observable: it would succeed and run the script.
test "ScriptManager: a failed preload is consumed, not refetched" {
const client = &testing.test_session.browser.http_client;
try client.setBlockedUrls(&.{ "*/preload_failed.js", "*/preload_failed_module.js" });
defer client.setBlockedUrls(&.{}) catch unreachable;
// Both hints' fetch errors, and nothing else.
testing.expectLog(&.{ .http, .http });
const page = try testing.pageTest("fixtures/preload_failed.html", .{});
defer page.close();
try client.setBlockedUrls(&.{});
{
const frame = page.frame().?;
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
try ls.local.eval(
\\const classic = document.createElement('script');
\\classic.src = 'preload_failed.js';
\\classic.onerror = () => window.classic_error = true;
\\document.head.appendChild(classic);
\\const module = document.createElement('script');
\\module.type = 'module';
\\module.src = 'preload_failed_module.js';
\\module.onerror = () => window.module_error = true;
\\document.head.appendChild(module);
\\const dynamic = document.createElement('script');
\\dynamic.textContent = "import('./preload_failed_module.js').catch(() => window.import_error = true);";
\\document.head.appendChild(dynamic);
, null);
}
var runner = testing.test_session.runner(.{});
try runner.waitForScript(page.frame_id,
\\window.classic_hint_error && window.module_hint_error &&
\\window.classic_error && window.module_error && window.import_error &&
\\!window.failed_classic_ran && !window.failed_module_ran
, 2000);
}
+40 -7
View File
@@ -253,8 +253,14 @@ pub fn preloadModuleHint(self: *ScriptManagerBase, element: ?*Element.Html, url:
// A <script type=module src=...> whose URL was hinted (modulepreload link or
// prescan)
pub fn takeModuleHint(self: *ScriptManagerBase, url: [:0]const u8) ?*Script {
pub fn takeModuleHint(self: *ScriptManagerBase, url: [:0]const u8) !?*Script {
const entry = self.imported_modules.getEntry(url) orelse return null;
if (entry.value_ptr.state == .err) {
// for loading/done, we'll remove the entry (because the script will
// get consumed). For err, we can keep the failure in the map to
// prevent a 2nd loader from needlessly trying to load this script
return try self.failedScript(url, .import);
}
if (entry.value_ptr.hint == false) {
// The script was preloaded, but not because of a hint. It came from v8
// telling us to preload the module. We cannot take it here because we know
@@ -268,15 +274,31 @@ pub fn takeModuleHint(self: *ScriptManagerBase, url: [:0]const u8) ?*Script {
break :blk script;
},
.done => |script| script,
// The hint's fetch failed; give the script its own attempt.
// I'm not sure if this is the right behavior. Why would a preload fail
// but the "real" load work? But it's definetly safer.
.err => return null,
.err => unreachable, // handled above
};
self.imported_modules.removeByPtr(entry.key_ptr);
return script;
}
// A dummy script for a module whose fetch already failed, to trigger the
// consumer's failure path (Script.eval fails on status == 0)
fn failedScript(self: *ScriptManagerBase, url: [:0]const u8, extra: Script.Extra) !*Script {
const arena = try self.acquireArena(.tiny, "SM.failedScript");
errdefer arena.release();
const script = try arena.create(Script);
script.* = .{
.arena = arena,
.url = url,
.status = 0,
.node = .{},
.manager = self,
.complete = true,
.source = .{ .remote = .empty },
.extra = extra,
};
return script;
}
pub fn waitForImport(self: *ScriptManagerBase, url: [:0]const u8) !ModuleSource {
const was_evaluating = self.is_evaluating;
self.is_evaluating = true;
@@ -354,6 +376,12 @@ pub fn releaseImport(self: *ScriptManagerBase, url: [:0]const u8) void {
pub fn getAsyncImport(self: *ScriptManagerBase, url: [:0]const u8, cb: ImportAsync.Callback, cb_data: *anyopaque, referrer: []const u8) !void {
// A <link rel=modulepreload> hint may already be fetching/fetched this module
if (self.imported_modules.getEntry(url)) |entry| {
if (entry.value_ptr.state == .err) {
const script = try self.failedScript(url, .{ .import_async = .{ .callback = cb, .data = cb_data } });
self.ready_scripts.append(&script.node);
self.evaluate();
return;
}
if (entry.value_ptr.hint) {
switch (entry.value_ptr.state) {
.loading => |script| {
@@ -381,8 +409,7 @@ pub fn getAsyncImport(self: *ScriptManagerBase, url: [:0]const u8, cb: ImportAsy
self.evaluate();
return;
},
// The hint's fetch failed; give the import its own attempt.
.err => {},
.err => unreachable, // handled above
}
}
}
@@ -852,6 +879,12 @@ pub const Script = struct {
return;
}
if (self.source == .remote and (self.status < 200 or self.status > 299)) {
// An adopted preload / module hint that had already failed.
self.executeCallback(comptime .wrap("error"));
return;
}
const previous_script = frame.document._current_script;
frame.document._current_script = fe.script_element;
defer frame.document._current_script = previous_script;
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -26,7 +26,7 @@ const KeyboardEvent = @import("webapi/event/KeyboardEvent.zig");
const Frame = @import("Frame.zig");
const Session = @import("Session.zig");
fn dispatchInputAndChangeEvents(el: *Element, frame: *Frame) !void {
pub fn dispatchInputAndChangeEvents(el: *Element, frame: *Frame) !void {
const input_evt: *Event = try .initTrusted(comptime .wrap("input"), .{ .bubbles = true }, frame.page);
frame._event_manager.dispatch(el.asEventTarget(), input_evt) catch |err| {
lp.log.err(.app, "dispatch input event failed", .{ .err = err });
+52 -4
View File
@@ -25,11 +25,59 @@ 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: <x> [<y>]`; 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 {
pub const AxisShorthand = struct {
name: []const u8,
x: []const u8,
y: []const u8,
};
// The `<x> [<y>]` 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 `<x> [<y>]` 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;
+167
View File
@@ -0,0 +1,167 @@
// Copyright (C) 2023 - 2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const 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.mem.eql(u8, hdr.name, "content-security-policy")) {
if (hasFrameAncestors(hdr.value)) {
// has priority over any x-frame-options
return true;
}
} else if (std.mem.eql(u8, 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'"));
}
+10 -2
View File
@@ -38,7 +38,7 @@ const String = lp.String;
const IFrame = Element.Html.IFrame;
pub fn createElementNS(document: *const Node.Document, namespace: Element.Namespace, name: []const u8, attribute_iterator: anytype) !*Node {
const from_parser = @TypeOf(attribute_iterator) == Parser.AttributeIterator;
const from_parser = @TypeOf(attribute_iterator) == Parser.AttributeIterator or @TypeOf(attribute_iterator) == Parser.RebuiltAttributes;
const from_clone = @TypeOf(attribute_iterator) == *Element.Attribute.List or @TypeOf(attribute_iterator) == *const Element.Attribute.List;
const frame = frameOf(document);
@@ -849,6 +849,7 @@ pub fn createElementNS(document: *const Node.Document, namespace: Element.Namesp
const node = try createHtmlElementT(document, Element.Html.Custom, namespace, attribute_iterator, .{
._tag_name = tag_name,
._definition = definition,
._upgrade_candidate = creation == .construct,
});
if (creation == .construct) {
try realm._undefined_custom_elements.append(realm.arena, node.as(Element).is(Element.Html.Custom).?);
@@ -1070,7 +1071,7 @@ fn createHtmlElementT(document: *const Node.Document, comptime E: type, namespac
const node = element.asNode();
if (@hasDecl(E, "Build") and @hasDecl(E.Build, "created")) {
if (comptime @TypeOf(attribute_iterator) == Parser.AttributeIterator and @hasDecl(E.Build, "parser_created_on_insert")) {
if (comptime (@TypeOf(attribute_iterator) == Parser.AttributeIterator or @TypeOf(attribute_iterator) == Parser.RebuiltAttributes) and @hasDecl(E.Build, "parser_created_on_insert")) {
// The element wants its parent, it'll do this work when inserted.
return node;
}
@@ -1120,6 +1121,13 @@ fn populateElementAttributes(frame: *Frame, element: *Element, list: anytype) !v
return;
}
if (@TypeOf(list) == Parser.RebuiltAttributes) {
// from the parser, rebuilding an element in another document
try element._attributes.cloneFrom(list.list, frame);
element.noteStyleAttribute();
return;
}
// from the parser
if (@TypeOf(list) == @TypeOf(null)) {
return;
+1 -1
View File
@@ -171,7 +171,7 @@ pub fn scheduleIntersectionChecks(frame: *Frame) void {
frame._intersection.check_scheduled = true;
frame.js.queueIntersectionChecks() catch |err| {
frame._intersection.check_scheduled = false;
log.err(.frame, "frame.scheduleIntersectionChecks", .{ .err = err, .type = frame._type, .url = frame.url });
log.err(.frame, "scheduleIntersectionChecks", .{ .err = err, .type = frame._type, .url = frame.url });
};
}
+41 -8
View File
@@ -16,12 +16,15 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const Frame = @import("../Frame.zig");
const Parser = @import("../parser/Parser.zig");
const Node = @import("../webapi/Node.zig");
const Element = @import("../webapi/Element.zig");
const Document = @import("../webapi/Document.zig");
const HTMLDocument = @import("../webapi/HTMLDocument.zig");
const ShadowRoot = @import("../webapi/ShadowRoot.zig");
const slotting = @import("../webapi/element/slotting.zig");
@@ -101,6 +104,44 @@ pub fn fragment(frame: *Frame, node: *Node, html: []const u8, opts: FragmentPars
try Element.Html.Picture.childrenInserted(node, frame);
}
pub const HtmlDocumentOpts = struct {
allow_declarative_shadow: bool = false,
};
// Build a detached HTMLDocument from `html` (DOMParser.parseFromString and
// Document.parseHTML). The caller sets its URL.
pub fn htmlDocument(frame: *Frame, html: []const u8, opts: HtmlDocumentOpts) !*HTMLDocument {
const arena = try frame.getArena(.medium, "parse.htmlDocument");
defer arena.release();
// Frame-side hooks triggered from `Build.created` / `nodeIsReady`
// (external stylesheet fetches, script execution, mutation-observer
// fan-out, default-script injection) treat the parsed nodes as detached
// and skip side effects on the live document.
const previous_parse_mode = frame._parse_mode;
frame._parse_mode = .fragment;
defer frame._parse_mode = previous_parse_mode;
const doc = try frame._factory.document(HTMLDocument{ ._proto = undefined });
var normalized = std.mem.trim(u8, html, &std.ascii.whitespace);
if (normalized.len == 0) {
normalized = "<html></html>";
}
var parser = Parser.init(arena.allocator(), doc.asNode(), frame, .{
.allow_declarative_shadow = opts.allow_declarative_shadow,
});
parser.parse(normalized);
if (parser.terminated) {
return error.ExecutionTerminated;
}
if (parser.err) |pe| {
return pe.err;
}
return doc;
}
// Build a detached XMLDocument from `xml` (DOMParser.parseFromString and
// XMLHttpRequest.responseXML). Returns null when the input isn't well-formed
// XML.
@@ -123,13 +164,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 <?xml?>
// declaration), skip it.
const first_child = doc_node.firstChild().?;
if (first_child.getNodeType() == 7) {
_ = try doc_node.removeChild(first_child, frame);
}
return doc;
}
+36 -8
View File
@@ -487,16 +487,44 @@ 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
/// 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);
/// 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;
}
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 (try container.scrollByAxis(axis, delta, frame)) {
return;
}
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 {
+8 -4
View File
@@ -443,7 +443,7 @@ pub fn hideServiceWorker(self: *const Env, comptime is_frame: bool, v8_context:
var deleted: v8.MaybeBool = undefined;
v8.v8__Object__Delete(global_obj, v8_context, @ptrCast(self.disabled_api_names.get(self.isolate.handle, "caches")), &deleted);
if (deleted.has_value == false or deleted.value == false) {
log.warn(.js, "failed to hide experimental API", .{ .interface = "global", .member = "caches" });
log.warn(.js, "experimental API not hidden", .{ .interface = "global", .member = "caches" });
}
}
@@ -457,7 +457,7 @@ fn deletePrototypeMember(self: *const Env, v8_context: *const v8.Context, global
var deleted: v8.MaybeBool = undefined;
v8.v8__Object__Delete(@ptrCast(prototype), v8_context, @ptrCast(names.get(isolate, member)), &deleted);
if (deleted.has_value == false or deleted.value == false) {
log.warn(.js, "failed to hide experimental API", .{ .interface = interface, .member = member });
log.warn(.js, "experimental API not hidden", .{ .interface = interface, .member = member });
}
}
@@ -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 {
+1 -1
View File
@@ -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
}
+49 -18
View File
@@ -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,
@@ -325,22 +326,18 @@ fn createSnapshotContext(
const template_index = comptime bridge.JsApiLookup.getId(JsApi);
const func = v8.v8__FunctionTemplate__GetFunction(templates[template_index], context);
if (@hasDecl(JsApi.Meta, "name")) {
const name = JsApi.Meta.name;
const v8_class_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
var maybe_result: v8.MaybeBool = undefined;
// Web IDL: interface objects on the global are non-enumerable.
v8.v8__Object__DefineOwnProperty(global_obj, context, v8_class_name, func, v8.DontEnum, &maybe_result);
if (@hasDecl(JsApi.Meta, "constructor_alias")) {
const alias = JsApi.Meta.constructor_alias;
const v8_class_name = v8.v8__String__NewFromUtf8(isolate, alias.ptr, v8.kNormal, @intCast(alias.len));
var maybe_result: v8.MaybeBool = undefined;
v8.v8__Object__Set(global_obj, context, v8_class_name, func, &maybe_result);
const name = JsApi.Meta.name;
const illegal_class_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
var maybe_result2: v8.MaybeBool = undefined;
v8.v8__Object__DefineOwnProperty(global_obj, context, illegal_class_name, func, 0, &maybe_result2);
} else {
const name = JsApi.Meta.name;
const v8_class_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
var maybe_result: v8.MaybeBool = undefined;
// Web IDL: interface objects on the global are non-enumerable.
v8.v8__Object__DefineOwnProperty(global_obj, context, v8_class_name, func, v8.DontEnum, &maybe_result);
const alias_func = generateLegacyFactoryFunction(JsApi, isolate, templates[template_index], func.?, context.?);
const v8_alias_name = v8.v8__String__NewFromUtf8(isolate, alias.ptr, v8.kNormal, @intCast(alias.len));
var maybe_alias_result: v8.MaybeBool = undefined;
v8.v8__Object__DefineOwnProperty(global_obj, context, v8_alias_name, @ptrCast(alias_func), v8.DontEnum, &maybe_alias_result);
}
}
@@ -671,7 +668,7 @@ fn illegalConstructorCallback(raw_info: ?*const v8.FunctionCallbackInfo) callcon
if (v8.v8__Function__GetName(func)) |name_value| {
if (v8.v8__Value__IsString(name_value)) {
const str: *const v8.String = @ptrCast(name_value);
const n = v8.v8__String__WriteUtf8(str, isolate, &name_buf, name_buf.len, v8.NO_NULL_TERMINATION | v8.REPLACE_INVALID_UTF8);
const n = v8.v8__String__WriteUtf8(str, isolate, &name_buf, name_buf.len, v8.WRITE_REPLACE_INVALID_UTF8, null);
name = name_buf[0..@intCast(n)];
}
}
@@ -720,7 +717,9 @@ fn protoIndexLookup(comptime JsApi: type) ?u16 {
// Generate a constructor template for a JsApi type (public for reuse)
fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *const v8.FunctionTemplate {
const callback, const arity = comptime blk: {
if (@hasDecl(JsApi, "constructor")) {
// The constructor belongs to the legacy factory function (`Image`),
// see generateLegacyFactoryFunction.
if (@hasDecl(JsApi, "constructor") and !@hasDecl(JsApi.Meta, "constructor_alias")) {
break :blk .{ JsApi.constructor.func, JsApi.constructor.arity };
}
if (inheritsFromHtmlElement(JsApi)) {
@@ -748,6 +747,38 @@ fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *const v8.Fun
return template;
}
// https://webidl.spec.whatwg.org/#legacy-factory-functions
// `Image`, `Audio`, `Option`: a function distinct from the interface object
// (so `new HTMLImageElement()` stays illegal and `Image.name` is "Image"),
// whose `prototype` is the interface's prototype object.
fn generateLegacyFactoryFunction(comptime JsApi: type, isolate: *v8.Isolate, interface_template: *const v8.FunctionTemplate, interface_func: *const v8.Function, context: *const v8.Context) *const v8.Function {
const alias = JsApi.Meta.constructor_alias;
const template = v8.v8__FunctionTemplate__New__Config(isolate, &.{
.length = JsApi.constructor.arity,
.callback = JsApi.constructor.func,
.behavior = v8.kConstructorBehavior_Allow,
}).?;
// Inherit so that the objects we construct pass the interface's
// accessor and method signature checks.
v8.v8__FunctionTemplate__Inherit(template, interface_template);
{
const internal_field_count = comptime countInternalFields(JsApi);
if (internal_field_count > 0) {
const instance_template = v8.v8__FunctionTemplate__InstanceTemplate(template);
v8.v8__ObjectTemplate__SetInternalFieldCount(instance_template, internal_field_count);
}
}
const class_name = v8.v8__String__NewFromUtf8(isolate, alias.ptr, v8.kNormal, @intCast(alias.len));
v8.v8__FunctionTemplate__SetClassName(template, class_name);
const func = v8.v8__FunctionTemplate__GetFunction(template, context).?;
const prototype_key = v8.v8__String__NewFromUtf8(isolate, "prototype", v8.kNormal, 9);
const interface_prototype = v8.v8__Object__Get(@ptrCast(interface_func), context, prototype_key).?;
var maybe_result: v8.MaybeBool = undefined;
v8.v8__Object__DefineOwnProperty(@ptrCast(func), context, prototype_key, interface_prototype, v8.ReadOnly + v8.DontEnum + v8.DontDelete, &maybe_result);
return func;
}
// hard-coded special case for HtmlElement which can be extended but not
// instantiated.
fn inheritsFromHtmlElement(comptime JsApi: type) bool {
+4 -4
View File
@@ -65,7 +65,7 @@ fn _toSlice(self: String, comptime null_terminate: bool, allocator: Allocator) !
const l = v8.v8__String__Utf8Length(handle, isolate);
const buf = try (if (comptime null_terminate) allocator.allocSentinel(u8, @intCast(l), 0) else allocator.alloc(u8, @intCast(l)));
const n = v8.v8__String__WriteUtf8(handle, isolate, buf.ptr, buf.len, v8.NO_NULL_TERMINATION | v8.REPLACE_INVALID_UTF8);
const n = v8.v8__String__WriteUtf8(handle, isolate, buf.ptr, buf.len, v8.WRITE_REPLACE_INVALID_UTF8, null);
if (comptime lp.IS_DEBUG) {
std.debug.assert(n == l);
}
@@ -87,7 +87,7 @@ pub fn toSSOWithAlloc(self: String, allocator: Allocator) !lp.String {
if (l <= 12) {
var content: [12]u8 = undefined;
const n = v8.v8__String__WriteUtf8(handle, isolate, &content[0], content.len, v8.NO_NULL_TERMINATION | v8.REPLACE_INVALID_UTF8);
const n = v8.v8__String__WriteUtf8(handle, isolate, &content[0], content.len, v8.WRITE_REPLACE_INVALID_UTF8, null);
if (comptime lp.IS_DEBUG) {
std.debug.assert(n == l);
}
@@ -103,7 +103,7 @@ pub fn toSSOWithAlloc(self: String, allocator: Allocator) !lp.String {
}
const buf = try allocator.alloc(u8, l);
const n = v8.v8__String__WriteUtf8(handle, isolate, buf.ptr, buf.len, v8.NO_NULL_TERMINATION | v8.REPLACE_INVALID_UTF8);
const n = v8.v8__String__WriteUtf8(handle, isolate, buf.ptr, buf.len, v8.WRITE_REPLACE_INVALID_UTF8, null);
if (comptime lp.IS_DEBUG) {
std.debug.assert(n == l);
}
@@ -124,7 +124,7 @@ pub fn format(self: String, writer: *std.Io.Writer) !void {
const l = v8.v8__String__Utf8Length(handle, isolate);
var buf = if (l < 1024) &small else local.call_arena.alloc(u8, @intCast(l)) catch return error.WriteFailed;
const n = v8.v8__String__WriteUtf8(handle, isolate, buf.ptr, buf.len, v8.NO_NULL_TERMINATION | v8.REPLACE_INVALID_UTF8);
const n = v8.v8__String__WriteUtf8(handle, isolate, buf.ptr, buf.len, v8.WRITE_REPLACE_INVALID_UTF8, null);
return writer.writeAll(buf[0..n]);
}
+68 -12
View File
@@ -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,56 @@ pub const Indexed = struct {
}
};
fn hasNotHandled(comptime E: type) bool {
// anyerror includes it
const errors = @typeInfo(E).error_set orelse return true;
for (errors) |e| {
if (std.mem.eql(u8, e.name, "NotHandled")) return true;
}
return false;
}
// 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.?;
// A getter that can return neither null nor error.NotHandled would report
// every index as present.
const can_be_absent = switch (@typeInfo(@typeInfo(@TypeOf(getter)).@"fn".return_type.?)) {
.optional => true,
.error_union => |eu| @typeInfo(eu.payload) == .optional or hasNotHandled(eu.error_set),
else => false,
};
if (can_be_absent == false) {
@compileError(@typeName(Self) ++ ": an indexed getter that can't return null or error.NotHandled needs an explicit query");
}
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,
@@ -1271,6 +1326,7 @@ pub const PageJsApis = flattenTypes(&.{
@import("../webapi/XPathExpression.zig"),
@import("../webapi/XPathEvaluator.zig"),
@import("../webapi/collections/DOMStringList.zig"),
@import("../webapi/Sanitizer.zig"),
});
// APIs available on EVERY worker global — dedicated, shared and service. This
+1 -1
View File
@@ -598,7 +598,7 @@ pub fn writeStackTrace(isolate: *v8.Isolate, stack_handle: *const v8.StackTrace,
const frame_handle = v8.v8__StackTrace__GetFrame(stack_handle, isolate, @intCast(i)).?;
if (v8.v8__StackFrame__GetFunctionName(frame_handle)) |name| {
var buf: [1024]u8 = undefined;
const n = v8.v8__String__WriteUtf8(name, isolate, &buf, buf.len, v8.NO_NULL_TERMINATION | v8.REPLACE_INVALID_UTF8);
const n = v8.v8__String__WriteUtf8(name, isolate, &buf, buf.len, v8.WRITE_REPLACE_INVALID_UTF8, null);
try writer.print("{s}{s}:{d}", .{ separator, buf[0..n], v8.v8__StackFrame__GetLineNumber(frame_handle) });
} else {
try writer.print("{s}<anonymous>:{d}", .{ separator, v8.v8__StackFrame__GetLineNumber(frame_handle) });
+114 -59
View File
@@ -36,6 +36,8 @@ pub const Opts = struct {
const truncation_marker = LimitedWriter.truncation_marker;
const Error = error{ WriteFailed, OutOfMemory };
const State = struct {
const ListType = enum { ordered, unordered };
const ListState = struct {
@@ -63,11 +65,31 @@ fn getAnchorLabel(el: *Element) ?[]const u8 {
return el.getAttributeInterned("aria-label") orelse el.getAttributeInterned("title");
}
// Iterative else large trees will stackoverflow
const Context = struct {
state: State,
writer: *std.Io.Writer,
frame: *Frame,
tree: RenderTree,
stack: std.ArrayList(Open) = .empty,
// Content still to render, followed by what closes the element.
const Open = struct {
iter: RenderTree.Slotted,
epilogue: Epilogue,
};
// what follows after the children
const Epilogue = union(enum) {
none,
element: Element.Tag,
block_anchor: struct { href: ?[]const u8, label: ?[]const u8 },
inline_anchor: struct { href: ?[]const u8, standalone: bool },
};
fn deinit(self: *Context) void {
self.stack.deinit(self.frame.local_arena);
}
fn ensureNewline(self: *Context) !void {
if (!self.state.last_char_was_newline) {
@@ -76,18 +98,32 @@ const Context = struct {
}
}
fn render(self: *Context, node: *Node) error{WriteFailed}!void {
fn render(self: *Context, node: *Node) Error!void {
switch (node._type) {
.document, .document_fragment => try self.renderChildren(node, false),
.document, .document_fragment => try self.open(.init(self.tree.children(node, false)), .none),
else => {
if (self.tree.classify(node, .{})) |child| {
try self.renderChild(child);
}
},
}
while (self.stack.items.len > 0) {
// renderChild can grow the stack, so we can't re-use top on continue
const top = &self.stack.items[self.stack.items.len - 1];
if (top.iter.next()) |child| {
try self.renderChild(child);
continue;
}
try self.close(self.stack.pop().?.epilogue);
}
}
fn renderChild(self: *Context, child: RenderTree.Child) error{WriteFailed}!void {
fn open(self: *Context, iter: RenderTree.Slotted, epilogue: Epilogue) Error!void {
return self.stack.append(self.frame.local_arena, .{ .iter = iter, .epilogue = epilogue });
}
fn renderChild(self: *Context, child: RenderTree.Child) Error!void {
switch (child.what) {
.element => |display| {
const el = child.node.subtype(Node.Element);
@@ -105,28 +141,8 @@ const Context = struct {
}
}
fn renderChildren(self: *Context, parent: *Node, boxed: bool) error{WriteFailed}!void {
var it = self.tree.children(parent, boxed);
while (it.next()) |child| {
try self.renderChild(child);
}
}
fn renderContent(self: *Context, el: *Element, boxed: bool) error{WriteFailed}!void {
var it = self.tree.content(el, boxed);
while (it.next()) |child| {
try self.renderChild(child);
}
}
fn renderSlotContent(self: *Context, slot: *Slot) error{WriteFailed}!void {
var it = self.tree.slotted(slot);
while (it.next()) |child| {
try self.renderChild(child);
}
}
fn renderElement(self: *Context, el: *Element, display: StyleManager.Display) !void {
// write the element's opening markers and push its contents
fn renderElement(self: *Context, el: *Element, display: StyleManager.Display) Error!void {
const tag = el.getTag();
const boxed = display == .flex or display == .grid;
@@ -247,22 +263,17 @@ const Context = struct {
const label = getAnchorLabel(el);
const href_raw = el.getAttributeInterned("href");
if (!info.has_visible and label == null and href_raw == null) return;
if (!info.has_visible and label == null and href_raw == null) {
return;
}
const href = if (href_raw) |h| URL.resolve(frame.local_arena, frame.base(), h, .{ .encoding = frame.charset }) catch h else null;
if (info.has_block) {
try self.renderContent(el, boxed);
if (href) |h| {
if (!self.state.last_char_was_newline) try self.writer.writeByte('\n');
try self.writer.writeByte('[');
try self.writer.writeAll(label orelse h);
try self.writer.writeAll("](");
try self.writer.writeAll(h);
try self.writer.writeAll(")\n");
self.state.last_char_was_newline = true;
}
return;
return self.open(.init(self.tree.content(el, boxed)), .{ .block_anchor = .{
.href = href,
.label = label,
} });
}
const standalone = RenderTree.isStandaloneAnchor(el, frame);
@@ -270,23 +281,13 @@ const Context = struct {
if (!self.state.last_char_was_newline) try self.writer.writeByte('\n');
}
try self.writer.writeByte('[');
const epilogue: Epilogue = .{ .inline_anchor = .{ .href = href, .standalone = standalone } };
if (info.has_visible) {
try self.renderContent(el, boxed);
} else {
try self.writer.writeAll(label orelse "");
return self.open(.init(self.tree.content(el, boxed)), epilogue);
}
try self.writer.writeAll("](");
if (href) |h| {
try self.writer.writeAll(h);
}
try self.writer.writeByte(')');
if (standalone) {
try self.writer.writeByte('\n');
self.state.last_char_was_newline = true;
} else {
self.state.last_char_was_newline = false;
}
return;
try self.writer.writeAll(label orelse "");
return self.close(epilogue);
},
.input => {
const type_attr = el.getAttributeInterned("type") orelse return;
@@ -297,20 +298,49 @@ const Context = struct {
}
return;
},
.slot => return self.renderSlotContent(el.as(Slot)),
.slot => return self.open(self.tree.slotted(el.as(Slot)), .none),
else => {},
}
try self.renderContent(el, boxed);
return self.open(.init(self.tree.content(el, boxed)), .{ .element = tag });
}
// Finish the element after renderElement has written the children
fn close(self: *Context, epilogue: Epilogue) Error!void {
const tag = switch (epilogue) {
.none => return,
.element => |t| t,
.block_anchor => |anchor| {
const href = anchor.href orelse return;
try self.ensureNewline();
try self.writer.writeByte('[');
try self.writer.writeAll(anchor.label orelse href);
try self.writer.writeAll("](");
try self.writer.writeAll(href);
try self.writer.writeAll(")\n");
return;
},
.inline_anchor => |anchor| {
try self.writer.writeAll("](");
if (anchor.href) |h| {
try self.writer.writeAll(h);
}
try self.writer.writeByte(')');
if (anchor.standalone) {
try self.writer.writeByte('\n');
self.state.last_char_was_newline = true;
} else {
self.state.last_char_was_newline = false;
}
return;
},
};
switch (tag) {
.pre => {
if (!self.state.last_char_was_newline) {
try self.writer.writeByte('\n');
}
try self.ensureNewline();
try self.writer.writeAll("```\n");
self.state.pre_node = null;
self.state.last_char_was_newline = true;
},
.code => {
if (self.state.pre_node == null) {
@@ -425,7 +455,9 @@ pub fn dump(state: RenderTree.State, opts: Opts, writer: *std.Io.Writer, frame:
.frame = frame,
.tree = .{ .frame = frame, .state = state },
};
defer ctx.deinit();
ctx.render(node) catch |err| switch (err) {
error.OutOfMemory => return err,
error.WriteFailed => {
if (!lw.truncated) return err;
try writer.writeAll(truncation_marker);
@@ -444,6 +476,7 @@ pub fn dump(state: RenderTree.State, opts: Opts, writer: *std.Io.Writer, frame:
.frame = frame,
.tree = .{ .frame = frame, .state = state },
};
defer ctx.deinit();
try ctx.render(node);
if (!ctx.state.last_char_was_newline) {
try writer.writeByte('\n');
@@ -919,7 +952,7 @@ test "browser.markdown: declarative shadow DOM renders through piercing" {
const host = try doc.createElement("div", null, frame);
try host.setHTMLUnsafe(
\\<div><template shadowrootmode="open"><p>shadow content</p></template></div>
, frame);
, null, frame);
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
@@ -927,3 +960,25 @@ test "browser.markdown: declarative shadow DOM renders through piercing" {
try testing.expectString("\nshadow content\n", aw.written());
}
test "browser.markdown: deep nesting doesn't overflow the native stack" {
const frame = try testing.createFrame();
defer testing.test_session.closeAllPages();
const depth = 50_000;
const doc = frame.window._document;
var top = (try doc.createElement("i", null, frame)).asNode();
for (1..depth) |_| {
const parent = (try doc.createElement("i", null, frame)).asNode();
_ = try parent.appendChild(top, frame);
top = parent;
}
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try dump(.{ .root = top }, .{}, &aw.writer, frame);
// Every <i> opens and closes with a '*', then dump's trailing newline.
try testing.expectEqual(depth * 2 + 1, aw.written().len);
try testing.expectString("**", aw.written()[0..2]);
}
+124 -22
View File
@@ -42,6 +42,17 @@ pub const ParsedNode = struct {
// html5ever should never ask us for this data on a non-element, and we'll
// assert that, with this optional, to make sure our assumption is correct.
data: ?*anyopaque,
// Set once html5ever has put the node in the tree or given it children.
// Until then the node is unobservable and its document is only a guess.
placed: bool = false,
};
// Attributes of an element the parser rebuilds (see settleDocument): the ones
// html5ever gave the original, read back from it. node_factory treats this
// like AttributeIterator, i.e. as a creation by the parser.
pub const RebuiltAttributes = struct {
list: *const Element.Attribute.List,
};
// html5ever's tokenizer flushes the script-data character buffer on every '<'
@@ -63,6 +74,13 @@ err: ?Error,
arena: Allocator,
container: ParsedNode,
document: *Node.Document,
// The document new nodes are created in. The spec creates an element for a
// token in its intended parent's document, and a <template>'s content belongs
// to the inert template contents owner document (Document.templateContentsOwner)
// where custom elements are never constructed and nothing runs or loads.
// html5ever creates an element before it says where it goes, so this is the
// document of the last insertion point it did tell us about.
creation_document: *Node.Document,
strings: std.StringHashMapUnmanaged(void),
pending_text: ?PendingText,
// One buffer reused across every text run in this parser. clearRetainingCapacity
@@ -98,15 +116,18 @@ pub const Options = struct {
};
pub fn init(arena: Allocator, node: *Node, frame: *Frame, opts: Options) Parser {
const document = node.getDocument(frame);
return .{
.err = null,
.frame = frame,
.document = node.getDocument(frame),
.document = document,
.creation_document = document,
.strings = .empty,
.arena = arena,
.container = ParsedNode{
.data = null,
.node = node,
.placed = true,
},
.pending_text = null,
.buf = .empty,
@@ -161,7 +182,7 @@ fn appendTextChunk(self: *Parser, parent: *Node, txt: []const u8) !void {
// Fresh text run: the first chunk lives on _data only. buf stays empty
// until (and unless) a second chunk arrives.
const new_text = try Frame.node_factory.createTextNode(self.document, txt);
const new_text = try Frame.node_factory.createTextNode(parent.getDocument(self.frame), txt);
try self.frame.appendNew(parent, new_text);
self.inserted_since_checkpoint +|= 1;
self.pending_text = .{
@@ -530,7 +551,7 @@ fn _createElementCallback(self: *Parser, data: *anyopaque, qname: h5e.QualName,
} else local;
const namespace_string = qname.ns.slice();
const namespace = if (namespace_string.len == 0) default_namespace else Element.Namespace.parse(namespace_string);
const node = try Frame.node_factory.createElementNS(self.document, namespace, name, attributes);
const node = try Frame.node_factory.createElementNS(self.creation_document, namespace, name, attributes);
if (namespace == .unknown and namespace_string.len > 0) {
// Same as Document.createElementNS: keep the URI so namespaceURI and
// lookupNamespaceURI can return it.
@@ -556,7 +577,7 @@ fn createCommentCallback(ctx: *anyopaque, str: h5e.StringSlice) callconv(.c) ?*a
};
}
fn _createCommentCallback(self: *Parser, str: []const u8) !*anyopaque {
const node = try Frame.node_factory.createComment(self.document, str);
const node = try Frame.node_factory.createComment(self.creation_document, str);
const pn = try self.arena.create(ParsedNode);
pn.* = .{
.data = null,
@@ -575,7 +596,7 @@ fn createProcessingInstruction(ctx: *anyopaque, target: h5e.StringSlice, data: h
};
}
fn _createProcessingInstruction(self: *Parser, target: []const u8, data: []const u8) !*anyopaque {
const node = try Frame.node_factory.createProcessingInstruction(self.document, target, data);
const node = try Frame.node_factory.createProcessingInstruction(self.creation_document, target, data);
const pn = try self.arena.create(ParsedNode);
pn.* = .{
.data = null,
@@ -649,11 +670,15 @@ fn _getTemplateContentsCallback(self: *Parser, node: *Node) !*anyopaque {
const template = element.subtype(Element.Html).is(Element.Html.Template) orelse unreachable;
const content_node = template.getContent().asNode();
// html5ever asks for this to insert there next.
self.creation_document = content_node.getDocument(self.frame);
// Create a ParsedNode wrapper for the content DocumentFragment
const pn = try self.arena.create(ParsedNode);
pn.* = .{
.data = null,
.node = content_node,
.placed = true,
};
return pn;
}
@@ -708,11 +733,11 @@ fn appendCallback(ctx: *anyopaque, parent_ref: *anyopaque, node_or_text: h5e.Nod
const cp = self.frame._ce_reactions.push();
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
self._appendCallback(getNode(parent_ref), node_or_text) catch |err| {
self._appendCallback(getParsed(parent_ref), node_or_text) catch |err| {
self.err = .{ .err = err, .source = .append };
};
}
fn _appendCallback(self: *Parser, parent: *Node, node_or_text: h5e.NodeOrText) !void {
fn _appendCallback(self: *Parser, parent_pn: *ParsedNode, node_or_text: h5e.NodeOrText) !void {
// child node is guaranteed not to belong to another parent
switch (node_or_text.toUnion()) {
.node => |cpn| {
@@ -722,7 +747,8 @@ fn _appendCallback(self: *Parser, parent: *Node, node_or_text: h5e.NodeOrText) !
try self.flushPendingText();
self.maybeCheckpoint();
self.inserted_since_checkpoint +|= 1;
const child = getNode(cpn);
const child = try self.settleDocument(parent_pn, getParsed(cpn));
const parent = parent_pn.node;
if (child._parent) |previous_parent| {
// html5ever says this can't happen, but we might be screwing up
// the node on our side. We shouldn't be, but we're seeing this
@@ -736,11 +762,66 @@ fn _appendCallback(self: *Parser, parent: *Node, node_or_text: h5e.NodeOrText) !
}
try self.frame.appendNew(parent, child);
},
.text => |txt| try self.appendTextChunk(parent, txt),
.text => |txt| {
const parent = parent_pn.node;
self.creation_document = parent.getDocument(self.frame);
try self.appendTextChunk(parent, txt);
},
.failed => {},
}
}
fn settleDocument(self: *Parser, parent_pn: *ParsedNode, child_pn: *ParsedNode) !*Node {
const frame = self.frame;
const parent = parent_pn.node;
const child = child_pn.node;
// A custom element constructor may have put the child in the tree
// itself; that is as observable as being placed by html5ever.
if (child._parent != null) {
child_pn.placed = true;
}
var document = parent.getDocument(frame);
const child_document = child.getDocument(frame);
if (child_document != document) {
if (child_pn.placed == false) {
child_pn.node = try self.rebuildIn(child, document);
} else if (parent_pn.placed == false) {
try frame.adoptNodeTree(parent, document, child_document);
document = child_document;
} else {
try frame.adoptNodeTree(child, child_document, document);
}
}
parent_pn.placed = true;
child_pn.placed = true;
self.creation_document = document;
return child_pn.node;
}
// A copy of the never-placed `node` in `document`, created like the parser
// created `node`, in the wrong document.
fn rebuildIn(self: *Parser, node: *Node, document: *Node.Document) !*Node {
const frame = self.frame;
const element = node.is(Element) orelse {
// A comment or processing instruction: nothing to construct or load.
try frame.adoptNodeTree(node, node.getDocument(frame), document);
return node;
};
const copy = try Frame.node_factory.createElementNS(document, element._namespace, element.getTagNameDump(), RebuiltAttributes{ .list = &element._attributes });
if (element._namespace == .unknown) {
// The URI lives in a side table, see _createElementCallback.
const page = self.document._page;
if (page.element_namespace_uris.fetchRemove(element)) |entry| {
try page.element_namespace_uris.put(page.frame_arena, copy.as(Element), entry.value);
}
}
return copy;
}
fn removeFromParentCallback(ctx: *anyopaque, target_ref: *anyopaque) callconv(.c) void {
const self: *Parser = @ptrCast(@alignCast(ctx));
if (self.terminated) {
@@ -769,15 +850,28 @@ fn reparentChildrenCallback(ctx: *anyopaque, node_ref: *anyopaque, new_parent_re
}
const cp = self.frame._ce_reactions.push();
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
self._reparentChildrenCallback(getNode(node_ref), getNode(new_parent_ref)) catch |err| {
self._reparentChildrenCallback(getParsed(node_ref), getParsed(new_parent_ref)) catch |err| {
self.err = .{ .err = err, .source = .reparent_children };
};
}
fn _reparentChildrenCallback(self: *Parser, node: *Node, new_parent: *Node) !void {
fn _reparentChildrenCallback(self: *Parser, node_pn: *ParsedNode, new_parent_pn: *ParsedNode) !void {
// Reparenting can move the pending text node out from under us — the
// node's _parent changes but pending_text.parent does not. Flush so the
// accumulator commits before the tree is rearranged.
try self.flushPendingText();
// The new parent is a fresh element (adoption agency algorithm); the
// children it takes settle its document, not the other way around.
const node = node_pn.node;
const new_parent = new_parent_pn.node;
const document = node.getDocument(self.frame);
if (new_parent_pn.placed == false) {
const new_parent_document = new_parent.getDocument(self.frame);
if (new_parent_document != document) {
try self.frame.adoptNodeTree(new_parent, new_parent_document, document);
}
new_parent_pn.placed = true;
}
try self.frame.appendAllChildren(node, new_parent);
}
@@ -789,19 +883,21 @@ fn appendBeforeSiblingCallback(ctx: *anyopaque, sibling_ref: *anyopaque, node_or
const cp = self.frame._ce_reactions.push();
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
self._appendBeforeSiblingCallback(getNode(sibling_ref), node_or_text) catch |err| {
self._appendBeforeSiblingCallback(getParsed(sibling_ref), node_or_text) catch |err| {
self.err = .{ .err = err, .source = .append_before_sibling };
};
}
fn _appendBeforeSiblingCallback(self: *Parser, sibling: *Node, node_or_text: h5e.NodeOrText) !void {
fn _appendBeforeSiblingCallback(self: *Parser, sibling_pn: *ParsedNode, node_or_text: h5e.NodeOrText) !void {
// Foster parenting / before-sibling insertions interrupt any pending text
// run (the new node lands at a different position from the pending text's
// tail). Flush before reading the parent's structure.
try self.flushPendingText();
const sibling = sibling_pn.node;
const parent = sibling.parentNode() orelse return error.NoParent;
const node: *Node = switch (node_or_text.toUnion()) {
.node => |cpn| blk: {
const child = getNode(cpn);
var parent_pn = ParsedNode{ .node = parent, .data = null, .placed = true };
const child = try self.settleDocument(&parent_pn, getParsed(cpn));
if (child._parent) |previous_parent| {
// A custom element constructor may have inserted the node into the
// DOM before the parser officially places it (e.g. via foster
@@ -810,7 +906,10 @@ fn _appendBeforeSiblingCallback(self: *Parser, sibling: *Node, node_or_text: h5e
}
break :blk child;
},
.text => |txt| try Frame.node_factory.createTextNode(self.document, txt),
.text => |txt| blk: {
self.creation_document = parent.getDocument(self.frame);
break :blk try Frame.node_factory.createTextNode(self.creation_document, txt);
},
.failed => return,
};
try self.frame.insertNodeRelative(parent, node, .{ .before = sibling }, .{});
@@ -824,21 +923,24 @@ fn appendBasedOnParentNodeCallback(ctx: *anyopaque, element_ref: *anyopaque, pre
const cp = self.frame._ce_reactions.push();
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
self._appendBasedOnParentNodeCallback(getNode(element_ref), getNode(prev_element_ref), node_or_text) catch |err| {
self._appendBasedOnParentNodeCallback(getParsed(element_ref), getParsed(prev_element_ref), node_or_text) catch |err| {
self.err = .{ .err = err, .source = .append_based_on_parent_node };
};
}
fn _appendBasedOnParentNodeCallback(self: *Parser, element: *Node, prev_element: *Node, node_or_text: h5e.NodeOrText) !void {
if (element.parentNode()) |_| {
try self._appendBeforeSiblingCallback(element, node_or_text);
fn _appendBasedOnParentNodeCallback(self: *Parser, element_pn: *ParsedNode, prev_element_pn: *ParsedNode, node_or_text: h5e.NodeOrText) !void {
if (element_pn.node.parentNode()) |_| {
try self._appendBeforeSiblingCallback(element_pn, node_or_text);
} else {
try self._appendCallback(prev_element, node_or_text);
try self._appendCallback(prev_element_pn, node_or_text);
}
}
fn getParsed(ref: *anyopaque) *ParsedNode {
return @ptrCast(@alignCast(ref));
}
fn getNode(ref: *anyopaque) *Node {
const pn: *ParsedNode = @ptrCast(@alignCast(ref));
return pn.node;
return getParsed(ref).node;
}
fn asUint(comptime string: anytype) std.meta.Int(
+145 -71
View File
@@ -155,7 +155,7 @@ pub fn collect(arena: Allocator, state: RenderTree.State, frame: *Frame) ![]cons
// Any non-zero rc means nothing usable was written, so the caller has to fail
// rather than hand back a truncated file. WriteFailed is the only error
// jsonStringify's signature can carry, hence the log line.
fn renderFailed(what: []const u8, rc: i32) error{WriteFailed} {
fn renderFailed(comptime what: []const u8, rc: i32) error{WriteFailed} {
log.err(.browser, what, .{ .reason = switch (rc) {
RC_WRITE_REFUSED => "write refused",
RC_INVALID => "invalid options",
@@ -567,11 +567,15 @@ extern "c" fn lp_layout_new(
extern "c" fn lp_layout_free(h: *LayoutHandle) void;
extern "c" fn lp_render_abi(out: *LpAbi) void;
// Iterative else large trees will stackoverflow
const Builder = struct {
frame: *Frame,
arena: Allocator,
tree: RenderTree,
// Content still to render, followed by what closes the element.
stack: std.ArrayList(Open) = .empty,
blocks: std.ArrayList(LpBlock) = .empty,
// The block being built.
@@ -618,6 +622,30 @@ const Builder = struct {
index: u32,
};
const Open = struct {
iter: RenderTree.Slotted,
epilogue: Epilogue,
};
// what follows after the children
const Epilogue = union(enum) {
none,
block,
pre: ?*Node,
list: bool,
list_item: bool,
blockquote,
anchor: Anchor,
cell: bool,
element: Element.Tag,
const Anchor = struct {
linked: bool,
prev_href: []const u8,
standalone: bool,
};
};
const Error = Allocator.Error;
fn currentFlags(self: *const Builder) u32 {
@@ -758,9 +786,23 @@ const Builder = struct {
fn render(self: *Builder, node: *Node) Error!void {
switch (node._type) {
.document, .document_fragment => try self.renderChildren(node, false),
.document, .document_fragment => try self.open(.init(self.tree.children(node, false)), .none),
else => if (self.tree.classify(node, .{})) |child| try self.renderChild(child),
}
while (self.stack.items.len > 0) {
// renderChild can grow the stack, so we can't re-use top on continue
const top = &self.stack.items[self.stack.items.len - 1];
if (top.iter.next()) |child| {
try self.renderChild(child);
continue;
}
try self.close(self.stack.pop().?.epilogue);
}
}
fn open(self: *Builder, iter: RenderTree.Slotted, epilogue: Epilogue) Error!void {
return self.stack.append(self.arena, .{ .iter = iter, .epilogue = epilogue });
}
fn renderChild(self: *Builder, child: RenderTree.Child) Error!void {
@@ -771,21 +813,6 @@ const Builder = struct {
}
}
fn renderChildren(self: *Builder, parent: *Node, boxed: bool) Error!void {
var it = self.tree.children(parent, boxed);
while (it.next()) |child| try self.renderChild(child);
}
fn renderContent(self: *Builder, el: *Element, boxed: bool) Error!void {
var it = self.tree.content(el, boxed);
while (it.next()) |child| try self.renderChild(child);
}
fn renderSlotContent(self: *Builder, slot: *Slot) Error!void {
var it = self.tree.slotted(slot);
while (it.next()) |child| try self.renderChild(child);
}
fn renderText(self: *Builder, text: []const u8) Error!void {
if (text.len == 0) return;
@@ -824,16 +851,13 @@ const Builder = struct {
else => 6,
};
try self.openBlock(.heading, level);
try self.renderContent(el, boxed);
return self.closeBlock();
return self.open(.init(self.tree.content(el, boxed)), .block);
},
.pre => {
try self.openBlock(.pre, 0);
const prev = self.pre_node;
self.pre_node = el.asNode();
try self.renderContent(el, boxed);
self.pre_node = prev;
return self.closeBlock();
return self.open(.init(self.tree.content(el, boxed)), .{ .pre = prev });
},
.hr => {
try self.openBlock(.rule, 0);
@@ -854,10 +878,7 @@ const Builder = struct {
self.list_stack[self.list_depth] = .{ .ordered = tag == .ol, .index = 1 };
self.list_depth += 1;
}
try self.renderContent(el, boxed);
try self.closeBlock();
if (pushed) self.list_depth -= 1;
return;
return self.open(.init(self.tree.content(el, boxed)), .{ .list = pushed });
},
.li => {
try self.closeBlock();
@@ -871,19 +892,12 @@ const Builder = struct {
} else {
self.pending_marker = "•";
}
try self.renderContent(el, boxed);
try self.closeBlock();
self.pending_marker = "";
if (stray) self.list_depth = 0;
return;
return self.open(.init(self.tree.content(el, boxed)), .{ .list_item = stray });
},
.blockquote => {
try self.closeBlock();
self.quote_depth +|= 1;
try self.renderContent(el, boxed);
try self.closeBlock();
self.quote_depth -= 1;
return;
return self.open(.init(self.tree.content(el, boxed)), .blockquote);
},
.img => {
const alt = el.getAttributeInterned("alt") orelse return;
@@ -926,24 +940,19 @@ const Builder = struct {
// point outside the document.
self.href = URL.resolve(self.arena, self.frame.base(), h, .{ .encoding = self.frame.charset }) catch h;
}
const epilogue: Epilogue = .{ .anchor = .{
.linked = href != null,
.prev_href = prev_href,
.standalone = standalone,
} };
if (info.has_visible) {
try self.renderContent(el, boxed);
} else {
try self.renderText(label.?);
return self.open(.init(self.tree.content(el, boxed)), epilogue);
}
if (href != null) {
self.link -= 1;
self.href = prev_href;
}
if (standalone) {
try self.closeBlock();
self.tight -= 1;
} else {
self.after_anchor = true;
}
return;
try self.renderText(label.?);
return self.close(epilogue);
},
.slot => return self.renderSlotContent(el.as(Slot)),
.slot => return self.open(self.tree.slotted(el.as(Slot)), .none),
.td, .th => {
if (self.has_content) {
self.pending_space = true;
@@ -953,19 +962,12 @@ const Builder = struct {
self.pending_space = true;
}
if (tag == .th) self.bold += 1;
try self.renderContent(el, boxed);
if (tag == .th) self.bold -= 1;
self.pending_space = true;
return;
return self.open(.init(self.tree.content(el, boxed)), .{ .cell = tag == .th });
},
else => {},
}
const block = tag.isBlock() or switch (tag) {
.tr, .dt, .dd, .details, .summary, .caption, .legend, .option, .textarea => true,
else => false,
};
if (block) try self.closeBlock();
if (isBlockTag(tag)) try self.closeBlock();
switch (tag) {
.b, .strong => self.bold += 1,
@@ -975,22 +977,70 @@ const Builder = struct {
.code => self.mono += 1,
else => {},
}
try self.renderContent(el, boxed);
switch (tag) {
.b, .strong => self.bold -= 1,
.i, .em, .dfn => self.italic -= 1,
.ins => self.underline -= 1,
.s, .del => self.strike -= 1,
.code => self.mono -= 1,
else => {},
}
return self.open(.init(self.tree.content(el, boxed)), .{ .element = tag });
}
if (block) {
try self.closeBlock();
// Finish the element after renderElement has written the children
fn close(self: *Builder, epilogue: Epilogue) Error!void {
switch (epilogue) {
.none => {},
.block => try self.closeBlock(),
.pre => |prev| {
self.pre_node = prev;
try self.closeBlock();
},
.list => |pushed| {
try self.closeBlock();
if (pushed) self.list_depth -= 1;
},
.list_item => |stray| {
try self.closeBlock();
self.pending_marker = "";
if (stray) self.list_depth = 0;
},
.blockquote => {
try self.closeBlock();
self.quote_depth -= 1;
},
.anchor => |anchor| {
if (anchor.linked) {
self.link -= 1;
self.href = anchor.prev_href;
}
if (anchor.standalone) {
try self.closeBlock();
self.tight -= 1;
} else {
self.after_anchor = true;
}
},
.cell => |header| {
if (header) self.bold -= 1;
self.pending_space = true;
},
.element => |tag| {
switch (tag) {
.b, .strong => self.bold -= 1,
.i, .em, .dfn => self.italic -= 1,
.ins => self.underline -= 1,
.s, .del => self.strike -= 1,
.code => self.mono -= 1,
else => {},
}
if (isBlockTag(tag)) try self.closeBlock();
},
}
}
};
// Tags that get a block of their own, beyond the ones CSS calls blocks.
fn isBlockTag(tag: Element.Tag) bool {
return tag.isBlock() or switch (tag) {
.tr, .dt, .dd, .details, .summary, .caption, .legend, .option, .textarea => true,
else => false,
};
}
const testing = @import("../testing.zig");
test "browser.screenshot: rust abi matches" {
var got: LpAbi = undefined;
@@ -1418,7 +1468,7 @@ test "browser.screenshot: shadow dom and slots" {
const div = try doc.createElement("div", null, frame);
try div.setHTMLUnsafe(
\\<x-host><template shadowrootmode="open"><p>shadow <slot></slot></p></template>light</x-host>
, frame);
, null, frame);
var builder: Builder = .{ .arena = testing.arena_allocator, .frame = frame, .tree = .{ .frame = frame, .state = .{ .root = div.asNode() } } };
try builder.render(div.asNode());
@@ -1535,3 +1585,27 @@ test "browser.screenshot: collect honours strip flags" {
try testing.expectEqual(1, stripped.len);
try testing.expectEqual("Body text", try S.text(stripped[0], arena));
}
test "browser.screenshot: deep nesting doesn't overflow the native stack" {
defer testing.test_session.closeAllPages();
const frame = try testing.createFrame();
// <div>, not an inline tag: the inline style counters are u8, and nesting
// one 256 deep overflows them long before the walk is the problem.
const depth = 50_000;
const doc = frame.window._document;
const innermost = (try doc.createElement("div", null, frame)).asNode();
_ = try innermost.appendChild(try doc.createTextNode("deep"), frame);
var top = innermost;
for (1..depth) |_| {
const parent = (try doc.createElement("div", null, frame)).asNode();
_ = try parent.appendChild(top, frame);
top = parent;
}
const blocks = try collect(testing.arena_allocator, .{ .root = top }, frame);
try testing.expectEqual(1, blocks.len);
try testing.expectEqual(1, blocks[0].spans_len);
try testing.expectEqual("deep", blocks[0].spans[0].text[0..blocks[0].spans[0].len]);
}
+2 -2
View File
@@ -236,7 +236,7 @@ fn collectLinkHeaders(
const header_link_rels = [_][]const u8{ "service-doc", "service-desc", "api" };
for (frame._http_headers.items) |header| {
if (!std.ascii.eqlIgnoreCase(header.name, "link")) {
if (std.mem.eql(u8, header.name, "link") == false) {
continue;
}
@@ -705,7 +705,7 @@ test "structured_data: link headers from response" {
defer testing.test_session.closeAllPages();
// Stand in for what frameHeaderDoneCallback records from the navigation.
try frame._http_headers.append(frame.arena, .{ .name = "Link", .value =
try frame._http_headers.append(frame.arena, .{ .name = "link", .value =
\\<https://docs.example.com/>; rel="service-doc"
});
try frame._http_headers.append(frame.arena, .{ .name = "link", .value =
@@ -1 +1 @@
<!doctype html><title>Parent jobs</title><iframe src="/redirect-cross-origin/src/browser/tests/cdp/isolated_world_one.html?delay_ms=1000"></iframe>
<!doctype html><title>Parent jobs</title><iframe src="/redirect-cross-origin/src/browser/tests/cdp/isolated_world_one.html?delay_ms=300"></iframe>
@@ -0,0 +1,54 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<form id=f><input name=i1><select id=s><option>a</option><option>b</option></select></form>
<div id=d class="x y" data-a=1></div>
<iframe></iframe>
<script id=supported_indices>
{
// Web IDL: a supported index is enumerable, configurable and, unless
// there's an indexed setter (HTMLOptionsCollection), read-only. An
// unsupported one isn't there at all.
const d = document.getElementById('d');
const cases = {
HTMLCollection: document.getElementsByTagName('div'),
NodeList: document.querySelectorAll('div'),
NamedNodeMap: d.attributes,
HTMLOptionsCollection: document.getElementById('s').options,
HTMLAllCollection: document.all,
HTMLFormControlsCollection: document.getElementById('f').elements,
DOMTokenList: d.classList,
Window: window,
};
for (const [name, obj] of Object.entries(cases)) {
const desc = Object.getOwnPropertyDescriptor(obj, 0);
const writable = name === 'HTMLOptionsCollection';
testing.expectEqual([name, true, true, writable].join(' '), [name, desc.enumerable, desc.configurable, desc.writable].join(' '));
testing.expectEqual(name + ' true', name + ' ' + obj.propertyIsEnumerable(0));
testing.expectEqual(name + ' true', name + ' ' + (0 in obj));
testing.expectEqual(name + ' false', name + ' ' + (9999 in obj));
testing.expectEqual(name + ' true', name + ' ' + (Object.getOwnPropertyDescriptor(obj, 9999) === undefined));
}
}
</script>
<script id=enumeration>
{
// the query must not filter supported indices out of enumeration
const list = document.querySelectorAll('div');
testing.expectEqual(['0'], Object.keys(list));
const seen = [];
for (const k in list) seen.push(k);
testing.expectEqual('0', seen[0]);
}
</script>
<script id=read_only>
(function() {
'use strict';
const list = document.querySelectorAll('div');
const first = list[0];
testing.expectError('TypeError', () => { list[0] = 'x'; });
testing.expectEqual(first, list[0]);
})();
</script>
@@ -0,0 +1,211 @@
<!DOCTYPE html>
<body>
<script src="../testing.js"></script>
<script>
// Elements inside <template> content live in the inert "template contents
// owner document", which has no browsing context and no custom element
// registry. They must never be constructed/upgraded, and no reactions may run
// on them, even if a matching definition already exists. Only a copy stamped
// into the real document (importNode/cloneNode + insert) gets upgraded.
//
// Polymer relies on this: its `html` tag sets template.innerHTML with binding
// annotations such as items="[[list]]", then strips those attributes from the
// template content *before* stamping. If the element is upgraded while still
// in the template, attributeChangedCallback receives the raw "[[list]]" text
// and JSON.parse fails:
// Polymer::Attributes: couldn't decode Array as JSON: [[list]]
// (lib/mixins/property-accessors.js `_deserializeValue`, case Array).
let calls = [];
class TplElement extends HTMLElement {
static get observedAttributes() {
return ['items'];
}
constructor() {
super();
calls.push('ctor');
}
attributeChangedCallback(name, oldValue, newValue) {
calls.push(`acc:${name}=${newValue}`);
}
connectedCallback() {
calls.push('connected');
}
}
customElements.define('tpl-element', TplElement);
</script>
<template id="markup"><tpl-element items="[[fromMarkup]]"></tpl-element></template>
<script id="template_content">
{
// Parsed from document markup, defined before the <template> was parsed.
const tpl = document.getElementById('markup');
const inner = tpl.content.firstChild;
testing.expectEqual('TPL-ELEMENT', inner.tagName);
testing.expectFalse(inner instanceof TplElement);
testing.expectEqual('[]', JSON.stringify(calls));
}
{
// Polymer's html`` path: template.innerHTML with binding annotations.
calls = [];
const tpl = document.createElement('template');
tpl.innerHTML = '<tpl-element items="[[list]]"></tpl-element>';
const inner = tpl.content.firstChild;
testing.expectEqual('TPL-ELEMENT', inner.tagName);
testing.expectFalse(inner instanceof TplElement);
testing.expectEqual('[]', JSON.stringify(calls));
// Polymer strips the binding attribute from the template content before
// stamping; that must not trigger a reaction either.
inner.removeAttribute('items');
inner.setAttribute('items', '["a"]');
testing.expectEqual('[]', JSON.stringify(calls));
// Stamping a copy into the document upgrades the copy, which sees only
// the current attribute values.
const frag = document.importNode(tpl.content, true);
document.body.appendChild(frag);
testing.expectEqual('["ctor","acc:items=[\\"a\\"]","connected"]', JSON.stringify(calls));
// The template content itself is still untouched.
testing.expectFalse(inner instanceof TplElement);
}
{
// Template content belongs to a separate, inert document, shared by all
// templates: https://html.spec.whatwg.org/#template-contents-owner-document
const tpl = document.createElement('template');
tpl.innerHTML = '<tpl-element></tpl-element>';
const owner = tpl.content.ownerDocument;
testing.expectTrue(owner !== document);
testing.expectTrue(owner === document.createElement('template').content.ownerDocument);
testing.expectTrue(owner instanceof HTMLDocument);
testing.expectEqual(null, owner.defaultView);
testing.expectEqual(null, owner.documentElement);
testing.expectEqual('about:blank', owner.URL);
testing.expectEqual('complete', owner.readyState);
testing.expectTrue(tpl.content.firstChild.ownerDocument === owner);
}
{
// Polymer's <template is="dom-repeat"> wrapping: an element created by
// the template contents owner document is never upgraded, and neither
// are the binding attributes copied onto it.
calls = [];
const tpl = document.createElement('template');
tpl.innerHTML = '<template items="[[tabHeaders]]"></template>';
const inner = tpl.content.firstChild;
const wrapper = inner.ownerDocument.createElement('tpl-element');
testing.expectTrue(wrapper.ownerDocument === inner.ownerDocument);
testing.expectFalse(wrapper instanceof TplElement);
testing.expectEqual('[]', JSON.stringify(calls));
inner.parentNode.replaceChild(wrapper, inner);
wrapper.appendChild(inner);
wrapper.setAttribute('items', inner.getAttribute('items'));
inner.removeAttribute('items');
testing.expectFalse(wrapper instanceof TplElement);
testing.expectEqual('[]', JSON.stringify(calls));
// A fragment created by the inert document keeps moved content inert.
const holder = tpl.content.ownerDocument.createDocumentFragment();
holder.appendChild(tpl.content);
testing.expectTrue(holder.ownerDocument === wrapper.ownerDocument);
wrapper.setAttribute('items', '[[still]]');
testing.expectEqual('[]', JSON.stringify(calls));
// Stamping still works and only sees the final attributes.
wrapper.setAttribute('items', '["x"]');
const stamped = document.importNode(holder, true);
testing.expectTrue(stamped.ownerDocument === document);
testing.expectTrue(stamped.firstChild instanceof TplElement);
testing.expectEqual('["ctor","acc:items=[\\"x\\"]"]', JSON.stringify(calls));
// Moving the inert original into the document adopts and upgrades it.
calls = [];
document.body.appendChild(wrapper);
testing.expectTrue(wrapper.ownerDocument === document);
testing.expectTrue(wrapper instanceof TplElement);
testing.expectEqual('["ctor","acc:items=[\\"x\\"]","connected"]', JSON.stringify(calls));
}
{
// Cloning template content keeps the clone inert until it is stamped.
calls = [];
const tpl = document.createElement('template');
tpl.innerHTML = '<tpl-element items="[[clone]]"></tpl-element>';
const clone = tpl.content.cloneNode(true);
testing.expectEqual('[]', JSON.stringify(calls));
testing.expectFalse(clone.firstChild instanceof TplElement);
}
{
// Defining the element after the template exists does not reach into
// template content.
calls = [];
const tpl = document.createElement('template');
tpl.innerHTML = '<late-tpl-element items="[[late]]"></late-tpl-element>';
class LateTplElement extends HTMLElement {
static get observedAttributes() {
return ['items'];
}
attributeChangedCallback(name, oldValue, newValue) {
calls.push(`late:${name}=${newValue}`);
}
}
customElements.define('late-tpl-element', LateTplElement);
testing.expectFalse(tpl.content.firstChild instanceof LateTplElement);
testing.expectEqual('[]', JSON.stringify(calls));
// Nor does an explicit upgrade.
customElements.upgrade(tpl.content);
testing.expectFalse(tpl.content.firstChild instanceof LateTplElement);
testing.expectEqual('[]', JSON.stringify(calls));
}
</script>
<script>
let adopted = 0;
class AdoptElement extends HTMLElement {
adoptedCallback() {
adopted += 1;
}
}
customElements.define('adopt-element', AdoptElement);
</script>
<div id="adoption"><a><b><div><adopt-element></adopt-element><template>t</template></a></div></b></div>
<script id="adoption_agency">
{
// The parser creates an element in the document of the last insertion
// point, here the <template>'s content (hence the "t"). </a> then runs
// the adoption agency algorithm, which creates a clone of <b> (in that
// wrong document) and moves the <div>, already in this document, into
// it. The clone must join the <div>'s document, not the other way
// around, so nothing that was placed gets adopted.
const root = document.getElementById('adoption');
const shape = (el) => el.tagName + (el.children.length ? `(${[...el.children].map(shape).join(',')})` : '');
testing.expectEqual('DIV(A(B),B(DIV(A(ADOPT-ELEMENT,TEMPLATE))))', shape(root));
testing.expectEqual(0, adopted);
for (const el of root.querySelectorAll('*')) {
testing.expectTrue(el.ownerDocument === document);
}
testing.expectTrue(root.querySelector('adopt-element') instanceof AdoptElement);
const tpl = root.querySelector('template');
testing.expectTrue(tpl.content.ownerDocument !== document);
testing.expectEqual('t', tpl.content.textContent);
}
</script>
+13
View File
@@ -521,6 +521,11 @@
'<a>&#0;</a>',
'<a><?1x?></a>', // invalid processing instruction target
'<?1x?><a/>',
'<?xml version="2.0"?><a/>', // only 1.x versions are accepted
'<?xml version="10.0"?><a/>',
'<?xml version="1."?><a/>',
'<?xml version=1.0?><a/>',
'<?xml encoding="UTF-8"?><a/>', // version is required
]) {
testing.expectEqual(bad + ' -> error', bad + (isError(p.parseFromString(bad, 'text/xml')) ? ' -> error' : ' -> ok'));
}
@@ -529,6 +534,8 @@
for (const good of [
'<a/>',
'<?xml version="1.0" encoding="UTF-8"?>\n<a/>\n',
"<?xml version = '1.1075' ?><a/>",
'<?xml version="1.000" standalone="yes"?><a/>',
'\uFEFF<a/>',
'<a><![CDATA[<x>]]><!-- c --><?pi d?></a><!-- trailing -->',
'<a b="x&quot;y&apos;&lt;&#169;"/>',
@@ -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('<?xml version="1.0"?><?xml-stylesheet href="a.css"?><a/>', 'text/xml');
testing.expectEqual(2, decl.childNodes.length);
testing.expectEqual('xml-stylesheet', decl.firstChild.target);
testing.expectEqual('xml-stylesheet', p.parseFromString('<?xml-stylesheet href="a.css"?><a/>', 'text/xml').firstChild.target);
}
</script>
+20
View File
@@ -179,3 +179,23 @@
img.src = 'data:image/gif;base64,R0lGODlhAQABAAAAACw=';
testing.expectEqual(undefined, await img.decode());
</script>
<script id="legacy_factory_function">
// https://webidl.spec.whatwg.org/#legacy-factory-functions
testing.expectEqual(true, Image !== HTMLImageElement)
testing.expectEqual('Image', Image.name)
testing.expectEqual(0, Image.length)
testing.expectEqual(HTMLImageElement.prototype, Image.prototype)
testing.expectEqual(HTMLImageElement, new Image().constructor)
testing.expectEqual('img', new Image().localName)
testing.expectError('TypeError', () => new HTMLImageElement())
testing.expectError('TypeError', () => Image())
const desc = Object.getOwnPropertyDescriptor(Image, 'prototype')
testing.expectEqual(false, desc.writable)
testing.expectEqual(false, desc.enumerable)
testing.expectEqual(false, desc.configurable)
testing.expectEqual(false, Object.keys(window).includes('Image'))
testing.expectEqual(false, Object.keys(window).includes('HTMLImageElement'))
</script>
+20
View File
@@ -308,3 +308,23 @@
testing.expectEqual(true, video.paused);
}
</script>
<script id="legacy_factory_function">
// https://webidl.spec.whatwg.org/#legacy-factory-functions
testing.expectEqual(true, Audio !== HTMLAudioElement)
testing.expectEqual('Audio', Audio.name)
testing.expectEqual(0, Audio.length)
testing.expectEqual(HTMLAudioElement.prototype, Audio.prototype)
testing.expectEqual(HTMLAudioElement, new Audio().constructor)
testing.expectEqual('audio', new Audio().localName)
testing.expectError('TypeError', () => new HTMLAudioElement())
testing.expectError('TypeError', () => Audio())
const desc = Object.getOwnPropertyDescriptor(Audio, 'prototype')
testing.expectEqual(false, desc.writable)
testing.expectEqual(false, desc.enumerable)
testing.expectEqual(false, desc.configurable)
testing.expectEqual(false, Object.keys(window).includes('Audio'))
testing.expectEqual(false, Object.keys(window).includes('HTMLAudioElement'))
</script>
@@ -191,3 +191,89 @@
$('#opt4').disabled = false
testing.expectEqual(false, $('#opt4').disabled)
</script>
<script id="constructor">
{
const opt = new Option()
testing.expectEqual('OPTION', opt.tagName)
testing.expectEqual(true, opt instanceof HTMLOptionElement)
testing.expectEqual(0, opt.childNodes.length)
testing.expectEqual(false, opt.hasAttribute('value'))
testing.expectEqual(false, opt.defaultSelected)
testing.expectEqual(false, opt.selected)
}
{
const opt = new Option('Label', 'v1')
testing.expectEqual('Label', opt.text)
testing.expectEqual(1, opt.childNodes.length)
testing.expectEqual('v1', opt.getAttribute('value'))
testing.expectEqual('v1', opt.value)
}
{
// An empty text appends no Text node; null is stringified.
testing.expectEqual(0, new Option('').childNodes.length)
testing.expectEqual('null', new Option(null).text)
testing.expectEqual('', new Option('x', '').value)
const undef = new Option(undefined, undefined)
testing.expectEqual(0, undef.childNodes.length)
testing.expectEqual(false, undef.hasAttribute('value'))
const falsy = new Option(false, false)
testing.expectEqual('false', falsy.text)
testing.expectEqual('false', falsy.getAttribute('value'))
}
{
// WebIDL boolean conversion
const falsy = new Option('text', 'value', 0, '')
testing.expectEqual(false, falsy.hasAttribute('selected'))
testing.expectEqual(false, falsy.selected)
const truthy = new Option('text', 'value', {}, 1)
testing.expectEqual(true, truthy.hasAttribute('selected'))
testing.expectEqual(true, truthy.selected)
}
{
// defaultSelected sets the attribute, selectedness comes from `selected` only.
const opt = new Option('a', 'b', true)
testing.expectEqual(true, opt.defaultSelected)
testing.expectEqual('', opt.getAttribute('selected'))
testing.expectEqual(false, opt.selected)
const opt2 = new Option('a', 'b', false, true)
testing.expectEqual(false, opt2.defaultSelected)
testing.expectEqual(true, opt2.selected)
}
{
const select = document.createElement('select')
select.add(new Option('One', '1'))
select.add(new Option('Two', '2', false, true))
testing.expectEqual('2', select.value)
testing.expectEqual(1, select.selectedIndex)
}
</script>
<script id="legacy_factory_function">
// https://webidl.spec.whatwg.org/#legacy-factory-functions
testing.expectEqual(true, Option !== HTMLOptionElement)
testing.expectEqual('Option', Option.name)
testing.expectEqual(0, Option.length)
testing.expectEqual(HTMLOptionElement.prototype, Option.prototype)
testing.expectEqual(HTMLOptionElement, new Option().constructor)
testing.expectEqual('option', new Option().localName)
testing.expectError('TypeError', () => new HTMLOptionElement())
testing.expectError('TypeError', () => Option())
const desc = Object.getOwnPropertyDescriptor(Option, 'prototype')
testing.expectEqual(false, desc.writable)
testing.expectEqual(false, desc.enumerable)
testing.expectEqual(false, desc.configurable)
testing.expectEqual(false, Object.keys(window).includes('Option'))
testing.expectEqual(false, Object.keys(window).includes('HTMLOptionElement'))
</script>
@@ -275,6 +275,63 @@
}
</script>
<script id="options_indexed_setter">
{
const sel = document.createElement('select')
const opts = sel.options
const make = (v) => { const o = document.createElement('option'); o.value = v; return o }
// past the end pads with blank options, then appends
opts[2] = make('c')
testing.expectEqual(3, opts.length)
testing.expectEqual(['', '', 'c'], Array.from(opts, (o) => o.value))
// in range replaces in place, including inside an optgroup (Firefox;
// Chrome removes it and add()s the new one before the next option)
opts[0] = make('a')
testing.expectEqual(['a', '', 'c'], Array.from(opts, (o) => o.value))
const group = document.createElement('optgroup')
group.appendChild(opts[1])
sel.appendChild(group)
opts[2] = make('b')
testing.expectEqual(group, opts[2].parentNode)
testing.expectEqual(['a', 'c', 'b'], Array.from(opts, (o) => o.value))
// null removes; null past the end is a no-op
opts[1] = null
testing.expectEqual(['a', 'b'], Array.from(opts, (o) => o.value))
opts[10] = null
testing.expectEqual(2, opts.length)
// only options (or null) are accepted
testing.expectError('TypeError', () => { opts[0] = document.createElement('div') })
testing.expectEqual(2, opts.length)
const desc = Object.getOwnPropertyDescriptor(opts, 0)
testing.expectEqual(true, desc.writable)
testing.expectEqual(true, desc.enumerable)
}
</script>
<script id="options_indexed_setter_padding">
{
const sel = document.createElement('select')
const make = (v) => { const o = document.createElement('option'); o.value = v; return o }
const records = []
new MutationObserver((r) => records.push(...r)).observe(sel, { childList: true })
// The spec pads with one DocumentFragment (we do, so 2 records); Chrome
// appends each blank option on its own. Only what was added is common.
sel.options[3] = make('d')
testing.expectEqual(4, sel.options.length)
testing.onload(() => {
const added = records.flatMap((r) => Array.from(r.addedNodes, (n) => n.value))
testing.expectEqual(['', '', '', 'd'], added)
})
}
</script>
<script id="select_add">
{
const sel = document.createElement('select')
+102
View File
@@ -0,0 +1,102 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<body>
<button id="outside">outside</button>
<div id="container" inert>
<input id="inner" type="text">
<span id="text">hidden</span>
</div>
<div id="selectable">
<p>shown</p>
<span inert>hidden</span>
<math inert><mi>math</mi></math>
</div>
</body>
<script id="inert_property">
{
testing.expectEqual(false, $('#outside').inert);
testing.expectEqual(true, $('#container').inert);
// inherited inertness isn't reflected
testing.expectEqual(false, $('#inner').inert);
const outside = $('#outside');
outside.inert = true;
testing.expectEqual('', outside.getAttribute('inert'));
outside.inert = false;
testing.expectEqual(false, outside.hasAttribute('inert'));
}
</script>
<script id="inert_unfocusable">
{
const outside = $('#outside');
outside.focus();
testing.expectEqual(outside, document.activeElement);
$('#inner').focus();
testing.expectEqual(outside, document.activeElement);
$('#container').inert = false;
$('#inner').focus();
testing.expectEqual($('#inner'), document.activeElement);
$('#container').inert = true;
}
</script>
<script id="inert_selection">
{
const selection = getSelection();
selection.selectAllChildren($('#selectable'));
// inert only applies to HTML elements, so the MathML text stays
testing.expectEqual('shown math', selection.toString().trim().replace(/\s+/g, ' '));
selection.removeAllRanges();
}
</script>
<script id="inert_focus_fixup" type=module>
const state = await testing.async();
const nextRendering = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const outside = $('#outside');
outside.focus();
const events = [];
outside.addEventListener('blur', () => events.push('blur'));
outside.addEventListener('focusout', () => events.push('focusout'));
outside.inert = true;
// like browsers, the fixup waits for the next rendering update
const syncActive = document.activeElement;
await nextRendering();
const fixedActive = document.activeElement;
outside.inert = false;
const host = document.createElement('div');
host.innerHTML = '<button id="slotted" slot="s">slotted</button>';
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: 'open' });
const slot = document.createElement('slot');
slot.name = 's';
slot.inert = true;
shadow.appendChild(slot);
const shadowButton = document.createElement('button');
shadow.appendChild(shadowButton);
$('#slotted').focus();
const slottedActive = document.activeElement;
shadowButton.focus();
const shadowActive = document.activeElement;
host.inert = true;
await nextRendering();
const hostFixedActive = document.activeElement;
host.remove();
state.resolve();
await state.done(() => {
testing.expectEqual(outside, syncActive);
testing.expectEqual(document.body, fixedActive);
testing.expectEqual(['blur', 'focusout'], events);
testing.expectEqual(document.body, slottedActive);
testing.expectEqual(host, shadowActive);
testing.expectEqual(document.body, hostFixedActive);
});
</script>
+64
View File
@@ -600,3 +600,67 @@
testing.expectEqual(targetY, window.scrollY);
}
</script>
<div id="clampBox" style="width: 100px; height: 100px; overflow: scroll;">
<div style="width: 500px; height: 500px;">content</div>
</div>
<script id="scrollClampsToExtent">
{
// The limit is scrollHeight - clientHeight, whatever those measure: a real
// browser reserves scrollbar space in clientHeight and lands a few px lower.
const box = $('#clampBox');
const maxTop = box.scrollHeight - box.clientHeight;
const maxLeft = box.scrollWidth - box.clientWidth;
testing.expectTrue(maxTop > 0);
box.scrollTop = 9999;
box.scrollLeft = 9999;
testing.expectEqual(maxTop, box.scrollTop);
testing.expectEqual(maxLeft, box.scrollLeft);
box.scrollTo(10, 10);
box.scrollBy(9999, 9999);
testing.expectEqual(maxTop, box.scrollTop);
testing.expectEqual(maxLeft, box.scrollLeft);
box.scrollBy(-9999, -9999);
testing.expectEqual(0, box.scrollTop);
testing.expectEqual(0, box.scrollLeft);
}
</script>
<style>
.sheetBox { height: 100px; overflow: auto; }
#sheetAuto { height: auto; }
.sheetVh { height: 10vh; }
</style>
<div id="sheetBox" class="sheetBox"><div style="height: 500px;">content</div></div>
<div id="sheetAuto" class="sheetBox"><div style="height: 500px;">content</div></div>
<div id="inlineAuto" class="sheetBox" style="height: 50%"><div style="height: 500px;">content</div></div>
<div id="sheetVh" class="sheetVh"></div>
<script id="sheetSizedBox">
{
// A sheet size is as explicit as an inline one
const box = $('#sheetBox');
testing.expectEqual(100, box.clientHeight);
testing.expectEqual(500, box.scrollHeight);
testing.expectEqual('100px', getComputedStyle(box).height);
box.scrollTop = 9999;
testing.expectEqual(400, box.scrollTop);
// A stronger rule's `auto` wins, leaving no size to clamp to. Chrome
// clamps to 0; we can't tell auto from max-height, so we don't clamp.
const auto = $('#sheetAuto');
testing.expectEqual(500, auto.clientHeight);
auto.scrollTop = 9999;
testing.expectEqual(9999, auto.scrollTop);
// So does an unresolvable inline value
testing.expectEqual(500, $('#inlineAuto').clientHeight);
testing.expectEqual(window.innerHeight / 10, $('#sheetVh').clientHeight);
}
</script>
+19
View File
@@ -113,6 +113,25 @@
}
</script>
<script id="overscrollBehaviorShorthand">
{
const div = document.createElement('div');
div.style.cssText = 'overscroll-behavior: contain auto';
testing.expectEqual('contain', div.style.overscrollBehaviorX);
testing.expectEqual('auto', div.style.overscrollBehaviorY);
testing.expectEqual('contain auto', div.style.overscrollBehavior);
testing.expectEqual(2, div.style.length);
div.style.overscrollBehaviorY = 'contain';
testing.expectEqual('contain', div.style.overscrollBehavior);
testing.expectEqual('overscroll-behavior: contain;', div.style.cssText);
div.style.setProperty('overscroll-behavior', 'none');
testing.expectEqual('none', div.style.overscrollBehaviorX);
testing.expectEqual('none', div.style.overscrollBehaviorY);
}
</script>
<script id="overflowShorthand">
{
const div = document.createElement('div');
+8
View File
@@ -0,0 +1,8 @@
<!DOCTYPE html>
<!--
Driven by the "a failed preload is consumed, not refetched" test in
ScriptManager.zig, which blocks these URLs while the hints fetch, then
unblocks them and inserts the matching <script> elements.
-->
<link rel="preload" as="script" href="preload_failed.js" onerror="window.classic_hint_error = true">
<link rel="modulepreload" href="preload_failed_module.js" onerror="window.module_hint_error = true">
+1
View File
@@ -0,0 +1 @@
window.failed_classic_ran = true;
+1
View File
@@ -0,0 +1 @@
window.failed_module_ran = true;
+1 -1
View File
@@ -36,7 +36,7 @@
testing.expectEqual(200, req.status);
testing.expectEqual('OK', req.statusText);
testing.expectEqual('text/html; charset=utf-8', req.getResponseHeader('Content-Type'));
testing.expectEqual('content-length: 100\r\nContent-Type: text/html; charset=utf-8\r\n', req.getAllResponseHeaders());
testing.expectEqual('content-length: 100\r\ncontent-type: text/html; charset=utf-8\r\n', req.getAllResponseHeaders());
testing.expectEqual(100, req.responseText.length);
testing.expectEqual(req.responseText.length, req.response.length);
testing.expectEqual('http://127.0.0.1:9582/xhr', req.responseURL);
+603
View File
@@ -0,0 +1,603 @@
<!DOCTYPE html>
<script src="testing.js"></script>
<body></body>
<script id=constructor>
{
testing.expectEqual('function', typeof Sanitizer);
testing.expectEqual(true, new Sanitizer() instanceof Sanitizer);
testing.expectEqual(true, new Sanitizer({}) instanceof Sanitizer);
testing.expectEqual(true, new Sanitizer(null) instanceof Sanitizer);
testing.expectEqual(true, new Sanitizer(undefined) instanceof Sanitizer);
testing.expectEqual(true, new Sanitizer('default') instanceof Sanitizer);
testing.expectEqual(true, new Sanitizer({unknown: [1, 2]}) instanceof Sanitizer);
testing.expectError('TypeError', () => new Sanitizer('nope'));
}
</script>
<script id=emptyConfig>
{
// Neither half of a pair given: canonicalization fills in the empty
// remove-lists, which is what "allow everything" looks like.
testing.expectEqual({
removeElements: [],
removeAttributes: [],
removeProcessingInstructions: [],
comments: true,
javascriptURLs: true,
}, new Sanitizer({}).get());
}
</script>
<script id=canonicalization>
{
const get = (config) => new Sanitizer(config).get();
testing.expectEqual([{name: 'div', namespace: 'http://www.w3.org/1999/xhtml', removeAttributes: []}],
get({elements: ['div']}).elements);
testing.expectEqual([{name: 'b', namespace: 'http://www.w3.org/1999/xhtml'}],
get({removeElements: [{name: 'b'}]}).removeElements);
testing.expectEqual([{name: 'b', namespace: null}],
get({removeElements: [{name: 'b', namespace: null}]}).removeElements);
// An empty namespace is the null namespace.
testing.expectEqual([{name: 'b', namespace: null}],
get({removeElements: [{name: 'b', namespace: ''}]}).removeElements);
// Attributes default to the null namespace, elements to XHTML.
testing.expectEqual([{name: 'href', namespace: null}], get({attributes: ['href']}).attributes);
testing.expectEqual([{target: 'xml-stylesheet'}],
get({processingInstructions: ['xml-stylesheet']}).processingInstructions);
}
</script>
<script id=sorting>
{
// Namespace first, with null below every URI; then local name.
const config = new Sanitizer({attributes: [
{name: 'b', namespace: 'http://example.org/'},
{name: 'z'},
{name: 'a', namespace: 'http://example.org/'},
{name: 'a'},
]}).get();
testing.expectEqual([
{name: 'a', namespace: null},
{name: 'z', namespace: null},
{name: 'a', namespace: 'http://example.org/'},
{name: 'b', namespace: 'http://example.org/'},
], config.attributes);
}
</script>
<script id=booleanDefaults>
{
testing.expectEqual(false, new Sanitizer().get().comments);
testing.expectEqual(true, new Sanitizer({}).get().comments);
testing.expectEqual(false, new Sanitizer({comments: false}).get().comments);
testing.expectEqual(false, new Sanitizer().get().dataAttributes);
testing.expectEqual(true, new Sanitizer({attributes: []}).get().dataAttributes);
// dataAttributes only exists alongside an attribute allow-list.
testing.expectEqual(false, 'dataAttributes' in new Sanitizer({}).get());
testing.expectEqual(false, 'dataAttributes' in new Sanitizer({removeAttributes: []}).get());
// Web IDL: an explicit null is ToBoolean(null) = false, while an explicit
// undefined leaves the member absent and so takes the default.
testing.expectEqual(false, new Sanitizer({comments: null}).get().comments);
testing.expectEqual(true, new Sanitizer({comments: 'abc'}).get().comments);
// The setters report whether they changed anything.
const s = new Sanitizer();
testing.expectEqual(true, s.setComments(true));
testing.expectEqual(false, s.setComments(true));
testing.expectEqual(true, s.get().comments);
testing.expectEqual(true, s.setDataAttributes(true));
testing.expectEqual(false, s.setDataAttributes(true));
testing.expectEqual(true, s.get().dataAttributes);
// Without an attribute allow-list there is no dataAttributes to set.
testing.expectEqual(false, new Sanitizer({removeAttributes: []}).setDataAttributes(true));
}
</script>
<script id=javascriptUrls>
{
testing.expectEqual(false, new Sanitizer().get().javascriptURLs);
testing.expectEqual(true, new Sanitizer({}).get().javascriptURLs);
testing.expectEqual(false, new Sanitizer({javascriptURLs: false}).get().javascriptURLs);
testing.expectEqual(false, new Sanitizer({javascriptURLs: null}).get().javascriptURLs);
testing.expectEqual(true, new Sanitizer({javascriptURLs: undefined}).get().javascriptURLs);
const s = new Sanitizer();
testing.expectEqual(true, s.setJavascriptURLs(true));
testing.expectEqual(false, s.setJavascriptURLs(true));
testing.expectEqual(true, s.get().javascriptURLs);
// removeUnsafe turns them back off, and reports that it changed something.
testing.expectEqual(true, s.removeUnsafe());
testing.expectEqual(false, s.get().javascriptURLs);
}
{
// Allowing every data attribute subsumes any named individually, and the
// config has to stay valid.
const s = new Sanitizer({attributes: ['data-x', 'id'], elements: [{name: 'div', attributes: ['data-y']}]});
testing.expectEqual(true, s.setDataAttributes(true));
testing.expectEqual([{name: 'id', namespace: null}], s.get().attributes);
testing.expectEqual([], s.get().elements[0].attributes);
}
</script>
<script id=invalidConfigs>
{
// Each pair is mutually exclusive.
testing.expectError('TypeError', () => new Sanitizer({elements: [], removeElements: []}));
testing.expectError('TypeError', () => new Sanitizer({attributes: [], removeAttributes: []}));
// No duplicates, whichever spelling they arrive in.
testing.expectError('TypeError', () => new Sanitizer({elements: ['abc', 'abc']}));
testing.expectError('TypeError', () => new Sanitizer({elements: ['abc', {name: 'abc'}]}));
testing.expectError('TypeError', () => new Sanitizer({attributes: ['abc', {name: 'abc', namespace: null}]}));
// <html>, <svg> and <math> can never be replaced with their children.
testing.expectError('TypeError', () => new Sanitizer({replaceWithChildrenElements: ['html']}));
testing.expectError('TypeError', () => new Sanitizer({
replaceWithChildrenElements: [{name: 'svg', namespace: 'http://www.w3.org/2000/svg'}],
}));
// ... but in another namespace it is just an element.
testing.expectEqual(1, new Sanitizer({replaceWithChildrenElements: ['svg']}).get().replaceWithChildrenElements.length);
testing.expectError('TypeError', () => new Sanitizer({elements: ['p'], replaceWithChildrenElements: ['p']}));
testing.expectError('TypeError', () => new Sanitizer({removeElements: ['p'], replaceWithChildrenElements: ['p']}));
// A per-element list may not restate a global one.
testing.expectError('TypeError', () => new Sanitizer({
attributes: ['id'],
elements: [{name: 'div', attributes: ['id']}],
}));
// A per-element removeAttributes must be a subset of the global allow-list.
testing.expectError('TypeError', () => new Sanitizer({
attributes: ['class'],
elements: [{name: 'div', removeAttributes: ['title']}],
}));
testing.expectError('TypeError', () => new Sanitizer({
attributes: ['data-bar'],
dataAttributes: true,
}));
testing.expectError('TypeError', () => new Sanitizer({removeAttributes: [], dataAttributes: false}));
testing.expectError('TypeError', () => new Sanitizer({
removeAttributes: [],
elements: [{name: 'div', attributes: [], removeAttributes: []}],
}));
testing.expectError('TypeError', () => new Sanitizer({processingInstructions: [], removeProcessingInstructions: []}));
testing.expectError('TypeError', () => new Sanitizer({removeProcessingInstructions: ['x', {target: 'x'}]}));
}
</script>
<script id=perElementAttributes>
{
{
const s = new Sanitizer({elements: [{name: 'div', attributes: ['href', 'src']}]});
testing.expectEqual(true, 'attributes' in s.get().elements[0]);
testing.expectEqual(false, 'removeAttributes' in s.get().elements[0]);
testing.expectEqual(2, s.get().elements[0].attributes.length);
// allowElement overwrites the per-element list rather than merging.
s.allowElement({name: 'div', namespace: 'http://www.w3.org/1999/xhtml', attributes: ['class']});
testing.expectEqual([{name: 'class', namespace: null}], s.get().elements[0].attributes);
}
{
// An element with neither list still reports an empty remove-list.
const s = new Sanitizer({elements: ['div']});
testing.expectEqual([], s.get().elements[0].removeAttributes);
testing.expectEqual(false, 'attributes' in s.get().elements[0]);
}
}
</script>
<script id=elementModifiers>
{
const s = new Sanitizer({elements: ['div', 'p']});
testing.expectEqual(2, s.get().elements.length);
testing.expectEqual(true, s.allowElement('bla'));
testing.expectEqual(false, s.allowElement('bla'));
testing.expectEqual(3, s.get().elements.length);
testing.expectEqual(true, s.removeElement({name: 'div'}));
testing.expectEqual(2, s.get().elements.length);
testing.expectEqual(true, s.replaceElementWithChildren({name: 'p', namespace: 'http://www.w3.org/1999/xhtml'}));
testing.expectEqual([{name: 'bla', namespace: 'http://www.w3.org/1999/xhtml', removeAttributes: []}],
s.get().elements);
testing.expectEqual([{name: 'p', namespace: 'http://www.w3.org/1999/xhtml'}],
s.get().replaceWithChildrenElements);
testing.expectEqual(false, s.replaceElementWithChildren('html'));
}
{
// With a remove-list the same calls flip meaning.
const s = new Sanitizer({removeElements: ['div', 'p']});
testing.expectEqual(true, s.removeElement('bla'));
testing.expectEqual(3, s.get().removeElements.length);
testing.expectEqual(true, s.allowElement('p'));
testing.expectEqual(2, s.get().removeElements.length);
// A remove-list config has nowhere to put per-element attributes.
testing.expectEqual(false, s.allowElement({name: 'div', attributes: ['id']}));
}
{
// An element with neither attribute list is the same as one with an empty
// removeAttributes, so re-allowing it either way is not a change.
const s = new Sanitizer({elements: ['div', {name: 'p', removeAttributes: []}]});
testing.expectEqual(false, s.allowElement({name: 'div', removeAttributes: []}));
testing.expectEqual(false, s.allowElement('p'));
testing.expectEqual(true, s.allowElement('span'));
testing.expectEqual(false, s.allowElement({name: 'span', removeAttributes: []}));
testing.expectEqual(true, s.allowElement({name: 'span', attributes: []}));
}
</script>
<script id=attributeModifiers>
{
const s = new Sanitizer({attributes: ['href', 'src']});
testing.expectEqual(true, s.allowAttribute('id'));
testing.expectEqual(false, s.allowAttribute('id'));
testing.expectEqual(3, s.get().attributes.length);
// A different namespace is a different attribute.
testing.expectEqual(false, s.removeAttribute({name: 'href', namespace: 'http://example.org/'}));
testing.expectEqual(true, s.removeAttribute({name: 'href'}));
testing.expectEqual(true, s.removeAttribute({name: 'src', namespace: null}));
testing.expectEqual([{name: 'id', namespace: null}], s.get().attributes);
}
{
const s = new Sanitizer({removeAttributes: ['href', 'src']});
testing.expectEqual(true, s.removeAttribute('id'));
testing.expectEqual(3, s.get().removeAttributes.length);
testing.expectEqual(false, s.allowAttribute({name: 'href', namespace: 'http://example.org/'}));
testing.expectEqual(true, s.allowAttribute('href'));
testing.expectEqual(true, s.allowAttribute({name: 'src', namespace: null}));
testing.expectEqual([{name: 'id', namespace: null}], s.get().removeAttributes);
}
{
// A global allow subsumes the per-element lists.
const s = new Sanitizer({
attributes: ['id', 'title'],
elements: [{name: 'div', attributes: ['class', 'dir'], removeAttributes: ['title']}],
});
testing.expectEqual(true, s.removeAttribute('dir'));
testing.expectEqual([{name: 'class', namespace: null}], s.get().elements[0].attributes);
testing.expectEqual(true, s.removeAttribute('title'));
testing.expectEqual([{name: 'id', namespace: null}], s.get().attributes);
testing.expectEqual([], s.get().elements[0].removeAttributes);
}
</script>
<script id=processingInstructionModifiers>
{
const s = new Sanitizer({processingInstructions: ['target-1', 'target-2']});
testing.expectEqual(true, s.allowProcessingInstruction('target-3'));
testing.expectEqual(false, s.allowProcessingInstruction('target-3'));
testing.expectEqual(false, s.removeProcessingInstruction({target: 'target-4'}));
testing.expectEqual(true, s.removeProcessingInstruction({target: 'target-1'}));
testing.expectEqual(true, s.removeProcessingInstruction({target: 'target-2'}));
testing.expectEqual([{target: 'target-3'}], s.get().processingInstructions);
}
</script>
<script id=removeUnsafe>
{
const s = new Sanitizer({});
testing.expectEqual(true, s.removeUnsafe());
testing.expectEqual(false, s.removeUnsafe());
const config = s.get();
testing.expectEqual(false, 'elements' in config);
testing.expectEqual(false, 'attributes' in config);
testing.expectEqual([
{name: 'base', namespace: 'http://www.w3.org/1999/xhtml'},
{name: 'embed', namespace: 'http://www.w3.org/1999/xhtml'},
{name: 'frame', namespace: 'http://www.w3.org/1999/xhtml'},
{name: 'iframe', namespace: 'http://www.w3.org/1999/xhtml'},
{name: 'object', namespace: 'http://www.w3.org/1999/xhtml'},
{name: 'script', namespace: 'http://www.w3.org/1999/xhtml'},
{name: 'script', namespace: 'http://www.w3.org/2000/svg'},
{name: 'use', namespace: 'http://www.w3.org/2000/svg'},
], config.removeElements);
const names = config.removeAttributes.map((a) => a.name);
testing.expectEqual(true, names.length > 0);
for (const attribute of config.removeAttributes) {
testing.expectEqual(null, attribute.namespace);
testing.expectEqual(true, attribute.name.startsWith('on'));
}
testing.expectEqual(true, names.every((n, i) => i === 0 || names[i - 1] < n));
testing.expectEqual(true, names.includes('onclick'));
}
{
// Nothing in the default config is unsafe, so removeUnsafe is a no-op on it.
const before = new Sanitizer('default').get();
const after = new Sanitizer('default');
testing.expectEqual(false, after.removeUnsafe());
testing.expectEqual(true, before.elements.length > 0);
testing.expectEqual(before.elements.length, after.get().elements.length);
testing.expectEqual(before.attributes.length, after.get().attributes.length);
testing.expectEqual(false, 'removeElements' in after.get());
testing.expectEqual(false, 'removeAttributes' in after.get());
}
{
// lightpanda-specific: our event handler list is HTML's merged with the
// handlers we compile ourselves, so neither side can go missing. Another
// engine ships only its own set and is expected to fail this.
const s = new Sanitizer({});
s.removeUnsafe();
const names = s.get().removeAttributes.map((a) => a.name);
for (const name of ['onunload', 'onmouseenter', 'onbeforematch', 'onfullscreenchange']) {
testing.expectEqual(true, names.includes(name), {script_id: 'removeUnsafe'});
}
}
</script>
<script id=defaultConfig>
{
const config = new Sanitizer().get();
testing.expectEqual(121, config.elements.length);
testing.expectEqual(58, config.attributes.length);
testing.expectEqual(false, config.comments);
testing.expectEqual(false, config.dataAttributes);
// Sorted by namespace URI, which puts MathML (1998) first and SVG (2000) last.
testing.expectEqual({name: 'math', namespace: 'http://www.w3.org/1998/Math/MathML', attributes: []},
config.elements[0]);
testing.expectEqual('http://www.w3.org/2000/svg', config.elements[config.elements.length - 1].namespace);
// Nothing that runs script is in there.
const names = config.elements.map((e) => e.name);
for (const unsafe of ['script', 'iframe', 'object', 'embed', 'frame']) {
testing.expectEqual(false, names.includes(unsafe));
}
testing.expectEqual(false, config.attributes.some((a) => a.name.startsWith('on')));
testing.expectEqual([], config.processingInstructions);
testing.expectEqual(false, config.javascriptURLs);
}
</script>
<script id=setHTML>
{
const div = document.createElement('div');
div.setHTML('<p id=a onclick="x()" title=t data-x=1>hi<script>boom()<\/script><!-- c --></p><iframe></iframe>');
testing.expectEqual('<p title="t">hi</p>', div.innerHTML);
// an explicit "default" is the same thing
div.setHTML('<b onclick="x()">b</b>', {sanitizer: 'default'});
testing.expectEqual('<b>b</b>', div.innerHTML);
testing.expectError('TypeError', () => div.setHTML('x', {sanitizer: 'nope'}));
testing.expectError('TypeError', () => div.setHTML('x', {sanitizer: {elements: [], removeElements: []}}));
div.setHTML('');
testing.expectEqual('', div.innerHTML);
}
</script>
<script id=setHTMLConfig>
{
const div = document.createElement('div');
// A dictionary on a safe method defaults to not keeping comments or data-*.
div.setHTML('<p data-x=1 id=i>a<!--c--></p>', {sanitizer: {attributes: ['id']}});
testing.expectEqual('<p id="i">a</p>', div.innerHTML);
// ... but unsafe methods, like the constructor, default to keeping them.
div.setHTMLUnsafe('<p data-x=1 id=i>a<!--c--></p>', {sanitizer: {attributes: ['id']}});
testing.expectEqual('<p data-x="1" id="i">a<!--c--></p>', div.innerHTML);
div.setHTML('<div><p>a<b>b</b></p><i>i</i></div>', {sanitizer: {elements: ['div', 'p']}});
testing.expectEqual('<div><p>a</p></div>', div.innerHTML);
div.setHTML('<div><p>a<b>b<i>i</i></b>c</p></div>', {sanitizer: {replaceWithChildrenElements: ['b', 'p']}});
testing.expectEqual('<div>ab<i>i</i>c</div>', div.innerHTML);
div.setHTML('<div><span>s</span><em>e</em></div>', {sanitizer: {removeElements: ['span']}});
testing.expectEqual('<div><em>e</em></div>', div.innerHTML);
// per-element attribute lists
div.setHTML('<p id=1 title=2>a</p><em id=3 title=4>b</em>', {sanitizer: {
elements: [{name: 'p', attributes: ['id']}, {name: 'em', removeAttributes: ['title']}],
attributes: ['title'],
}});
testing.expectEqual('<p id="1" title="2">a</p><em>b</em>', div.innerHTML);
// an element's removeAttributes must be a subset of the global attributes
testing.expectError('TypeError', () => div.setHTML('x', {sanitizer: {
elements: [{name: 'em', removeAttributes: ['id']}],
attributes: ['title'],
}}));
// A safe method still removes unsafe markup the config allows.
div.setHTML('<script>x()<\/script><b onclick=x()>b</b>', {sanitizer: {elements: ['script', 'b'], attributes: ['onclick']}});
testing.expectEqual('<b>b</b>', div.innerHTML);
}
</script>
<script id=setHTMLUnsafe>
{
const div = document.createElement('div');
div.setHTMLUnsafe('<b onclick="x()">b</b><script>x()<\/script><!--c-->');
testing.expectEqual('<b onclick="x()">b</b><script>x()<\/script><!--c-->', div.innerHTML);
// The sanitizer's own configuration is used as-is.
const sanitizer = new Sanitizer({removeElements: ['i']});
const before = sanitizer.get();
div.setHTMLUnsafe('<b onclick="x()">b</b><i>i</i>', {sanitizer});
testing.expectEqual('<b onclick="x()">b</b>', div.innerHTML);
// A safe method must not remove unsafe from the caller's Sanitizer.
div.setHTML('<b onclick="x()">b</b><i>i</i>', {sanitizer});
testing.expectEqual('<b>b</b>', div.innerHTML);
testing.expectEqual(before, sanitizer.get());
// The default configuration on an unsafe method is still the default one
div.setHTMLUnsafe('<b onclick="x()">b</b><script><\/script>', {sanitizer: 'default'});
testing.expectEqual('<b>b</b>', div.innerHTML);
}
</script>
<script id=textMerge>
{
// Text left adjacent by a removal is merged, as it would be had the node
// never been parsed.
const div = document.createElement('div');
div.setHTML('a <!-- c --> b');
testing.expectEqual(1, div.childNodes.length);
testing.expectEqual('a b', div.firstChild.data);
div.setHTML('<p>x<script>s<\/script>y<!--c-->z<b>b</b></p>');
testing.expectEqual(2, div.firstChild.childNodes.length);
testing.expectEqual('xyz', div.firstChild.firstChild.data);
// replaced elements merge at both edges
div.setHTML('<p>x<span>y<i>i</i>z</span>w</p>', {sanitizer: {replaceWithChildrenElements: ['span']}});
const p = div.firstChild;
testing.expectEqual(3, p.childNodes.length);
testing.expectEqual('xy', p.childNodes[0].data);
testing.expectEqual('zw', p.childNodes[2].data);
div.setHTML('<p>x<b><i>y</i></b>z</p>', {sanitizer: {replaceWithChildrenElements: ['b', 'i']}});
testing.expectEqual(1, div.firstChild.childNodes.length);
testing.expectEqual('xyz', div.firstChild.firstChild.data);
// nothing removed: nothing touched
div.setHTMLUnsafe('<p>x<!--c-->y</p>');
testing.expectEqual(3, div.firstChild.childNodes.length);
// in the other trees too
const doc = Document.parseHTML('a<!--c-->b<template>c<!--c-->d</template>', {sanitizer: {elements: ['html', 'head', 'body', 'template']}});
testing.expectEqual(2, doc.body.childNodes.length);
testing.expectEqual('ab', doc.body.firstChild.data);
testing.expectEqual(1, doc.body.lastChild.content.childNodes.length);
testing.expectEqual('cd', doc.body.lastChild.content.firstChild.data);
}
</script>
<script id=scriptContext>
{
const script = document.createElement('script');
script.textContent = 'untouched';
script.setHTML('<b>b</b>');
testing.expectEqual('untouched', script.textContent);
script.setHTMLUnsafe('<b>b</b>');
testing.expectEqual('<b>b</b>', script.innerHTML);
}
</script>
<script id=javascriptURLs>
{
const div = document.createElement('div');
// safe: always removed, whatever the config says
div.setHTML('<a href="javascript:x()">a</a><a href="https://x/">b</a>', {sanitizer: {javascriptURLs: true}});
testing.expectEqual('<a>a</a><a href="https://x/">b</a>', div.innerHTML);
// unsafe: removed only when the config says so
div.setHTMLUnsafe('<a href="javascript:x()">a</a>', {sanitizer: {}});
testing.expectEqual('<a href="javascript:x()">a</a>', div.innerHTML);
const off = {sanitizer: {javascriptURLs: false}};
for (const url of ['javascript:x()', ' JaVaScRiPt:x()', '\x01java\nscript:x()', 'java\tscript:x()']) {
const a = document.createElement('a');
a.setAttribute('href', url);
div.setHTMLUnsafe(a.outerHTML, off);
testing.expectEqual(null, div.firstChild.getAttribute('href'), {script_id: 'javascriptURLs'});
}
div.setHTMLUnsafe('<a href="javascriptx:x()">a</a><a href="xjavascript:x()">b</a><p href="javascript:x()"></p>', off);
testing.expectEqual('<a href="javascriptx:x()">a</a><a href="xjavascript:x()">b</a><p href="javascript:x()"></p>', div.innerHTML);
div.setHTMLUnsafe('<area href="javascript:x()"><form action="javascript:x()"><button formaction="javascript:x()"></button></form>', off);
testing.expectEqual('<area><form><button></button></form>', div.innerHTML);
div.setHTMLUnsafe('<svg><a href="javascript:x()"></a><a xlink:href="javascript:x()"></a></svg>', off);
testing.expectEqual('<svg><a></a><a></a></svg>', div.innerHTML);
div.setHTMLUnsafe('<math><mi href="javascript:x()"></mi></math>', off);
testing.expectEqual('<math><mi></mi></math>', div.innerHTML);
div.setHTMLUnsafe('<svg><animate attributeName="href"></animate><set attributeName="xlink:href"></set><animate attributeName="x"></animate></svg>', off);
testing.expectEqual('<svg><animate></animate><set></set><animate attributeName="x"></animate></svg>', div.innerHTML);
}
</script>
<script id=nestedTrees>
{
const div = document.createElement('div');
div.setHTML('<template><b onclick=x()>b</b><script><\/script></template>', {sanitizer: {elements: ['template', 'b', 'script']}});
testing.expectEqual('<b>b</b>', div.firstChild.innerHTML);
const host = document.createElement('div');
host.setHTMLUnsafe('<div><template shadowrootmode=open><i onclick=x()>i</i><script><\/script></template></div>', {sanitizer: {removeElements: ['script'], removeAttributes: ['onclick']}});
testing.expectEqual('<i>i</i>', host.firstChild.shadowRoot.innerHTML);
// setHTML on a template sets its contents
const template = document.createElement('template');
template.setHTML('<b onclick=x()>b</b>');
testing.expectEqual(0, template.childNodes.length);
testing.expectEqual('<b>b</b>', template.content.firstChild.outerHTML);
}
</script>
<script id=shadowRoot>
{
const host = document.createElement('div');
const root = host.attachShadow({mode: 'open'});
root.setHTML('<b onclick=x()>b</b><script><\/script>');
testing.expectEqual('<b>b</b>', root.innerHTML);
root.setHTMLUnsafe('<b onclick=x()>b</b>');
testing.expectEqual('<b onclick="x()">b</b>', root.innerHTML);
root.setHTMLUnsafe('<b onclick=x()>b</b>', {sanitizer: {}});
testing.expectEqual('<b onclick="x()">b</b>', root.innerHTML);
}
</script>
<script id=mutationRecord>
{
const div = document.createElement('div');
div.innerHTML = '<p>old</p>';
const observer = new MutationObserver(() => {});
observer.observe(div, {childList: true});
div.setHTML('<b>a</b><i>b</i><script><\/script>');
const records = observer.takeRecords();
testing.expectEqual(1, records.length);
testing.expectEqual(1, records[0].removedNodes.length);
testing.expectEqual(2, records[0].addedNodes.length);
}
</script>
<script id=parseHTML>
{
const doc = Document.parseHTML('<title>t</title><p onclick=x() data-x=1>a<script>x()<\/script></p>');
testing.expectEqual(true, doc instanceof Document);
testing.expectEqual('about:blank', doc.URL);
testing.expectEqual('<p>a</p>', doc.body.innerHTML);
testing.expectEqual('t', doc.title);
const unsafe = Document.parseHTMLUnsafe('<p onclick=x()>a<script>x()<\/script></p>');
testing.expectEqual('<p onclick="x()">a<script>x()<\/script></p>', unsafe.body.innerHTML);
const filtered = Document.parseHTMLUnsafe('<p>a<i>b</i></p>', {sanitizer: {removeElements: ['i']}});
testing.expectEqual('<p>a</p>', filtered.body.innerHTML);
// html isn't allowed, so it all goes
testing.expectEqual(null, Document.parseHTML('<div>text', {sanitizer: {elements: ['body', 'div']}}).documentElement);
testing.expectError('TypeError', () => Document.parseHTML('x', {sanitizer: {replaceWithChildrenElements: ['html']}}));
}
</script>
@@ -13,3 +13,4 @@
<option id=opt_a value=a selected>A</option>
<option id=opt_b value=b>B</option>
</select>
<iframe id=child srcdoc="<p id=inner>inner</p>"></iframe>
+26
View File
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<title>webdriver input</title>
<button id=btn>go</button>
<button id=gone style="display: none">hidden</button>
<input id=name value="old">
<input id=ro value="fixed" readonly>
<input id=check type=checkbox>
<textarea id=area>text</textarea>
<div id=plain>not focusable</div>
<select id=pick>
<option id=opt_a value=a selected>A</option>
<option id=opt_b value=b>B</option>
</select>
<a id=next href="elements.html">next</a>
<form action="elements.html">
<input id=q name=q>
</form>
<script>
window.events = [];
for (const type of ['click', 'input', 'change', 'keydown', 'focus', 'blur']) {
document.addEventListener(type, (e) => {
const key = type === 'keydown' ? ':' + (e.shiftKey ? 'S-' : '') + e.key : '';
window.events.push(type + key + '@' + e.target.id);
}, true);
}
</script>
-13
View File
@@ -19,7 +19,6 @@
const std = @import("std");
const js = @import("../js/js.zig");
const Frame = @import("../Frame.zig");
const units = @import("../css/units.zig");
const Tokenizer = @import("../css/Tokenizer.zig");
const CSS = @This();
@@ -27,18 +26,6 @@ _pad: bool = false,
pub const init: CSS = .{};
// parseDimension plus viewport-relative units, which the faux layout
// resolves against the page viewport.
pub fn parseDimensionViewport(value: []const u8, frame: *Frame) ?f64 {
const parsed = units.parse(value) catch return null;
return switch (parsed.unit) {
.none, .px => parsed.value,
.vh => parsed.value * @as(f64, @floatFromInt(frame.page.getViewport().height)) / 100.0,
.vw => parsed.value * @as(f64, @floatFromInt(frame.page.getViewport().width)) / 100.0,
else => null,
};
}
// Extract the X value from a transfrom. This could come from a translate,
// translatex, translate3d or matrix function.
pub fn parseTranslateX(value: []const u8) f64 {
@@ -149,6 +149,9 @@ pub fn getName(self: *CustomElementRegistry, constructor: js.Function) ?[]const
}
pub fn upgrade(self: *CustomElementRegistry, root: *Node, frame: *Frame) !void {
if (root.getDocument(frame)._frame == null) {
return;
}
try upgradeNode(self, root, frame);
}
+1 -37
View File
@@ -21,11 +21,9 @@ const std = @import("std");
const js = @import("../js/js.zig");
const Frame = @import("../Frame.zig");
const Parser = @import("../parser/Parser.zig");
const Node = @import("Node.zig");
const Document = @import("Document.zig");
const HTMLDocument = @import("HTMLDocument.zig");
const DOMParser = @This();
@@ -45,42 +43,8 @@ pub fn parseFromString(
switch (target_mime) {
.@"text/html" => {
const arena = try frame.getArena(.medium, "DOMParser.parseFromString");
defer arena.release();
// DOMParser builds a detached Document. Borrow the same fragment
// parse-mode that `Frame.parse` uses so frame-side hooks
// triggered from `Build.created` / `nodeIsReady` (external
// stylesheet fetches, script execution, mutation-observer fan-out,
// default-script injection) treat the parsed nodes as detached and
// skip side effects on the live document. The frame's
// `_parse_mode` is restored on exit.
const previous_parse_mode = frame._parse_mode;
frame._parse_mode = .fragment;
defer frame._parse_mode = previous_parse_mode;
// Create a new HTMLDocument
const doc = try frame._factory.document(HTMLDocument{
._proto = undefined,
});
const doc = try Frame.parse.htmlDocument(frame, html, .{});
doc.asDocument()._url = frame.url;
var normalized = std.mem.trim(u8, html, &std.ascii.whitespace);
if (normalized.len == 0) {
normalized = "<html></html>";
}
// Parse HTML into the document
var parser = Parser.init(arena.allocator(), doc.asNode(), frame, .{});
parser.parse(normalized);
if (parser.terminated) {
return error.ExecutionTerminated;
}
if (parser.err) |pe| {
return pe.err;
}
return doc.asDocument();
},
else => {
+47 -1
View File
@@ -40,6 +40,7 @@ const DOMImplementation = @import("DOMImplementation.zig");
const StyleSheetList = @import("css/StyleSheetList.zig");
const FontFaceSet = @import("css/FontFaceSet.zig");
const Selection = @import("Selection.zig");
const Sanitizer = @import("Sanitizer.zig");
const XPathResult = @import("XPathResult.zig");
const XPathExpression = @import("XPathExpression.zig");
@@ -58,6 +59,7 @@ _proto: *Node,
_page: *Page,
_index: u32, // browser.documents index
_frame: ?*Frame = null,
_template_contents_owner: ?*Document = null,
_url: ?[:0]const u8 = null, // URL for documents created via DOMImplementation (about:blank)
// content type override for documents created via DOMImplementation.createDocument
_content_type: ?[]const u8 = null,
@@ -196,7 +198,7 @@ fn getLastModified(self: *const Document, frame: *Frame) ![]const u8 {
const timestamp = blk: {
if (self._frame) |owner| {
for (owner._http_headers.items) |header| {
if (std.ascii.eqlIgnoreCase(header.name, "last-modified")) {
if (std.mem.eql(u8, header.name, "last-modified")) {
if (dt.DateTime.parse(header.value, .rfc822)) |parsed| {
break :blk parsed.unix(.seconds);
} else |_| {}
@@ -517,6 +519,32 @@ fn createDocumentFragment(self: *Document, frame: *Frame) !*Node.DocumentFragmen
return Node.DocumentFragment.init(self, frame);
}
// https://html.spec.whatwg.org/multipage/scripting.html#appropriate-template-contents-owner-document
// A <template>'s content lives in a document with no browsing context, shared
// by every template of this document (and by the templates nested in that
// content). Nothing in there is connected, scripts never run and, having no
// custom element registry, custom elements are never constructed.
//
// Only a copy stamped into a real document gets upgraded.
pub fn templateContentsOwner(self: *Document, frame: *Frame) !*Document {
if (self._template_contents_owner) |owner| {
return owner;
}
const owner: *Document = if (self._type == .html)
(try frame._factory.document(HTMLDocument{ ._proto = undefined })).asDocument()
else
try frame._factory.genericDocument(.{});
owner._url = "about:blank";
owner._charset = "UTF-8";
owner._ready_state = .complete;
// Its own templates' content stays in it.
owner._template_contents_owner = owner;
self._template_contents_owner = owner;
return owner;
}
pub fn createComment(self: *Document, data: []const u8) !*Node {
return Frame.node_factory.createComment(self, data);
}
@@ -1157,7 +1185,15 @@ pub fn open(self: *Document, call_frame: *Frame) !*Document {
// gone for good, as in Chrome.
frame.cancelQueuedNavigation();
if (std.mem.indexOfScalar(*Document, frame._script_created_parser_docs.items, self) == null) {
// have the page track this document (if it isn't already)
// so that, on shutdown, it can close the parser if needed.
try frame._script_created_parser_docs.append(frame.arena, self);
}
self._script_created_parser = Parser.Streaming.init(frame.arena, doc_node, frame, .{ .allow_declarative_shadow = true });
// on start() failure the internal `handle` isn't yet create. So we can't
// call done() and we don't want any subsequent cleanup to call done().
errdefer self._script_created_parser = null;
try self._script_created_parser.?.start();
frame._parse_mode = .document;
@@ -1516,6 +1552,16 @@ pub const JsApi = struct {
return frame._factory.genericDocument(.{ .url = "about:blank", .charset = "UTF-8" });
}
pub const parseHTML = bridge.function(_parseHTML, .{ .static = true });
fn _parseHTML(html: []const u8, options: ?Sanitizer.Options, frame: *Frame) !*Document {
return Sanitizer.parseHTML(html, options, true, frame);
}
pub const parseHTMLUnsafe = bridge.function(_parseHTMLUnsafe, .{ .static = true });
fn _parseHTMLUnsafe(html: []const u8, options: ?Sanitizer.Options, frame: *Frame) !*Document {
return Sanitizer.parseHTML(html, options, false, frame);
}
pub const onselectionchange = bridge.accessor(Document.getOnSelectionChange, Document.setOnSelectionChange, .{});
pub const onclick = bridge.accessor(Document.getOnClick, Document.setOnClick, .{});
pub const ontouchstart = bridge.accessor(handlerAccessor(.ontouchstart).get, handlerAccessor(.ontouchstart).set, .{});
-6
View File
@@ -165,12 +165,6 @@ pub fn setInnerHTML(self: *DocumentFragment, html: []const u8, frame: *Frame) !v
return parent.setHTML(html, .{}, frame);
}
/// allows declarative shadow dom
pub fn setHTMLUnsafe(self: *DocumentFragment, html: []const u8, frame: *Frame) !void {
const parent = self.asNode();
return parent.setHTML(html, .{ .allow_declarative_shadow = true }, frame);
}
pub fn cloneFragment(self: *DocumentFragment, deep: bool, document: *const Node.Document, frame: *Frame) !*Node {
const fragment = try DocumentFragment.init(document, frame);
const fragment_node = fragment.asNode();
+170 -78
View File
@@ -29,6 +29,7 @@ const Node = @import("Node.zig");
const ShadowRoot = @import("ShadowRoot.zig");
const EventTarget = @import("EventTarget.zig");
const collections = @import("collections.zig");
const Sanitizer = @import("Sanitizer.zig");
const Selector = @import("selector/Selector.zig");
const Animation = @import("animation/Animation.zig");
@@ -627,10 +628,20 @@ pub fn setInnerHTML(self: *Element, html: []const u8, frame: *Frame) !void {
return parent.setHTML(html, .{}, frame);
}
pub fn setHTML(self: *Element, html: []const u8, options: ?Sanitizer.Options, frame: *Frame) !void {
return Sanitizer.setAndFilterHTML(self.htmlTarget(), self, html, options, true, frame);
}
/// allows declarative shadow dom
pub fn setHTMLUnsafe(self: *Element, html: []const u8, frame: *Frame) !void {
const parent = self.asNode();
return parent.setHTML(html, .{ .allow_declarative_shadow = true }, frame);
pub fn setHTMLUnsafe(self: *Element, html: []const u8, options: ?Sanitizer.Options, frame: *Frame) !void {
return Sanitizer.setAndFilterHTML(self.htmlTarget(), self, html, options, false, frame);
}
fn htmlTarget(self: *Element) *Node {
if (self.is(Html.Template)) |template| {
return template.getContent().asNode();
}
return self.asNode();
}
pub fn getId(self: *const Element) ?[]const u8 {
@@ -1233,12 +1244,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);
@@ -1481,27 +1497,34 @@ pub const Axis = enum {
};
pub fn getElementAxis(self: *Element, frame: *Frame, comptime axis: Axis) Axis.State {
if (self.inlineStyle(frame)) |style| {
const decl = style.asCSSStyleDeclaration();
if (CSS.parseDimensionViewport(decl.getPropertyValue(@tagName(axis), frame), frame)) |v| {
const tag = self.getTag();
const root = tag == .html or tag == .body;
if (self.ownerFrame(frame)) |owner| {
const style_manager = &owner._style_manager;
// Roots take only an inline size: a sheet's `height: 100vh` on body
// would shrink the box every synthetic position must fit in.
const size = if (root) style_manager.inlineSize(self, axis) else style_manager.declaredSize(self, axis);
if (size) |v| {
return .{ .value = v, .explicit = true };
}
}
switch (self.getTag()) {
// Root containers get large default size to contain descendant positions.
// With calculateDocumentPosition using linear depth scaling (100px per level),
// even very deep trees (100 levels) stay within 10,000px.
// 100M pixels is plausible for very long documents.
.html, .body => return .{ .value = if (axis == .width) 1920.0 else 100_000_000.0 },
.img, .iframe => {
if (self.getAttributeSafe(comptime .wrap(@tagName(axis)))) |attr| {
if (std.fmt.parseFloat(f64, attr)) |parsed| {
return .{ .value = parsed, .explicit = true };
} else |_| {}
}
},
else => {},
// Root containers get large default size to contain descendant positions.
// With calculateDocumentPosition using linear depth scaling (100px per level),
// even very deep trees (100 levels) stay within 10,000px.
// 100M pixels is plausible for very long documents.
if (root) {
return .{ .value = if (axis == .width) 1920.0 else 100_000_000.0 };
}
// Presentational attributes lose to CSS sizes.
if (tag == .img or tag == .iframe) {
if (self.getAttributeSafe(comptime .wrap(@tagName(axis)))) |attr| {
if (std.fmt.parseFloat(f64, attr)) |parsed| {
return .{ .value = parsed, .explicit = true };
} else |_| {}
}
}
return .{ .value = 5.0 };
@@ -1604,16 +1627,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);
}
_ = try self.writeScroll(.{ .to = .{ .left = null, .top = value } }, frame);
}
pub fn getScrollLeft(self: *Element, frame: *Frame) u32 {
@@ -1623,16 +1637,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);
}
_ = try self.writeScroll(.{ .to = .{ .left = value, .top = null } }, frame);
}
pub const ScrollAxes = struct { x: bool = false, y: bool = false };
@@ -1642,14 +1647,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
@@ -1669,6 +1666,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.
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,
@@ -1711,6 +1716,26 @@ pub fn getScrollWidth(self: *Element, frame: *Frame) f64 {
return @max(width, self.contentAxis(frame, .width));
}
/// 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;
}
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.
//
@@ -2040,55 +2065,97 @@ 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);
}
_ = 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);
const owner = self.ownerFrame(frame) orelse return;
_ = try self.writeScroll(.{ .by = o }, frame);
}
/// 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);
}
const ScrollWrite = union(enum) {
to: ScrollToOpts.Offsets,
by: ScrollToOpts.Offsets,
// Null leaves that axis 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,
};
}
};
/// 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;
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);
gop.value_ptr.x = x;
gop.value_ptr.y = y;
try self.scheduleScrollEvents(gop.value_ptr, owner);
return true;
}
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 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;
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, .{
@@ -2613,6 +2680,7 @@ pub const JsApi = struct {
pub const assignedSlot = bridge.accessor(Element.getAssignedSlot, null, .{});
pub const attachShadow = bridge.function(_attachShadow, .{});
pub const insertAdjacentHTML = bridge.function(Element.insertAdjacentHTML, .{ .ce_reactions = true });
pub const setHTML = bridge.function(Element.setHTML, .{ .ce_reactions = true });
pub const setHTMLUnsafe = bridge.function(Element.setHTMLUnsafe, .{ .ce_reactions = true });
pub const insertAdjacentElement = bridge.function(Element.insertAdjacentElement, .{ .ce_reactions = true });
pub const insertAdjacentText = bridge.function(Element.insertAdjacentText, .{ .ce_reactions = true });
@@ -2748,6 +2816,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(),
\\<div style="height: 100px; overflow: auto"><div style="height: 500px"></div></div>
\\<div style="height: 100px; overflow: auto">text, and no element child to measure</div>
);
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.
+43
View File
@@ -1613,6 +1613,28 @@ pub fn setHTML(self: *Node, html: []const u8, opts: Frame.parse.FragmentParseOpt
}
}
pub fn replaceAllWithFragment(self: *Node, fragment: *Node, frame: *Frame) !void {
frame.domChanged();
const notify = Frame.observers.hasMutationObservers(frame);
var added: std.ArrayList(*Node) = .empty;
if (notify) {
var it = fragment.childrenIterator();
while (it.next()) |child| {
try added.append(frame.call_arena, child);
}
}
const removed = try self.removeAllChildrenCollecting(notify, frame);
try frame.moveAllChildren(fragment, self, null, .silent_parent);
if (notify and (removed.items.len > 0 or added.items.len > 0)) {
// The point here is to batch all of the adds/remove and get a combined
// mutation record
Frame.observers.notifyChildListChange(frame, self, added.items, removed.items, null, null);
}
}
// Writes a JSON representation of the node and its children
pub fn jsonStringify(self: *const Node, writer: *std.json.Stringify) !void {
// stupid json api requires this to be const,
@@ -1699,6 +1721,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);
+1 -1
View File
@@ -617,7 +617,7 @@ fn scheduleBufferFull(self: *Performance) !void {
const exec = perf._exec;
const event = try Event.initTrusted(.wrap(BUFFER_FULL), .{}, exec.page);
try exec.dispatch(perf.asEventTarget(), event, perf._on_buffer_full, .{
.context = "Performance.resourcetimingbufferfull",
.context = "Performance.bufferfull",
});
return null;
}
+22 -6
View File
@@ -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;
}
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -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 {
@@ -404,7 +404,7 @@ fn dispatchExtendable(
self._pending_event = event;
errdefer self.releasePendingEvent();
try wgs.dispatch(wgs.asEventTarget(), base, handler, .{ .context = "ServiceWorkerGlobalScope lifecycle" });
try wgs.dispatch(wgs.asEventTarget(), base, handler, .{ .context = "service worker lifecycle" });
// Seal only after the handlers have run, so a synchronous waitUntil is
// counted before an empty pending set can complete the phase.
+8 -2
View File
@@ -24,6 +24,7 @@ const Frame = @import("../Frame.zig");
const Node = @import("Node.zig");
const Element = @import("Element.zig");
const Sanitizer = @import("Sanitizer.zig");
const DocumentFragment = @import("DocumentFragment.zig");
const ShadowRoot = @This();
@@ -110,8 +111,12 @@ fn getSerializable(self: *const ShadowRoot) bool {
return self._serializable;
}
pub fn setHTMLUnsafe(self: *ShadowRoot, html: []const u8, frame: *Frame) !void {
return self.asDocumentFragment().setHTMLUnsafe(html, frame);
pub fn setHTML(self: *ShadowRoot, html: []const u8, options: ?Sanitizer.Options, frame: *Frame) !void {
return Sanitizer.setAndFilterHTML(self.asNode(), self._host, html, options, true, frame);
}
pub fn setHTMLUnsafe(self: *ShadowRoot, html: []const u8, options: ?Sanitizer.Options, frame: *Frame) !void {
return Sanitizer.setAndFilterHTML(self.asNode(), self._host, html, options, false, frame);
}
pub fn getHTML(self: *ShadowRoot, opts: dump.Opts.Shadow.Declarative, writer: *std.Io.Writer, frame: *Frame) !void {
@@ -219,6 +224,7 @@ pub const JsApi = struct {
return self.getElementById(try value.toZig([]const u8), frame);
}
pub const adoptedStyleSheets = bridge.accessor(ShadowRoot.getAdoptedStyleSheets, ShadowRoot.setAdoptedStyleSheets, .{});
pub const setHTML = bridge.function(ShadowRoot.setHTML, .{ .ce_reactions = true });
pub const setHTMLUnsafe = bridge.function(ShadowRoot.setHTMLUnsafe, .{ .ce_reactions = true });
pub const getHTML = bridge.function(_getHTML, .{});
const GetHTMLOpts = struct {
@@ -319,7 +319,7 @@ fn releaseScriptArena(self: *SharedWorkerGlobalScope) void {
fn drainPendingConnects(self: *SharedWorkerGlobalScope) void {
for (self._pending_connects.items) |port| {
self.scheduleConnect(port) catch |err| {
log.warn(.browser, "shared worker drain connect failed", .{ .err = err });
log.warn(.browser, "shared worker drain failed", .{ .err = err });
};
}
self._pending_connects.clearRetainingCapacity();
@@ -382,7 +382,7 @@ const ConnectCallback = struct {
.cancelable = false,
}, wgs.page)).asEvent();
try wgs.dispatch(target, event, on_connect, .{ .context = "SharedWorkerGlobalScope.connect" });
try wgs.dispatch(target, event, on_connect, .{ .context = "shared worker connect" });
return null;
}
};
+4 -2
View File
@@ -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);
}
@@ -347,3 +347,8 @@ pub const JsApi = struct {
pub const namedItem = bridge.function(HTMLCollection.getByName, .{});
pub const symbol_iterator = bridge.iterator(HTMLCollection.iterator, .{});
};
const testing = @import("../../../testing.zig");
test "WebApi: indexed properties" {
try testing.htmlRunner("collections/indexed_properties.html", .{});
}
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const lp = @import("lightpanda");
const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig");
const Frame = @import("../../Frame.zig");
@@ -104,6 +106,50 @@ pub fn remove(self: *HTMLOptionsCollection, index: i32, frame: *Frame) void {
}
}
// Chrome's cap (kMaxListItems): padding up to a huge index would otherwise
// create that many options. Past it, Chrome ignores the set (with a console
// warning); Firefox has no cap.
const max_list_items = 100_000;
// The indexed setter: null removes the option at index; an index past the
// end pads with blank options and then appends; otherwise the option at
// index is replaced.
fn setAtIndex(self: *HTMLOptionsCollection, index: u32, option_: ?*Option, frame: *Frame) !void {
const existing = self.getAtIndex(index, frame);
const option = (option_ orelse {
if (existing) |element| {
element.remove(frame);
}
return;
}).asElement().asNode();
if (existing) |element| {
const old = element.asNode();
_ = try old.parentNode().?.replaceChild(option, old, frame);
return;
}
if (index >= max_list_items) {
lp.log.warn(.js, "select overflow", .{ .max_list_items = max_list_items, .request = index });
return;
}
const select_node = self._select.asNode();
const len = self.length(frame);
if (index > len) {
// Per spec, the padding goes in as one DocumentFragment, so observers
// get one record rather than one per blank option.
const doc = select_node.ownerDocument(frame).?;
const fragment = (try Node.DocumentFragment.init(doc, frame)).asNode();
for (len..index) |_| {
const blank = try doc.createElementNS("http://www.w3.org/1999/xhtml", "option", frame);
_ = try fragment.appendChild(blank.asNode(), frame);
}
_ = try select_node.appendChild(fragment, frame);
}
_ = try select_node.appendChild(option, frame);
}
pub const JsApi = struct {
pub const bridge = js.Bridge(HTMLOptionsCollection);
@@ -117,7 +163,7 @@ pub const JsApi = struct {
pub const length = bridge.accessor(HTMLOptionsCollection.length, null, .{});
// Indexed access
pub const @"[int]" = bridge.indexed(HTMLOptionsCollection.getAtIndex, null, .{ .null_as_undefined = true });
pub const @"[int]" = bridge.indexedReadWrite(HTMLOptionsCollection.getAtIndex, setAtIndex, null, null, null, .{ .null_as_undefined = true, .ce_reactions = true });
pub const @"[str]" = bridge.namedIndexed(HTMLOptionsCollection.getByName, null, null, null, struct {
fn wrap(self: *HTMLOptionsCollection, name: []const u8, frame: *Frame) !u32 {
if (self.getByName(name, frame) != null) {
+38 -45
View File
@@ -136,13 +136,13 @@ pub fn getPropertyValue(self: *const CSSStyleDeclaration, property_name: []const
return getDefaultPropertyValue(self, wrapped);
}
/// The value of a declared property, or null when it isn't declared. The
/// `overflow` shorthand reads as its longhands when both are present with
/// the same priority, the way the CSSOM serializes a shorthand.
/// The value of a declared property, or null when it isn't declared. An axis
/// shorthand reads as its longhands when both are present with the same
/// priority, the way the CSSOM serializes a shorthand.
pub fn declaredValue(self: *const CSSStyleDeclaration, name: String, frame: *Frame) ?[]const u8 {
if (name.eql(overflow_shorthand)) {
const x = self.findProperty(comptime .wrap("overflow-x")) orelse return null;
const pair = self.overflowPair(x) orelse return null;
if (CssParser.axisShorthand(name.str())) |shorthand| {
const x = self.findProperty(.wrap(shorthand.x)) orelse return null;
const pair = self.axisPair(x) orelse return null;
var buf = std.Io.Writer.Allocating.init(frame.local_arena);
pair.formatValue(&buf.writer) catch return null;
return buf.written();
@@ -151,19 +151,14 @@ pub fn declaredValue(self: *const CSSStyleDeclaration, name: String, frame: *Fra
return prop._value.str();
}
// `overflow` is the one shorthand whose longhands the style cascade folds
// (StyleManager tracks overflow-x and overflow-y), so it is the one this
// object stores expanded, as the CSSOM does for every shorthand: setting it
// sets both longhands, reading or serializing it recombines them.
const overflow_shorthand: String = .wrap("overflow");
/// Both overflow longhands, declared with the same priority: the pair reads
/// and serializes as the shorthand.
const OverflowPair = struct {
/// Both longhands of an axis shorthand, declared with the same priority: the
/// pair reads and serializes as the shorthand.
const AxisPair = struct {
name: []const u8,
x: *const Property,
y: *const Property,
fn formatValue(self: OverflowPair, writer: *std.Io.Writer) !void {
fn formatValue(self: AxisPair, writer: *std.Io.Writer) !void {
try self.x._value.format(writer);
if (!self.x._value.eql(self.y._value)) {
try writer.writeByte(' ');
@@ -171,26 +166,24 @@ const OverflowPair = struct {
}
}
fn format(self: OverflowPair, writer: *std.Io.Writer) !void {
try writer.writeAll("overflow: ");
fn format(self: AxisPair, writer: *std.Io.Writer) !void {
try writer.writeAll(self.name);
try writer.writeAll(": ");
try self.formatValue(writer);
try formatDeclarationEnd(self.x._important, writer);
}
};
/// The pair `prop` belongs to, when it is an overflow longhand and the other
/// is declared with the same priority.
fn overflowPair(self: *const CSSStyleDeclaration, prop: *const Property) ?OverflowPair {
const is_x = prop._name.eql(comptime .wrap("overflow-x"));
if (!is_x and !prop._name.eql(comptime .wrap("overflow-y"))) {
return null;
}
const other: String = if (is_x) comptime .wrap("overflow-y") else comptime .wrap("overflow-x");
const partner = self.findProperty(other) orelse return null;
/// The pair `prop` belongs to, when it is an axis longhand and the other is
/// declared with the same priority.
fn axisPair(self: *const CSSStyleDeclaration, prop: *const Property) ?AxisPair {
const longhand = CssParser.axisLonghand(prop._name.str()) orelse return null;
const partner = self.findProperty(.wrap(longhand.partner())) orelse return null;
if (partner._important != prop._important) {
return null;
}
return if (is_x) .{ .x = prop, .y = partner } else .{ .x = partner, .y = prop };
const name = longhand.shorthand.name;
return if (longhand.is_x) .{ .name = name, .x = prop, .y = partner } else .{ .name = name, .x = partner, .y = prop };
}
fn resolvedDimension(element: *Element, dimension: enum { width, height }, frame: *Frame) []const u8 {
@@ -207,9 +200,9 @@ fn resolvedDimension(element: *Element, dimension: enum { width, height }, frame
pub fn getPropertyPriority(self: *const CSSStyleDeclaration, property_name: []const u8, frame: *Frame) []const u8 {
const normalized = normalizePropertyName(property_name, &frame.buf);
const wrapped = String.wrap(normalized);
if (wrapped.eql(overflow_shorthand)) {
const x = self.findProperty(comptime .wrap("overflow-x")) orelse return "";
const pair = self.overflowPair(x) orelse return "";
if (CssParser.axisShorthand(normalized)) |shorthand| {
const x = self.findProperty(.wrap(shorthand.x)) orelse return "";
const pair = self.axisPair(x) orelse return "";
return if (pair.x._important) "important" else "";
}
const prop = self.findProperty(wrapped) orelse return "";
@@ -240,10 +233,10 @@ pub fn setProperty(self: *CSSStyleDeclaration, property_name: []const u8, value:
/// not override an earlier !important one (CSS cascade precedence).
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;
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);
if (CssParser.axisShorthand(normalized)) |shorthand| {
const values = CssParser.splitAxisPair(declaration.value) orelse return;
try self.applyParsedDeclaration(.{ .name = shorthand.x, .value = values.x, .important = declaration.important }, frame);
try self.applyParsedDeclaration(.{ .name = shorthand.y, .value = values.y, .important = declaration.important }, frame);
return;
}
if (!declaration.important) {
@@ -266,10 +259,10 @@ 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 x = try self.setPropertyImpl("overflow-x", values.x, important, frame);
const y = try self.setPropertyImpl("overflow-y", values.y, important, frame);
if (CssParser.axisShorthand(normalized)) |shorthand| {
const values = CssParser.splitAxisPair(value) orelse return false;
const x = try self.setPropertyImpl(shorthand.x, values.x, important, frame);
const y = try self.setPropertyImpl(shorthand.y, values.y, important, frame);
return x or y;
}
@@ -309,10 +302,10 @@ pub fn removeProperty(self: *CSSStyleDeclaration, property_name: []const u8, fra
fn removePropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, frame: *Frame) !?[]const u8 {
const normalized = normalizePropertyName(property_name, &frame.buf);
if (overflow_shorthand.eqlSlice(normalized)) {
const old_value = self.declaredValue(overflow_shorthand, frame) orelse "";
const x = try self.removePropertyImpl("overflow-x", frame);
const y = try self.removePropertyImpl("overflow-y", frame);
if (CssParser.axisShorthand(normalized)) |shorthand| {
const old_value = self.declaredValue(.wrap(shorthand.name), frame) orelse "";
const x = try self.removePropertyImpl(shorthand.x, frame);
const y = try self.removePropertyImpl(shorthand.y, frame);
if (x == null and y == null) {
return null;
}
@@ -402,7 +395,7 @@ pub fn replaceCssText(self: *CSSStyleDeclaration, text: []const u8, frame: *Fram
pub fn format(self: *const CSSStyleDeclaration, writer: *std.Io.Writer) !void {
var first = true;
// An overflow pair serializes once, where its first longhand sits.
// An axis pair serializes once, where its first longhand sits.
var skip: ?*const Property = null;
var it = self.iterator();
while (it.next()) |prop| {
@@ -413,7 +406,7 @@ pub fn format(self: *const CSSStyleDeclaration, writer: *std.Io.Writer) !void {
try writer.writeByte(' ');
}
first = false;
if (self.overflowPair(prop)) |pair| {
if (self.axisPair(prop)) |pair| {
try pair.format(writer);
skip = if (pair.x == prop) pair.y else pair.x;
} else {
+13
View File
@@ -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 });
+6 -1
View File
@@ -45,6 +45,7 @@ _connected_callback_invoked: bool = false,
_disconnected_callback_invoked: bool = false,
_upgrade_failed: bool = false, // a failed upgrade is never retried
_upgrade_in_progress: bool = false,
_upgrade_candidate: bool = false, // listed in a frame's _undefined_custom_elements
pub fn asElement(self: *Custom) *Element {
return Factory.protoOf(self).asElement();
@@ -85,7 +86,11 @@ pub fn enqueueConnectedCallbackOnElement(comptime from_parser: bool, element: *E
CustomElementRegistry.upgradeCustomElement(custom, definition, frame) catch {};
return;
}
// Element is undefined and no definition exists yet — nothing to queue.
if (!custom._upgrade_candidate) {
custom._upgrade_candidate = true;
try frame._undefined_custom_elements.append(frame.arena, custom);
}
return;
}
+10 -1
View File
@@ -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 });
};
+24 -2
View File
@@ -39,6 +39,26 @@ _value: ?[]const u8 = null,
_selected: bool = false,
_default_selected: bool = false,
pub fn constructor(text_: ?js.NullableString, value_: ?js.NullableString, default_selected_: ?bool, selected_: ?bool, frame: *Frame) !*Option {
const node = try Frame.node_factory.createElementNS(frame.document, .html, "option", null);
const el = node.as(Element);
const text = if (text_) |t| t.value else "";
if (text.len > 0) {
_ = try node.appendChild(try frame.document.createTextNode(text), frame);
}
if (value_) |v| {
try el.setAttributeSafe(comptime .wrap("value"), .wrap(v.value), frame);
}
if (default_selected_ orelse false) {
try el.setAttributeSafe(comptime .wrap("selected"), comptime .wrap(""), frame);
}
const self = el.as(Option);
self._selected = selected_ orelse false;
return self;
}
pub fn asElement(self: *Option) *Element {
return Factory.protoOf(self).asElement();
}
@@ -79,7 +99,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 +116,7 @@ fn setSelectedness(self: *Option, selected: bool) void {
}
/// The <select> this option belongs to, directly or through an <optgroup>.
fn ownerSelect(self: *Option) ?*Select {
pub fn ownerSelect(self: *Option) ?*Select {
var node = self.asNode().parentNode();
while (node) |n| : (node = n.parentNode()) {
if (n.is(Select)) |select| return select;
@@ -135,12 +155,14 @@ pub const JsApi = struct {
pub const Meta = struct {
pub const name = "HTMLOptionElement";
pub const constructor_alias = "Option";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
const reflect = Element.Reflect(Option);
pub const constructor = bridge.constructor(Option.constructor, .{});
pub const value = bridge.accessor(Option.getValue, Option.setValue, .{ .ce_reactions = true });
pub const text = bridge.accessor(Option.getText, Option.setText, .{ .ce_reactions = true });
pub const label = bridge.accessor(Option.getLabel, Option.setLabel, .{ .ce_reactions = true });
+4 -2
View File
@@ -157,8 +157,10 @@ pub const JsApi = struct {
pub const Build = struct {
pub fn created(node: *Node, frame: *Frame) !void {
const self = node.as(Template);
// Create the template content DocumentFragment
self._content = try DocumentFragment.init(node.getDocument(frame), frame);
// The content DocumentFragment belongs to the inert template contents
// owner document, not to the template's own document.
const owner = try node.getDocument(frame).templateContentsOwner(frame);
self._content = try DocumentFragment.init(owner, frame);
}
// Per the HTML spec's cloning steps for <template>, a deep clone must
+127 -51
View File
@@ -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;
@@ -144,10 +153,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;
@@ -241,14 +260,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| {
try self.fireCurrentEntryChangeEvent(p, .{ .push = state.value }, frame);
}
}
@@ -292,19 +306,73 @@ 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);
}
try self.fireCurrentEntryChangeEvent(previous, .{ .replace = state.value }, frame);
}
return entry;
}
fn fireNavigateSuccess(self: *Navigation, frame: *Frame) !void {
if (!frame.hasDirectListeners(
self.asEventTarget(),
"navigatesuccess",
self._on_navigatesuccess,
)) {
return;
}
const event = Event.initTrusted(
.wrap("navigatesuccess"),
null,
frame.page,
) catch |err| {
log.warn(.event, "Navigation.navigatesuccess", .{ .err = err });
return;
};
try self.dispatch(self._on_navigatesuccess, event, frame);
}
fn fireCurrentEntryChangeEvent(
self: *Navigation,
previous: *NavigationHistoryEntry,
kind: ?NavigationKind,
frame: *Frame,
) !void {
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.currententrychange", .{ .err = err });
return;
};
try self.dispatch(self._on_currententrychange, event.asEvent(), frame);
}
fn resolveFinished(
self: *Navigation,
resolver: js.PromiseResolver,
comptime source: []const u8,
frame: *Frame,
) !void {
resolver.resolve(source, {});
try self.fireNavigateSuccess(frame);
}
const NavigateOptions = struct {
history: ?[]const u8 = null,
info: ?js.Value = null,
@@ -351,9 +419,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);
try self.resolveFinished(finished, "navigation push", frame);
} else {
try frame.scheduleNavigation(url, .{ .reason = .navigation, .kind = kind }, .{ .script = frame });
}
@@ -364,9 +431,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);
try self.resolveFinished(finished, "navigation replace", frame);
} else {
try frame.scheduleNavigation(url, .{ .reason = .navigation, .kind = kind }, .{ .script = frame });
}
@@ -379,7 +445,8 @@ pub fn navigateInner(
committed.resolve("navigation traverse", {});
// todo: Fire navigate event
finished.resolve("navigation traverse", {});
try self.fireCurrentEntryChangeEvent(previous, kind, frame);
try self.resolveFinished(finished, "navigation traverse", frame);
} else {
try frame.scheduleNavigation(url, .{ .reason = .navigation, .kind = kind }, .{ .script = frame });
}
@@ -393,16 +460,6 @@ 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);
}
_ = try committed.persist();
_ = try finished.persist();
return .{
@@ -436,14 +493,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,
};
try self.fireCurrentEntryChangeEvent(previous, .reload, frame);
}
return self.navigateInner(entry._url, .reload, frame);
@@ -480,18 +534,11 @@ 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);
}
try 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,
@@ -512,6 +559,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);
@@ -530,6 +595,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, .{});
@@ -538,6 +604,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");
+2 -2
View File
@@ -350,9 +350,9 @@ fn corsAllowed(self: *const EventSource, transfer: *Transfer) bool {
var allow_credentials: ?[]const u8 = null;
var it = transfer.responseHeaderIterator();
while (it.next()) |hdr| {
if (std.ascii.eqlIgnoreCase(hdr.name, "access-control-allow-origin")) {
if (std.mem.eql(u8, hdr.name, "access-control-allow-origin")) {
allow_origin = hdr.value;
} else if (std.ascii.eqlIgnoreCase(hdr.name, "access-control-allow-credentials")) {
} else if (std.mem.eql(u8, hdr.name, "access-control-allow-credentials")) {
allow_credentials = hdr.value;
}
}
+429
View File
@@ -0,0 +1,429 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// 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",
};
+6
View File
@@ -406,6 +406,9 @@
\\ --cookie-jar <PATH>
\\ Path to a JSON file to save cookies to on exit (write-only).
\\ Defaults to no cookie saving.
\\ --cors-store-entry-limit <INT>
\\ Maximum number of entries kept in the CorsStore. 0 means no limit.
\\ Defaults to 1000.
\\ --experimental-features <FEATURE>
\\ Enable an experimental, unstable feature. Can be passed multiple times.
\\ Behavior may change or be removed without notice.
@@ -515,6 +518,9 @@
\\ --proxy-bearer-token <TOKEN>
\\ Token sent for bearer authentication with the proxy:
\\ Proxy-Authorization: Bearer <token>.
\\ --robot-store-entry-limit <INT>
\\ Maximum number of entries kept in the RobotStore. 0 means no limit.
\\ Defaults to 1000.
\\ --timezone <IANA>
\\ Time zone used by Date and Intl, e.g. Europe/Paris or UTC.
\\ Defaults to the host time zone.
+34 -7
View File
@@ -140,27 +140,54 @@ pub const Format = enum {
pretty,
};
pub fn debug(scope: Scope, msg: []const u8, data: anytype) void {
// A message is a short, plain-text key; the detail belongs in the kvs.
const max_msg_len = 30;
/// The comptime form of the checks in logToErased. Those only fire in a debug
/// build *and* only once the line actually runs, so a message on a rare path
/// can ship and then panic on whoever first hits it. Every message below is a
/// literal, so the same rules can be a build error instead.
fn validateMsg(comptime msg: []const u8) void {
comptime {
if (msg.len > max_msg_len) {
@compileError("log msg cannot be more than 30 characters: " ++ msg);
}
for (msg) |b| {
switch (b) {
'A'...'Z', 'a'...'z', ' ', '0'...'9', '_', '-', '.', '{', '}' => {},
else => @compileError("log msg contains an invalid character: " ++ msg),
}
}
}
}
pub fn debug(scope: Scope, comptime msg: []const u8, data: anytype) void {
comptime validateMsg(msg);
log(scope, .debug, msg, data);
}
pub fn info(scope: Scope, msg: []const u8, data: anytype) void {
pub fn info(scope: Scope, comptime msg: []const u8, data: anytype) void {
comptime validateMsg(msg);
log(scope, .info, msg, data);
}
pub fn warn(scope: Scope, msg: []const u8, data: anytype) void {
pub fn warn(scope: Scope, comptime msg: []const u8, data: anytype) void {
comptime validateMsg(msg);
log(scope, .warn, msg, data);
}
pub fn err(scope: Scope, msg: []const u8, data: anytype) void {
pub fn err(scope: Scope, comptime msg: []const u8, data: anytype) void {
comptime validateMsg(msg);
log(scope, .err, msg, data);
}
pub fn fatal(scope: Scope, msg: []const u8, data: anytype) void {
pub fn fatal(scope: Scope, comptime msg: []const u8, data: anytype) void {
comptime validateMsg(msg);
log(scope, .fatal, msg, data);
}
pub fn note(scope: Scope, msg: []const u8, data: anytype) void {
pub fn note(scope: Scope, comptime msg: []const u8, data: anytype) void {
comptime validateMsg(msg);
if (comptime lp.IS_TEST == false) {
log(scope, .note, msg, data);
}
@@ -236,7 +263,7 @@ fn logTo(scope: Scope, level: Level, msg: []const u8, data: anytype, out: *std.I
fn logToErased(scope: Scope, level: Level, msg: []const u8, kvs: []const KV, out: *std.Io.Writer) !void {
if (lp.IS_DEBUG) {
if (msg.len > 30) {
if (msg.len > max_msg_len) {
std.debug.print("debug-only-panic: log msg cannot be more than 30 characters: {s}", .{msg});
@panic("invalid log msg");
}
+2 -2
View File
@@ -102,14 +102,14 @@ fn storeFromSystemCA(allocator: Allocator) !*crypto.X509_STORE {
// advances `ptr` past it, so we just hand it the rest of the buffer.
var ptr: [*]const u8 = bytes.ptr + index.*;
const x509 = crypto.d2i_X509(null, &ptr, @intCast(bytes.len - index.*)) orelse {
log.warn(.app, "Skipping unparseable system cert", .{});
log.warn(.app, "Skipping unparseable cert", .{});
continue;
};
defer crypto.X509_free(x509); // add_cert takes its own ref; drop ours.
const result = crypto.X509_STORE_add_cert(store, x509);
if (result != 1) {
log.warn(.app, "Failed to add X509 cert to store", .{});
log.warn(.app, "Failed to add X509 cert", .{});
}
count += 1;
}
+172
View File
@@ -0,0 +1,172 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const Allocator = std.mem.Allocator;
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 = std.array_hash_map.String(Entry(V));
pub const InsertResult = union(enum) {
exists,
inserted: ?V,
};
allocator: Allocator,
capacity: ?usize,
map: Map = .empty,
hand: usize = 0,
pub fn init(allocator: Allocator, capacity: usize) Self {
const true_capacity = if (capacity == 0) null else capacity;
return .{ .allocator = allocator, .capacity = true_capacity };
}
pub fn deinit(self: *Self) void {
for (self.map.keys()) |key| {
self.allocator.free(key);
}
self.map.deinit(self.allocator);
}
pub fn entries(self: *Self) []Entry(V) {
return self.map.values();
}
pub fn get(self: *Self, key: []const u8) ?*V {
const entry = self.map.getPtr(key) orelse return null;
entry.referenced = true;
return &entry.value;
}
pub fn remove(self: *Self, key: []const u8) ?V {
const index = self.map.getIndex(key) orelse return null;
const owned_key = self.map.keys()[index];
const value = self.map.values()[index].value;
self.map.swapRemoveAt(index);
self.allocator.free(owned_key);
return value;
}
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.capacity) |cap| {
if (self.map.count() > cap) {
return .{ .inserted = self.evictOne() };
}
}
return .{ .inserted = null };
}
fn evictOne(self: *Self) V {
const items = self.map.values();
while (true) {
if (self.hand >= items.len) self.hand = 0;
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);
self.allocator.free(key);
return value;
}
}
};
}
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(u32).init(testing.allocator, 3);
defer q.deinit();
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(u32).init(testing.allocator, 2);
defer q.deinit();
try testing.expectEqual(null, evictedOf(u32, try q.insert("a", 1)));
try testing.expectEqual(null, evictedOf(u32, try q.insert("b", 2)));
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(u32).init(testing.allocator, 2);
defer q.deinit();
_ = try q.insert("a", 1);
_ = try q.insert("b", 2);
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.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").?.*);
}
+123 -33
View File
@@ -25,11 +25,15 @@ const http = @import("http.zig");
const Transfer = @import("HttpClient.zig").Transfer;
const SingleFlight = @import("SingleFlight.zig");
const HttpClient = @import("HttpClient.zig");
const Network = @import("Network.zig");
const CorsStore = @import("CorsStore.zig");
const log = lp.log;
const CorsGate = @This();
network: *Network,
single_flight: SingleFlight,
// CORS Request Headers
@@ -42,6 +46,7 @@ const ACCESS_CONTROL_ALLOW_ORIGIN = "access-control-allow-origin";
const ACCESS_CONTROL_ALLOW_METHODS = "access-control-allow-methods";
const ACCESS_CONTROL_ALLOW_HEADERS = "access-control-allow-headers";
const ACCESS_CONTROL_ALLOW_CREDENTIALS = "access-control-allow-credentials";
const ACCESS_CONTROL_MAX_AGE = "access-control-max-age";
pub fn deinit(self: *CorsGate) void {
self.single_flight.deinit();
@@ -72,7 +77,7 @@ fn flushPending(self: *CorsGate, key: []const u8, allowed: bool) void {
}
}
fn isSafelistedMethod(value: http.Method) bool {
pub fn isSafelistedMethod(value: http.Method) bool {
return switch (value) {
.GET, .HEAD, .POST => true,
else => false,
@@ -212,6 +217,28 @@ pub fn check(self: *CorsGate, transfer: *Transfer) !Result {
return .allowed;
}
const wants_credentials = req.credentials_mode == .include;
const authored = try collectAuthoredHeaders(transfer, transfer.arena.allocator());
const covered = try self.network.cors_store.coversRequest(
transfer.arena.allocator(),
.{ .origin = origin, .target = req.url, .credentials = wants_credentials },
req.method,
authored.items,
);
if (covered) {
log.debug(.cors, "cross origin", .{
.url = req.url,
.origin = origin,
.preflight = false,
.cached = true,
});
lp.metrics.cors_check.incr(.cached);
return .allowed;
}
log.debug(.cors, "cross origin", .{
.url = req.url,
.origin = origin,
@@ -219,10 +246,25 @@ pub fn check(self: *CorsGate, transfer: *Transfer) !Result {
});
lp.metrics.cors_check.incr(.preflight);
try self.fetchThenResume(transfer);
try self.fetchThenResume(transfer, authored.items);
return .pending;
}
fn collectAuthoredHeaders(transfer: *Transfer, allocator: std.mem.Allocator) !std.ArrayList([]const u8) {
var header_names: std.ArrayList([]const u8) = .empty;
for (transfer.req_headers.items) |hdr| {
if (hdr.source != .author) continue;
if (isSafelistedHeader(hdr.name, hdr.value)) continue;
try header_names.append(allocator, try std.ascii.allocLowerString(allocator, hdr.name));
}
std.mem.sort([]const u8, header_names.items, {}, struct {
fn lessThan(_: void, a: []const u8, b: []const u8) bool {
return std.mem.lessThan(u8, a, b);
}
}.lessThan);
return header_names;
}
const CorsKey = struct {
url: []const u8,
origin: []const u8,
@@ -264,6 +306,9 @@ const CorsPreflightContext = struct {
wants_credentials: bool,
allowed: bool = false,
acam: ?[]const u8 = null,
acah: ?[]const u8 = null,
acma: ?[]const u8 = null,
fn validateHeaders(
self: *CorsPreflightContext,
@@ -355,6 +400,57 @@ const CorsPreflightContext = struct {
return true;
}
fn cacheGrant(self: *CorsPreflightContext, acam: ?[]const u8, acah: ?[]const u8, acma: ?[]const u8) !void {
if (self.url.len == 0) return;
const max_age_s: u64 = blk: {
const v = acma orelse break :blk 5;
if (v.len == 0) break :blk 5;
break :blk std.fmt.parseUnsigned(u64, v, 10) catch return;
};
if (max_age_s == 0) return;
const capped_s: u64 = @min(max_age_s, 7200);
const capped_ms = capped_s * 1000;
const methods_wildcard = acam != null and std.mem.eql(u8, acam.?, "*") and !self.wants_credentials;
var methods = std.EnumSet(http.Method).initEmpty();
if (!methods_wildcard) {
if (acam) |list| {
var it = std.mem.splitScalar(u8, list, ',');
while (it.next()) |raw| {
const token = std.mem.trim(u8, raw, &std.ascii.whitespace);
if (std.meta.stringToEnum(http.Method, token)) |m| methods.insert(m);
}
}
}
const headers_wildcard = acah != null and std.mem.eql(u8, acah.?, "*") and !self.wants_credentials;
var allowed_headers: std.ArrayList([]const u8) = .empty;
if (!headers_wildcard) {
if (acah) |list| {
var it = std.mem.splitScalar(u8, list, ',');
while (it.next()) |raw| {
const token = std.mem.trim(u8, raw, &std.ascii.whitespace);
if (token.len == 0) continue;
try allowed_headers.append(self.arena.allocator(), token);
}
}
}
try self.gate.network.cors_store.put(
.{ .origin = self.origin, .target = self.url, .credentials = self.wants_credentials },
.{
.methods_wildcard = methods_wildcard,
.methods = methods,
.headers_wildcard = headers_wildcard,
.headers = allowed_headers.items,
.expires_at = lp.datetime.milliTimestamp(.real) + capped_ms,
},
);
}
fn methodAllowed(list: []const u8, method: http.Method) bool {
const method_name = @tagName(method);
var it = std.mem.splitScalar(u8, list, ',');
@@ -393,26 +489,39 @@ const CorsPreflightContext = struct {
var acam: ?[]const u8 = null;
var acah: ?[]const u8 = null;
var acac: ?[]const u8 = null;
var acma: ?[]const u8 = null;
var iter = transfer.responseHeaderIterator();
while (iter.next()) |hdr| {
if (std.ascii.eqlIgnoreCase(ACCESS_CONTROL_ALLOW_ORIGIN, hdr.name)) {
if (std.mem.eql(u8, hdr.name, ACCESS_CONTROL_ALLOW_ORIGIN)) {
acao = hdr.value;
} else if (std.ascii.eqlIgnoreCase(ACCESS_CONTROL_ALLOW_METHODS, hdr.name)) {
} else if (std.mem.eql(u8, hdr.name, ACCESS_CONTROL_ALLOW_METHODS)) {
acam = hdr.value;
} else if (std.ascii.eqlIgnoreCase(ACCESS_CONTROL_ALLOW_HEADERS, hdr.name)) {
} else if (std.mem.eql(u8, hdr.name, ACCESS_CONTROL_ALLOW_HEADERS)) {
acah = hdr.value;
} else if (std.ascii.eqlIgnoreCase(ACCESS_CONTROL_ALLOW_CREDENTIALS, hdr.name)) {
} else if (std.mem.eql(u8, hdr.name, ACCESS_CONTROL_ALLOW_CREDENTIALS)) {
acac = hdr.value;
} else if (std.ascii.eqlIgnoreCase(ACCESS_CONTROL_MAX_AGE, hdr.name)) {
acma = hdr.value;
}
}
self.allowed = self.validateHeaders(acao, acam, acah, acac);
if (self.allowed) {
self.acam = acam;
self.acah = acah;
self.acma = acma;
}
return .proceed;
}
fn doneCallback(ctx_ptr: *anyopaque) anyerror!void {
const self: *CorsPreflightContext = @ptrCast(@alignCast(ctx_ptr));
if (self.allowed) {
self.cacheGrant(self.acam, self.acah, self.acma) catch |err| {
log.warn(.cors, "preflight cache store failed", .{ .url = self.url, .err = err });
};
}
self.resolve(self.allowed);
}
@@ -441,31 +550,16 @@ const CorsPreflightContext = struct {
}
};
fn fetchThenResume(self: *CorsGate, transfer: *Transfer) !void {
fn fetchThenResume(self: *CorsGate, transfer: *Transfer, authored_headers: []const []const u8) !void {
const url = transfer.req.url;
const origin = transfer.req.origin orelse "null";
var header_names: std.ArrayList([]const u8) = .empty;
for (transfer.req_headers.items) |hdr| {
if (hdr.source != .author) continue;
if (isSafelistedHeader(hdr.name, hdr.value)) continue;
try header_names.append(
transfer.arena.allocator(),
try std.ascii.allocLowerString(transfer.arena.allocator(), hdr.name),
);
}
std.mem.sort([]const u8, header_names.items, {}, struct {
fn lessThan(_: void, a: []const u8, b: []const u8) bool {
return std.mem.lessThan(u8, a, b);
}
}.lessThan);
const origin = transfer.effectiveOrigin();
const cors_key = CorsKey{
.url = url,
.origin = origin,
.method = transfer.req.method,
.wants_credentials = transfer.req.credentials_mode == .include,
.authored_headers = header_names.items,
.authored_headers = authored_headers,
};
const key = try cors_key.build(transfer.arena.allocator());
@@ -488,8 +582,8 @@ fn fetchThenResume(self: *CorsGate, transfer: *Transfer) !void {
const referer: ?[]const u8 = transfer.findRequestHeader("referer");
const owned_header_names = try arena.alloc([]const u8, header_names.items.len);
for (header_names.items, 0..) |name, i| {
const owned_header_names = try arena.alloc([]const u8, authored_headers.len);
for (authored_headers, 0..) |name, i| {
owned_header_names[i] = try arena.dupe(u8, name);
}
@@ -527,11 +621,7 @@ fn fetchThenResume(self: *CorsGate, transfer: *Transfer) !void {
errdefer fetch_transfer.deinit();
// Origin
try fetch_transfer.setHeader(
ORIGIN,
transfer.req.origin orelse "null",
.{},
);
try fetch_transfer.setHeader(ORIGIN, transfer.effectiveOrigin(), .{});
if (referer) |r| {
try fetch_transfer.setHeader("Referer", r, .{});
@@ -549,8 +639,8 @@ fn fetchThenResume(self: *CorsGate, transfer: *Transfer) !void {
);
// Access-Control-Allow-Headers
if (header_names.items.len > 0) {
const request_headers_value = try std.mem.join(arena.allocator(), ",", header_names.items);
if (authored_headers.len > 0) {
const request_headers_value = try std.mem.join(arena.allocator(), ",", authored_headers);
try fetch_transfer.setHeader(
ACCESS_CONTROL_REQUEST_HEADERS,
request_headers_value,
+418
View File
@@ -0,0 +1,418 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const http = @import("http.zig");
const isSafelistedMethod = @import("CorsGate.zig").isSafelistedMethod;
const ClockCache = @import("ClockCache.zig").ClockCache;
const CorsStore = @This();
pub const Key = struct {
origin: []const u8,
target: []const u8,
credentials: bool,
/// Serializes into a single string suitable as a ClockCache key.
fn build(self: Key, allocator: std.mem.Allocator) ![]const u8 {
var buf: std.ArrayList(u8) = .empty;
errdefer buf.deinit(allocator);
try buf.appendSlice(allocator, self.origin);
try buf.append(allocator, 0);
try buf.appendSlice(allocator, self.target);
try buf.append(allocator, 0);
try buf.append(allocator, @intFromBool(self.credentials));
return buf.toOwnedSlice(allocator);
}
};
pub const Entry = struct {
methods_wildcard: bool,
methods: std.EnumSet(http.Method),
headers_wildcard: bool,
headers: []const []const u8,
expires_at: u64,
fn unionHeaders(
allocator: std.mem.Allocator,
a: []const []const u8,
b: []const []const u8,
) ![]const []const u8 {
var list: std.ArrayList([]const u8) = .empty;
errdefer {
for (list.items) |s| allocator.free(s);
list.deinit(allocator);
}
outerA: for (a) |s| {
for (list.items) |existing| {
if (std.ascii.eqlIgnoreCase(existing, s)) continue :outerA;
}
try list.append(allocator, try allocator.dupe(u8, s));
}
outerB: for (b) |s| {
for (list.items) |existing| {
if (std.ascii.eqlIgnoreCase(existing, s)) continue :outerB;
}
try list.append(allocator, try allocator.dupe(u8, s));
}
return list.toOwnedSlice(allocator);
}
fn merge(self: Entry, allocator: std.mem.Allocator, new: Entry) !Entry {
return .{
.methods_wildcard = self.methods_wildcard or new.methods_wildcard,
.methods = self.methods.unionWith(new.methods),
.headers_wildcard = self.headers_wildcard or new.headers_wildcard,
.headers = try unionHeaders(allocator, self.headers, new.headers),
.expires_at = @min(self.expires_at, new.expires_at),
};
}
fn dupe(self: Entry, allocator: std.mem.Allocator) !Entry {
var new_headers: std.ArrayList([]const u8) = try .initCapacity(allocator, self.headers.len);
errdefer {
for (new_headers.items) |hdr| allocator.free(hdr);
new_headers.deinit(allocator);
}
for (self.headers) |hdr| {
new_headers.appendAssumeCapacity(try allocator.dupe(u8, hdr));
}
return .{
.methods_wildcard = self.methods_wildcard,
.methods = self.methods,
.headers_wildcard = self.headers_wildcard,
.headers = new_headers.items,
.expires_at = self.expires_at,
};
}
pub fn deinit(self: Entry, allocator: std.mem.Allocator) void {
for (self.headers) |h| allocator.free(h);
allocator.free(self.headers);
}
};
allocator: std.mem.Allocator,
map: ClockCache(Entry),
mutex: std.Io.Mutex = .init,
pub fn init(allocator: std.mem.Allocator, capacity: usize) CorsStore {
return .{ .allocator = allocator, .map = .init(allocator, capacity) };
}
pub fn deinit(self: *CorsStore) void {
self.mutex.lockUncancelable(lp.io);
defer self.mutex.unlock(lp.io);
for (self.map.entries()) |*entry| {
entry.value.deinit(self.allocator);
}
self.map.deinit();
}
// Caller is expected to be holding mutex.
fn getWithExpiration(self: *CorsStore, cache_key: []const u8) ?*Entry {
const entry = self.map.get(cache_key) orelse return null;
if (entry.expires_at <= lp.datetime.milliTimestamp(.real)) {
if (self.map.remove(cache_key)) |e| {
e.deinit(self.allocator);
}
return null;
}
return entry;
}
fn matches(entry: Entry, method: http.Method, authored_headers: []const []const u8) bool {
if (!isSafelistedMethod(method) and !entry.methods_wildcard and !entry.methods.contains(method)) {
return false;
}
for (authored_headers) |name| {
const is_authorization = std.ascii.eqlIgnoreCase(name, "authorization");
if (entry.headers_wildcard and !is_authorization) continue;
var found = false;
for (entry.headers) |allowed| {
if (std.ascii.eqlIgnoreCase(allowed, name)) {
found = true;
break;
}
}
if (!found) return false;
}
return true;
}
pub fn coversRequest(
self: *CorsStore,
allocator: std.mem.Allocator,
key: Key,
method: http.Method,
authored_headers: []const []const u8,
) !bool {
const primary_key = try key.build(allocator);
defer allocator.free(primary_key);
self.mutex.lockUncancelable(lp.io);
defer self.mutex.unlock(lp.io);
if (self.getWithExpiration(primary_key)) |entry| {
if (matches(entry.*, method, authored_headers)) return true;
}
if (key.credentials) return false;
const cred_key = try (Key{ .origin = key.origin, .target = key.target, .credentials = true }).build(allocator);
defer allocator.free(cred_key);
const entry = self.getWithExpiration(cred_key) orelse return false;
return matches(entry.*, method, authored_headers);
}
/// Insert or merge a CORS grant for (origin, target). `entry` is not
/// consumed: `put` copies whatever it needs (via `dupe`/`merge`, which
/// always allocate their own copies) and never takes ownership of
/// `entry.headers` or its contents.
///
/// Callers remain responsible for
/// freeing `entry.headers` after this call, on both the insert and
/// the merge path.
pub fn put(self: *CorsStore, key: Key, entry: Entry) !void {
const cache_key = try key.build(self.allocator);
defer self.allocator.free(cache_key);
self.mutex.lockUncancelable(lp.io);
defer self.mutex.unlock(lp.io);
if (self.getWithExpiration(cache_key)) |existing| {
const merged = try existing.merge(self.allocator, entry);
existing.deinit(self.allocator);
existing.* = merged;
return;
}
const owned_entry = try entry.dupe(self.allocator);
errdefer owned_entry.deinit(self.allocator);
switch (try self.map.insert(cache_key, owned_entry)) {
.exists => unreachable,
.inserted => |evicted| {
if (evicted) |v| {
var e = v;
e.deinit(self.allocator);
}
},
}
}
const testing = @import("../testing.zig");
fn freeHeaders(allocator: std.mem.Allocator, headers: []const []const u8) void {
for (headers) |h| allocator.free(h);
allocator.free(headers);
}
test "CorsStore: put then covers, miss on different origin/target/credentials" {
const allocator = testing.allocator;
var store = CorsStore.init(allocator, 10);
defer store.deinit();
const headers = try allocator.alloc([]const u8, 1);
headers[0] = try allocator.dupe(u8, "x-custom");
defer freeHeaders(allocator, headers);
try store.put(.{ .origin = "https://a.example", .target = "https://api.example", .credentials = false }, .{
.methods_wildcard = false,
.methods = std.EnumSet(http.Method).initOne(.POST),
.headers_wildcard = false,
.headers = headers,
.expires_at = lp.datetime.milliTimestamp(.real) + 60_000,
});
try testing.expect(try store.coversRequest(
allocator,
.{ .origin = "https://a.example", .target = "https://api.example", .credentials = false },
.POST,
&.{},
));
try testing.expect(!try store.coversRequest(
allocator,
.{ .origin = "https://a.example", .target = "https://api.example", .credentials = false },
.PUT,
&.{},
));
try testing.expect(!try store.coversRequest(
allocator,
.{ .origin = "https://b.example", .target = "https://api.example", .credentials = false },
.POST,
&.{},
));
try testing.expect(!try store.coversRequest(
allocator,
.{ .origin = "https://a.example", .target = "https://other.example", .credentials = false },
.POST,
&.{},
));
// Same origin/target but different credentials mode: separate entry, must miss.
try testing.expect(!try store.coversRequest(
allocator,
.{ .origin = "https://a.example", .target = "https://api.example", .credentials = true },
.POST,
&.{},
));
}
test "CorsStore: expired entries are treated as a miss on covers" {
const allocator = testing.allocator;
var store = CorsStore.init(allocator, 10);
defer store.deinit();
try store.put(.{ .origin = "https://a.example", .target = "https://api.example", .credentials = false }, .{
.methods_wildcard = true,
.methods = .initEmpty(),
.headers_wildcard = true,
.headers = &.{}, // empty slice, nothing to free
.expires_at = lp.datetime.milliTimestamp(.real) - 1,
});
try testing.expect(!try store.coversRequest(
allocator,
.{ .origin = "https://a.example", .target = "https://api.example", .credentials = false },
.GET,
&.{},
));
}
test "CorsStore: put merges into existing entry rather than clobbering" {
const allocator = testing.allocator;
var store = CorsStore.init(allocator, 10);
defer store.deinit();
const key = Key{ .origin = "https://a.example", .target = "https://api.example", .credentials = false };
const h1 = try allocator.alloc([]const u8, 1);
h1[0] = try allocator.dupe(u8, "x-one");
try store.put(key, .{
.methods_wildcard = false,
.methods = std.EnumSet(http.Method).initOne(.POST),
.headers_wildcard = false,
.headers = h1,
.expires_at = lp.datetime.milliTimestamp(.real) + 60_000,
});
freeHeaders(allocator, h1);
const h2 = try allocator.alloc([]const u8, 1);
h2[0] = try allocator.dupe(u8, "x-two");
try store.put(key, .{
.methods_wildcard = false,
.methods = std.EnumSet(http.Method).initOne(.PUT),
.headers_wildcard = false,
.headers = h2,
.expires_at = lp.datetime.milliTimestamp(.real) + 60_000,
});
freeHeaders(allocator, h2);
try testing.expect(try store.coversRequest(allocator, key, .POST, &.{"x-one"}));
try testing.expect(try store.coversRequest(allocator, key, .PUT, &.{"x-two"}));
try testing.expect(!try store.coversRequest(allocator, key, .DELETE, &.{}));
}
test "CorsStore: credentialed and non-credentialed grants for same origin/target stay separate" {
const allocator = testing.allocator;
var store = CorsStore.init(allocator, 10);
defer store.deinit();
const origin = "https://a.example";
const target = "https://api.example";
// Non-credentialed grant: wildcard headers allowed (valid per spec for non-cred requests).
try store.put(.{ .origin = origin, .target = target, .credentials = false }, .{
.methods_wildcard = true,
.methods = .initEmpty(),
.headers_wildcard = true,
.headers = &.{},
.expires_at = lp.datetime.milliTimestamp(.real) + 60_000,
});
// Credentialed grant: explicit methods/headers only, no wildcard.
const h = try allocator.alloc([]const u8, 1);
h[0] = try allocator.dupe(u8, "x-custom");
try store.put(.{ .origin = origin, .target = target, .credentials = true }, .{
.methods_wildcard = false,
.methods = std.EnumSet(http.Method).initOne(.GET),
.headers_wildcard = false,
.headers = h,
.expires_at = lp.datetime.milliTimestamp(.real) + 60_000,
});
freeHeaders(allocator, h);
// A credentialed request asking for an arbitrary header must be rejected
// against the credentialed entry, even though the non-cred entry has a wildcard.
try testing.expect(!try store.coversRequest(
allocator,
.{ .origin = origin, .target = target, .credentials = true },
.GET,
&.{"x-anything"},
));
try testing.expect(try store.coversRequest(
allocator,
.{ .origin = origin, .target = target, .credentials = true },
.GET,
&.{"x-custom"},
));
try testing.expect(!try store.coversRequest(
allocator,
.{ .origin = origin, .target = target, .credentials = true },
.PUT,
&.{},
));
// The non-credentialed entry's wildcard still works for non-cred requests.
try testing.expect(try store.coversRequest(allocator, .{ .origin = origin, .target = target, .credentials = false }, .GET, &.{"x-anything"}));
}
test "CorsStore: covers never lets a wildcard cover Authorization" {
const allocator = testing.allocator;
var store = CorsStore.init(allocator, 10);
defer store.deinit();
const key = Key{ .origin = "https://a.example", .target = "https://api.example", .credentials = false };
try store.put(key, .{
.methods_wildcard = true,
.methods = .initEmpty(),
.headers_wildcard = true,
.headers = &.{},
.expires_at = std.math.maxInt(u64),
});
try testing.expect(!try store.coversRequest(allocator, key, .GET, &.{"authorization"}));
try testing.expect(try store.coversRequest(allocator, key, .GET, &.{"x-anything"}));
}
+59 -25
View File
@@ -243,7 +243,10 @@ pub fn init(self: *Client, app: *lp.App) !void {
.network = network,
.single_flight = .init(allocator),
},
.cors = .{ .single_flight = .init(allocator) },
.cors = .{
.network = network,
.single_flight = .init(allocator),
},
.url_blocklist = url_blocklist,
.arena_pool = &app.arena_pool,
};
@@ -1207,9 +1210,18 @@ pub fn resumeAfterCors(self: *Client, transfer: *Transfer) !void {
return self.pipeline(transfer, .after_cors);
}
// `headers` are response headers (lowercased names); `name` must be lowercase.
pub fn findHeader(headers: []const http.Header, name: []const u8) ?[]const u8 {
if (comptime lp.IS_DEBUG) {
for (name) |c| {
if (std.ascii.isUpper(c)) {
log.fatal(.bug, "non-lowercase header", .{ .name = name });
@panic("non-lowercase-header");
}
}
}
for (headers) |hdr| {
if (std.ascii.eqlIgnoreCase(hdr.name, name)) {
if (std.mem.eql(u8, hdr.name, name)) {
return hdr.value;
}
}
@@ -2118,12 +2130,19 @@ pub fn fulfillIntercepted(
transfer.unpark();
// Copied into the transfer arena with lowercased names, like headers
// materialized from curl.
const owned = transfer.dupeHeadersLower(headers) catch |err| {
transfer.abortPipelineError(err);
return err;
};
const followed = blk: {
if (isRedirectStatus(status) == false) {
break :blk false;
}
const location = findHeader(headers, "location") orelse break :blk false;
try self.fulfillRedirect(transfer, status, headers, location);
const location = findHeader(owned, "location") orelse break :blk false;
try self.fulfillRedirect(transfer, status, owned, location);
break :blk true;
};
@@ -2131,7 +2150,7 @@ pub fn fulfillIntercepted(
return;
}
transfer.bufferFulfilled(status, headers, body) catch |err| {
transfer.bufferFulfilled(status, owned, body) catch |err| {
transfer.abortPipelineError(err);
return err;
};
@@ -2150,7 +2169,7 @@ fn fulfillRedirect(
if (transfer.req.credentialsAllowed()) {
if (transfer.cookie_jar) |jar| {
for (headers) |hdr| {
if (std.ascii.eqlIgnoreCase(hdr.name, "set-cookie")) {
if (std.mem.eql(u8, hdr.name, "set-cookie")) {
try jar.populateFromResponse(transfer.req.url, hdr.value);
}
}
@@ -3064,7 +3083,7 @@ pub const Transfer = struct {
return true;
}
for (headers) |hdr| {
if (!std.ascii.eqlIgnoreCase(hdr.name, "timing-allow-origin")) {
if (std.mem.eql(u8, hdr.name, "timing-allow-origin") == false) {
continue;
}
var it = std.mem.splitScalar(u8, hdr.value, ',');
@@ -3192,6 +3211,18 @@ pub const Transfer = struct {
self.scheduleDispatch();
}
// Response header names are lowercase, so lookups use std.mem.eql.
fn setResponseHeaders(self: *Transfer, headers: []const http.Header) void {
if (comptime lp.IS_DEBUG) {
for (headers) |hdr| {
for (hdr.name) |c| {
std.debug.assert(std.ascii.isUpper(c) == false);
}
}
}
self.res.headers = headers;
}
fn setResponseHead(self: *Transfer, status: u16, content_type: ?[]const u8) void {
self.res.header = .{
.url = self.req.url.ptr,
@@ -3214,7 +3245,7 @@ pub const Transfer = struct {
};
self.setResponseHead(cached.status, cached.content_type);
self.res.headers = cached.headers;
self.setResponseHeaders(cached.headers);
self._from_cache = true;
self._timing.cache = cache_state;
self._content_length = body.len;
@@ -3222,27 +3253,29 @@ pub const Transfer = struct {
}
// Materialize an interceptor-supplied response (CDP fulfillRequest).
// `headers` and `body` are caller-owned; copy everything that must
// survive until dispatch.
fn dupeHeadersLower(self: *Transfer, headers: []const http.Header) ![]const http.Header {
const allocator = self.arena.allocator();
const owned = try allocator.alloc(http.Header, headers.len);
for (headers, owned) |hdr, *o| {
o.* = try hdr.normalize(allocator);
}
return owned;
}
// our arena already owns `header` but not `body`. Why so complicated?
fn bufferFulfilled(self: *Transfer, status: u16, headers: []const http.Header, body: ?[]const u8) !void {
const arena = self.arena;
const owned = try arena.alloc(http.Header, headers.len);
var content_type: ?[]const u8 = null;
for (headers, 0..) |hdr, i| {
owned[i] = .{
.name = try arena.dupe(u8, hdr.name),
.value = try arena.dupe(u8, hdr.value),
};
if (std.ascii.eqlIgnoreCase(hdr.name, "content-type")) {
content_type = owned[i].value;
for (headers) |hdr| {
if (std.mem.eql(u8, hdr.name, "content-type")) {
content_type = hdr.value;
}
}
const owned_body: []const u8 = if (body) |b| try arena.dupe(u8, b) else "";
self.setResponseHead(status, content_type);
self.res.headers = owned;
self.setResponseHeaders(headers);
self._content_length = owned_body.len;
try self.bufferEvents(owned_body);
}
@@ -3278,12 +3311,12 @@ pub const Transfer = struct {
var it = HeaderIterator{ .curl = .{ .conn = conn } };
const headers = try it.collect(arena.allocator());
self.res.headers = headers.items;
self.setResponseHeaders(headers.items);
if (self.req.credentialsAllowed()) {
if (self.cookie_jar) |jar| {
for (self.res.headers) |hdr| {
if (std.ascii.eqlIgnoreCase(hdr.name, "set-cookie")) {
if (std.mem.eql(u8, hdr.name, "set-cookie")) {
jar.populateFromResponse(self.req.url, hdr.value) catch |err| {
log.err(.http, "set cookie", .{ .err = err, .req = self });
return err;
@@ -3973,7 +4006,7 @@ pub const Transfer = struct {
fn getContentLengthRawValue(self: *const Transfer) ?[]const u8 {
// Materialized headers (dispatch time, any source).
for (self.res.headers) |hdr| {
if (std.ascii.eqlIgnoreCase(hdr.name, "content-length")) {
if (std.mem.eql(u8, hdr.name, "content-length")) {
return hdr.value;
}
}
@@ -4178,7 +4211,8 @@ const Response = struct {
header: ?http.ResponseHead = null,
// Full response headers, materialized into the transfer arena at
// completion (or set directly by cache / synthetic / fulfill).
// completion (or set directly by cache / synthetic / fulfill). Names are
// lowercased.
headers: []const http.Header = &.{},
// total bytes received in the response, including the response status
@@ -4285,7 +4319,7 @@ const Synthetic = struct {
if (content_type.len > 0) {
const h = try arena.alloc(http.Header, 1);
h[0] = .{ .name = "content-type", .value = content_type };
transfer.res.headers = h;
transfer.setResponseHeaders(h);
}
transfer._content_length = body.len;
try transfer.bufferEvents(body);
+6 -1
View File
@@ -27,6 +27,7 @@ const libcurl = @import("../sys/libcurl.zig");
const http = @import("http.zig");
const IpFilter = @import("IpFilter.zig");
const RobotStore = @import("Robots.zig").RobotStore;
const CorsStore = @import("CorsStore.zig");
const WebBotAuth = @import("WebBotAuth.zig");
const RateLimiter = @import("RateLimiter.zig");
const Certificates = @import("Certificates.zig");
@@ -43,6 +44,7 @@ cache: Cache,
allocator: Allocator,
config: *const Config,
robot_store: RobotStore,
cors_store: CorsStore,
web_bot_auth: ?WebBotAuth,
rate_limiter: ?RateLimiter,
certificates: Certificates,
@@ -121,7 +123,8 @@ pub fn init(app: *App) !Network {
.connections = connections,
.cache = cache,
.robot_store = RobotStore.init(allocator),
.robot_store = RobotStore.init(allocator, config.robotStoreEntryLimit()),
.cors_store = CorsStore.init(allocator, config.corsStoreEntryLimit()),
.web_bot_auth = web_bot_auth,
.rate_limiter = if (config.httpNavDelay()) |ms| RateLimiter.init(allocator, ms, config.httpNavBurst()) else null,
.adblocker = adblocker,
@@ -144,6 +147,8 @@ pub fn deinit(self: *Network) void {
self.ws_pool.deinit(self.allocator);
self.robot_store.deinit();
self.cors_store.deinit();
if (self.rate_limiter) |*rl| {
rl.deinit();
}
+67 -61
View File
@@ -19,6 +19,8 @@
const std = @import("std");
const lp = @import("lightpanda");
const ClockCache = @import("ClockCache.zig").ClockCache;
const log = lp.log;
const CompiledPattern = struct {
@@ -86,9 +88,6 @@ 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 {
const RobotsEntry = union(enum) {
present: Robots,
@@ -96,39 +95,46 @@ pub const RobotStore = struct {
disallowed,
};
const RobotsMap = @import("Network.zig").HostHashMap(RobotsEntry);
allocator: std.mem.Allocator,
map: RobotsMap,
map: ClockCache(RobotsEntry),
mutex: std.Io.Mutex = .init,
pub fn init(allocator: std.mem.Allocator) RobotStore {
return .{ .allocator = allocator, .map = .empty };
pub fn init(allocator: std.mem.Allocator, capacity: u32) RobotStore {
return .{
.allocator = allocator,
.map = .init(allocator, capacity),
};
}
pub fn deinit(self: *RobotStore) void {
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.*);
switch (entry.value_ptr.*) {
.present => |*robots| robots.deinit(self.allocator),
.allowed, .disallowed => {},
}
for (self.map.entries()) |*entry| {
self.freeEntry(&entry.value);
}
self.map.deinit(self.allocator);
self.map.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 {
@@ -139,69 +145,62 @@ 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;
}
// cannot overwrite an existing value, if it was `present`, we'd have
// to free the value but it might be being used.
if (try self.insert(url, .{ .present = robots })) return;
var discarded = robots;
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.
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);
return gop.value_ptr;
}
};
@@ -1247,7 +1246,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",
@@ -1257,13 +1256,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 {
+68 -51
View File
@@ -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;
},
@@ -98,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,
};
@@ -134,24 +126,27 @@ 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);
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;
const decision: Robots.RobotStore.Decision = switch (outcome) {
.decision => |d| d,
.robots => |r| if (r.isAllowed(URL.getPathname(transfer.req.url))) .allowed else .blocked,
};
if (!allowed) {
if (decision == .blocked) {
lp.metrics.robots_access.incr(.deny);
log.warn(.http, "blocked by robots", .{ .url = transfer.req.url });
transfer.failAsync(error.RobotsBlocked);
@@ -170,7 +165,6 @@ fn flushPending(self: *RobotsGate, robots_url: []const u8) void {
const RobotsContext = struct {
gate: *RobotsGate,
arena: *lp.Arena,
arena_pool: *ArenaPool,
robots_url: [:0]const u8,
buffer: std.ArrayList(u8),
status: u16 = 0,
@@ -200,26 +194,26 @@ 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.settle(.{ .outcome = .{ .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 });
self.settle(.{ .outcome = .{ .decision = .blocked } });
return;
};
// BE CAREFUL: robots can be invalidated after this call
self.settle(.{ .outcome = .{ .robots = robots } });
},
// Unauthorized/Forbidden: treat as fully disallowed since we can't verify permissions.
401, 403 => {
@@ -227,12 +221,12 @@ const RobotsContext = struct {
.url = robots_url,
.status = self.status,
});
try network.robot_store.putDisallowed(robots_url);
self.settle(.{ .outcome = .{ .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.settle(.{ .outcome = .{ .decision = .allowed } });
},
// RFC9309: Unreachable (500-599) means that we are completely disallowed.
500...599 => {
@@ -240,27 +234,26 @@ const RobotsContext = struct {
.url = robots_url,
.status = self.status,
});
try network.robot_store.putDisallowed(robots_url);
self.settle(.{ .outcome = .{ .decision = .blocked } });
},
else => {
log.debug(.http, "unexpected status on robots", .{
.url = robots_url,
.status = self.status,
});
try network.robot_store.putDisallowed(robots_url);
self.settle(.{ .outcome = .{ .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.settle(.{
.outcome = .{ .decision = .allowed },
.cache = false,
});
}
fn shutdownCallback(ctx_ptr: *anyopaque) void {
@@ -273,10 +266,34 @@ const RobotsContext = struct {
arena.release();
}
fn resolve(self: *RobotsContext) void {
const gate = self.gate;
const SettleOptions = struct {
outcome: RobotsGate.Outcome,
cache: bool = true,
};
fn settle(self: *RobotsContext, options: SettleOptions) void {
const arena = self.arena;
gate.flushPending(self.robots_url);
arena.release();
defer arena.release();
const gate = self.gate;
const network = gate.network;
gate.flushPending(self.robots_url, options.outcome);
if (options.cache) {
switch (options.outcome) {
.decision => |d| switch (d) {
.allowed => network.robot_store.putAllowed(self.robots_url) catch |err| {
log.warn(.browser, "cache robots decision", .{ .url = self.robots_url, .err = err });
},
.blocked => network.robot_store.putDisallowed(self.robots_url) catch |err| {
log.warn(.browser, "cache robots decision", .{ .url = self.robots_url, .err = err });
},
},
.robots => |r| network.robot_store.put(self.robots_url, r) catch |err| {
log.warn(.browser, "cache robots rules", .{ .url = self.robots_url, .err = err });
},
}
}
}
};
+19 -19
View File
@@ -320,33 +320,33 @@ const ResponseHeaders = struct {
for (headers) |h| {
switch (h.name.len) {
3 => if (std.ascii.eqlIgnoreCase(h.name, "Age")) {
3 => if (std.mem.eql(u8, h.name, "age")) {
self.age = h.value;
},
4 => {
if (std.ascii.eqlIgnoreCase(h.name, "Date")) {
if (std.mem.eql(u8, h.name, "date")) {
self.date = h.value;
} else if (std.ascii.eqlIgnoreCase(h.name, "ETag")) {
} else if (std.mem.eql(u8, h.name, "etag")) {
self.etag = h.value;
} else if (std.ascii.eqlIgnoreCase(h.name, "Vary")) {
} else if (std.mem.eql(u8, h.name, "vary")) {
self.vary = h.value;
}
},
7 => if (std.ascii.eqlIgnoreCase(h.name, "Expires")) {
7 => if (std.mem.eql(u8, h.name, "expires")) {
self.expires = h.value;
},
10 => if (std.ascii.eqlIgnoreCase(h.name, "Set-Cookie")) {
10 => if (std.mem.eql(u8, h.name, "set-cookie")) {
self.has_set_cookie = true;
},
12 => if (std.ascii.eqlIgnoreCase(h.name, "Content-Type")) {
12 => if (std.mem.eql(u8, h.name, "content-type")) {
self.content_type = h.value;
},
13 => {
if (std.ascii.eqlIgnoreCase(h.name, "Cache-Control")) {
if (std.mem.eql(u8, h.name, "cache-control")) {
self.directives = .parse(h.value);
} else if (std.ascii.eqlIgnoreCase(h.name, "Last-Modified")) {
} else if (std.mem.eql(u8, h.name, "last-modified")) {
self.last_modified = h.value;
} else if (std.ascii.eqlIgnoreCase(h.name, "Authorization")) {
} else if (std.mem.eql(u8, h.name, "authorization")) {
self.has_authorization = true;
}
},
@@ -576,11 +576,11 @@ const TestResponse = struct {
fn run(self: TestResponse, arena: std.mem.Allocator) !?CachePutRequest {
var headers: std.ArrayList(Http.Header) = .empty;
inline for (.{
.{ "Cache-Control", self.cache_control },
.{ "Expires", self.expires },
.{ "Date", self.date },
.{ "ETag", self.etag },
.{ "Last-Modified", self.last_modified },
.{ "cache-control", self.cache_control },
.{ "expires", self.expires },
.{ "date", self.date },
.{ "etag", self.etag },
.{ "last-modified", self.last_modified },
}) |field| {
if (field[1]) |value| {
try headers.append(arena, .{ .name = field[0], .value = value });
@@ -721,8 +721,8 @@ test "Cache: tryCache vary headers" {
.status = 200,
.content_type = "text/html",
.headers = &.{
.{ .name = "Cache-Control", .value = "max-age=300" },
.{ .name = "Vary", .value = "accept-encoding, accept-language" },
.{ .name = "cache-control", .value = "max-age=300" },
.{ .name = "vary", .value = "accept-encoding, accept-language" },
},
.request_headers = &request_headers,
});
@@ -738,8 +738,8 @@ test "Cache: tryCache vary headers" {
.status = 200,
.content_type = "text/html",
.headers = &.{
.{ .name = "Cache-Control", .value = "max-age=300" },
.{ .name = "Vary", .value = "*" },
.{ .name = "cache-control", .value = "max-age=300" },
.{ .name = "vary", .value = "*" },
},
.request_headers = &request_headers,
});
-998
View File
@@ -1,998 +0,0 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const Cache = @import("Cache.zig");
const log = lp.log;
const CacheRequest = Cache.CacheRequest;
const RenewResponse = Cache.RenewResponse;
const CachedMetadata = Cache.CachedMetadata;
const CachedResponse = Cache.CachedResponse;
const CACHE_VERSION: usize = 1;
const LOCK_STRIPES = 16;
comptime {
std.debug.assert(std.math.isPowerOfTwo(LOCK_STRIPES));
}
pub const FsCache = @This();
dir: std.Io.Dir,
locks: [LOCK_STRIPES]std.Io.Mutex = .{std.Io.Mutex.init} ** LOCK_STRIPES,
const CacheMetadataJson = struct {
version: usize,
metadata: CachedMetadata,
};
fn getLockPtr(self: *FsCache, key: *const [HASHED_KEY_LEN]u8) *std.Io.Mutex {
const lock_idx = std.hash.Wyhash.hash(0, key[0..]) & (LOCK_STRIPES - 1);
return &self.locks[lock_idx];
}
const BODY_LEN_HEADER_LEN = 8;
const HASHED_KEY_LEN = 64;
const HASHED_PATH_LEN = HASHED_KEY_LEN + 6;
const HASHED_TMP_PATH_LEN = HASHED_PATH_LEN + 4;
fn hashKey(key: []const u8) [HASHED_KEY_LEN]u8 {
var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined;
std.crypto.hash.sha2.Sha256.hash(key, &digest, .{});
var hex: [HASHED_KEY_LEN]u8 = undefined;
_ = std.fmt.bufPrint(&hex, "{s}", .{std.fmt.bytesToHex(&digest, .lower)}) catch unreachable;
return hex;
}
fn cachePath(hashed_key: *const [HASHED_KEY_LEN]u8) [HASHED_PATH_LEN]u8 {
var path: [HASHED_PATH_LEN]u8 = undefined;
_ = std.fmt.bufPrint(&path, "{s}.cache", .{hashed_key}) catch unreachable;
return path;
}
fn cacheTmpPath(hashed_key: *const [HASHED_KEY_LEN]u8) [HASHED_TMP_PATH_LEN]u8 {
var path: [HASHED_TMP_PATH_LEN]u8 = undefined;
_ = std.fmt.bufPrint(&path, "{s}.cache.tmp", .{hashed_key}) catch unreachable;
return path;
}
fn writeCacheFile(
self: *FsCache,
hashed_key: *const [HASHED_KEY_LEN]u8,
body_reader: *std.Io.Reader,
body_len: u64,
meta: CachedMetadata,
) !void {
const cache_p = cachePath(hashed_key);
const cache_tmp_p = cacheTmpPath(hashed_key);
const file = self.dir.createFile(lp.io, &cache_tmp_p, .{ .truncate = true }) catch |e| {
log.err(.cache, "create file", .{ .url = meta.url, .file = &cache_tmp_p, .err = e });
return e;
};
errdefer self.dir.deleteFile(lp.io, &cache_tmp_p) catch {};
defer file.close(lp.io);
var writer_buf: [1024]u8 = undefined;
var file_writer = file.writer(lp.io, &writer_buf);
const w = &file_writer.interface;
var len_buf: [BODY_LEN_HEADER_LEN]u8 = undefined;
std.mem.writeInt(u64, &len_buf, body_len, .little);
try w.writeAll(&len_buf);
var copy_buf: [4096]u8 = undefined;
var remaining = body_len;
while (remaining > 0) {
const to_read = @min(copy_buf.len, remaining);
const n = try body_reader.readSliceShort(copy_buf[0..to_read]);
if (n == 0) break;
try w.writeAll(copy_buf[0..n]);
remaining -= n;
}
try std.json.Stringify.value(
CacheMetadataJson{ .version = CACHE_VERSION, .metadata = meta },
.{ .whitespace = .minified },
w,
);
try w.flush();
self.dir.rename(&cache_tmp_p, self.dir, &cache_p, lp.io) catch |e| {
log.err(.cache, "rename", .{ .url = meta.url, .from = &cache_tmp_p, .to = &cache_p, .err = e });
return e;
};
}
pub fn init(path: []const u8) !FsCache {
const cwd = std.Io.Dir.cwd();
try cwd.createDirPath(lp.io, path);
const dir = try cwd.openDir(lp.io, path, .{ .iterate = true });
return .{ .dir = dir };
}
pub fn deinit(self: *FsCache) void {
self.dir.close(lp.io);
}
pub fn get(self: *FsCache, arena: std.mem.Allocator, req: CacheRequest) ?CachedResponse {
const hashed_key = hashKey(req.url);
const cache_p = cachePath(&hashed_key);
const lock = self.getLockPtr(&hashed_key);
lock.lockUncancelable(lp.io);
defer lock.unlock(lp.io);
const file = self.dir.openFile(lp.io, &cache_p, .{ .mode = .read_only }) catch |e| {
switch (e) {
std.Io.File.OpenError.FileNotFound => {
log.debug(.cache, "miss", .{ .url = req.url, .hash = &hashed_key, .reason = "missing" });
},
else => |err| {
log.warn(.cache, "open file err", .{ .url = req.url, .err = err });
},
}
return null;
};
var cleanup = false;
defer if (cleanup) {
file.close(lp.io);
self.dir.deleteFile(lp.io, &cache_p) catch |e| {
log.err(.cache, "clean fail", .{ .url = req.url, .file = &cache_p, .err = e });
};
};
var file_buf: [1024]u8 = undefined;
var len_buf: [BODY_LEN_HEADER_LEN]u8 = undefined;
var file_reader = file.reader(lp.io, &file_buf);
const file_reader_iface = &file_reader.interface;
file_reader_iface.readSliceAll(&len_buf) catch |e| {
log.warn(.cache, "read header", .{ .url = req.url, .err = e });
cleanup = true;
return null;
};
const body_len = std.mem.readInt(u64, &len_buf, .little);
// Now we read metadata.
file_reader.seekTo(body_len + BODY_LEN_HEADER_LEN) catch |e| {
log.warn(.cache, "seek metadata", .{ .url = req.url, .err = e });
cleanup = true;
return null;
};
var json_reader = std.json.Reader.init(arena, file_reader_iface);
const cache_file: CacheMetadataJson = std.json.parseFromTokenSourceLeaky(
CacheMetadataJson,
arena,
&json_reader,
.{ .allocate = .alloc_always },
) catch |e| {
// Warn because malformed metadata can be a deeper symptom.
log.warn(.cache, "miss", .{ .url = req.url, .err = e, .reason = "malformed metadata" });
cleanup = true;
return null;
};
if (cache_file.version != CACHE_VERSION) {
log.debug(.cache, "miss", .{
.url = req.url,
.reason = "version mismatch",
.expected = CACHE_VERSION,
.got = cache_file.version,
});
cleanup = true;
return null;
}
const metadata = cache_file.metadata;
// If we have Vary headers, ensure they are present & matching.
for (metadata.vary_headers) |vary_hdr| {
const name = vary_hdr.name;
const value = vary_hdr.value;
const incoming = for (req.request_headers) |h| {
if (std.ascii.eqlIgnoreCase(h.name, name)) break h.value;
} else "";
if (!std.ascii.eqlIgnoreCase(value, incoming)) {
log.debug(.cache, "miss", .{
.url = req.url,
.reason = "vary mismatch",
.header = name,
.expected = value,
.got = incoming,
});
return null;
}
}
// On the case of a hash collision.
if (!std.ascii.eqlIgnoreCase(metadata.url, req.url)) {
log.warn(.cache, "collision", .{ .url = req.url, .expected = metadata.url, .got = req.url });
cleanup = true;
return null;
}
// Check entry expiration.
const expired = metadata.isStale(req.timestamp);
log.debug(.cache, "hit", .{ .url = req.url, .hash = &hashed_key, .expired = expired });
return .{
.metadata = metadata,
.data = .{
.file = .{
.file = file,
.offset = BODY_LEN_HEADER_LEN,
.len = body_len,
},
},
.expired = expired,
};
}
pub fn put(self: *FsCache, meta: CachedMetadata, body: []const u8) !void {
const hashed_key = hashKey(meta.url);
const lock = self.getLockPtr(&hashed_key);
lock.lockUncancelable(lp.io);
defer lock.unlock(lp.io);
var body_reader = std.Io.Reader.fixed(body);
try self.writeCacheFile(&hashed_key, &body_reader, body.len, meta);
log.debug(.cache, "put", .{ .url = meta.url, .hash = &hashed_key, .body_len = body.len });
}
pub fn clear(self: *FsCache) !void {
for (&self.locks) |*lock| lock.lockUncancelable(lp.io);
defer for (&self.locks) |*lock| lock.unlock(lp.io);
var iter = self.dir.iterate();
while (try iter.next(lp.io)) |entry| {
if (entry.kind != .file) continue;
if (!std.mem.endsWith(u8, entry.name, ".cache") and
!std.mem.endsWith(u8, entry.name, ".cache.tmp")) continue;
self.dir.deleteFile(lp.io, entry.name) catch |e| {
log.err(.cache, "clear delete fail", .{ .file = entry.name, .err = e });
};
}
}
pub fn evict(self: *FsCache, url: []const u8) void {
const hashed_key = hashKey(url);
const cache_p = cachePath(&hashed_key);
const lock = self.getLockPtr(&hashed_key);
lock.lockUncancelable(lp.io);
defer lock.unlock(lp.io);
self.dir.deleteFile(lp.io, &cache_p) catch |e| switch (e) {
error.FileNotFound => {},
else => log.warn(.cache, "evict failed", .{ .url = url, .err = e }),
};
}
pub fn renew(self: *FsCache, arena: std.mem.Allocator, req: RenewResponse) !void {
const hashed_key = hashKey(req.url);
const cache_p = cachePath(&hashed_key);
const lock = self.getLockPtr(&hashed_key);
lock.lockUncancelable(lp.io);
defer lock.unlock(lp.io);
const file = self.dir.openFile(lp.io, &cache_p, .{ .mode = .read_only }) catch |e| {
log.warn(.cache, "renew open failed", .{ .url = req.url, .err = e });
return e;
};
defer file.close(lp.io);
var file_buf: [1024]u8 = undefined;
var file_reader = file.reader(lp.io, &file_buf);
const r = &file_reader.interface;
var len_buf: [BODY_LEN_HEADER_LEN]u8 = undefined;
r.readSliceAll(&len_buf) catch |e| {
log.warn(.cache, "renew read len", .{ .url = req.url, .err = e });
return e;
};
const body_len = std.mem.readInt(u64, &len_buf, .little);
try file_reader.seekTo(BODY_LEN_HEADER_LEN + body_len);
var json_reader = std.json.Reader.init(arena, r);
var parsed: CacheMetadataJson = std.json.parseFromTokenSourceLeaky(
CacheMetadataJson,
arena,
&json_reader,
.{ .allocate = .alloc_always },
) catch |e| {
log.warn(.cache, "renew parse", .{ .url = req.url, .err = e });
return e;
};
parsed.metadata.renew(req);
try file_reader.seekTo(BODY_LEN_HEADER_LEN);
self.writeCacheFile(&hashed_key, r, body_len, parsed.metadata) catch |e| {
log.warn(.cache, "renew write", .{ .url = req.url, .err = e });
return e;
};
log.debug(.cache, "renewed", .{ .url = req.url });
}
const testing = std.testing;
fn setupCache() !struct { tmp: testing.TmpDir, cache: Cache } {
var tmp = testing.tmpDir(.{});
errdefer tmp.cleanup();
const path = try tmp.dir.realPathFileAlloc(lp.io, ".", testing.allocator);
defer testing.allocator.free(path);
return .{
.tmp = tmp,
.cache = Cache{ .kind = .{ .fs = try FsCache.init(path) } },
};
}
test "FsCache: basic put and get" {
var setup = try setupCache();
defer {
setup.cache.deinit();
setup.tmp.cleanup();
}
const cache = &setup.cache;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const now = lp.datetime.timestamp(.real);
const meta = CachedMetadata{
.url = "https://example.com",
.content_type = "text/html",
.status = 200,
.stored_at = now,
.age_at_store = 0,
.cache_control = .{ .max_age = 600 },
.headers = &.{},
.vary_headers = &.{},
};
const body = "hello world";
try cache.put(meta, body);
const result = cache.get(
arena.allocator(),
.{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{},
},
) orelse return error.CacheMiss;
const f = result.data.file;
const file = f.file;
defer file.close(lp.io);
var buf: [64]u8 = undefined;
var file_reader = file.reader(lp.io, &buf);
try file_reader.seekTo(f.offset);
const read_buf = try file_reader.interface.readAlloc(testing.allocator, f.len);
defer testing.allocator.free(read_buf);
try testing.expectEqualStrings(body, read_buf);
}
test "FsCache: get expiration" {
var setup = try setupCache();
defer {
setup.cache.deinit();
setup.tmp.cleanup();
}
const cache = &setup.cache;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const now = 5000;
const max_age = 1000;
const meta = CachedMetadata{
.url = "https://example.com",
.content_type = "text/html",
.status = 200,
.stored_at = now,
.age_at_store = 900,
.cache_control = .{ .max_age = max_age },
.headers = &.{},
.vary_headers = &.{},
};
const body = "hello world";
try cache.put(meta, body);
const result = cache.get(
arena.allocator(),
.{
.url = "https://example.com",
.timestamp = now + 50,
.request_headers = &.{},
},
) orelse return error.CacheMiss;
result.data.file.file.close(lp.io);
// Expired: age = 200 + 900 = 1100 >= 1000
const stale = cache.get(
arena.allocator(),
.{
.url = "https://example.com",
.timestamp = now + 200,
.request_headers = &.{},
},
) orelse return error.CacheMiss;
defer stale.data.file.file.close(lp.io);
try testing.expectEqual(true, stale.expired);
}
test "FsCache: put override" {
var setup = try setupCache();
defer {
setup.cache.deinit();
setup.tmp.cleanup();
}
const cache = &setup.cache;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
{
const now = 5000;
const max_age = 1000;
const meta = CachedMetadata{
.url = "https://example.com",
.content_type = "text/html",
.status = 200,
.stored_at = now,
.age_at_store = 900,
.cache_control = .{ .max_age = max_age },
.headers = &.{},
.vary_headers = &.{},
};
const body = "hello world";
try cache.put(meta, body);
const result = cache.get(
arena.allocator(),
.{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{},
},
) orelse return error.CacheMiss;
const f = result.data.file;
const file = f.file;
defer file.close(lp.io);
var buf: [64]u8 = undefined;
var file_reader = file.reader(lp.io, &buf);
try file_reader.seekTo(f.offset);
const read_buf = try file_reader.interface.readAlloc(testing.allocator, f.len);
defer testing.allocator.free(read_buf);
try testing.expectEqualStrings(body, read_buf);
}
{
const now = 10000;
const max_age = 2000;
const meta = CachedMetadata{
.url = "https://example.com",
.content_type = "text/html",
.status = 200,
.stored_at = now,
.age_at_store = 0,
.cache_control = .{ .max_age = max_age },
.headers = &.{},
.vary_headers = &.{},
};
const body = "goodbye world";
try cache.put(meta, body);
const result = cache.get(
arena.allocator(),
.{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{},
},
) orelse return error.CacheMiss;
const f = result.data.file;
const file = f.file;
defer file.close(lp.io);
var buf: [64]u8 = undefined;
var file_reader = file.reader(lp.io, &buf);
try file_reader.seekTo(f.offset);
const read_buf = try file_reader.interface.readAlloc(testing.allocator, f.len);
defer testing.allocator.free(read_buf);
try testing.expectEqualStrings(body, read_buf);
}
}
test "FsCache: garbage file" {
@import("../../testing.zig").silenceLog(&.{.cache});
var setup = try setupCache();
defer {
setup.cache.deinit();
setup.tmp.cleanup();
}
const hashed_key = hashKey("https://example.com");
const cache_p = cachePath(&hashed_key);
const file = try setup.cache.kind.fs.dir.createFile(lp.io, &cache_p, .{});
try file.writeStreamingAll(lp.io, "this is not a valid cache file !@#$%");
file.close(lp.io);
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
try testing.expectEqual(
null,
setup.cache.get(arena.allocator(), .{
.url = "https://example.com",
.timestamp = 5000,
.request_headers = &.{},
}),
);
}
test "FsCache: vary hit and miss" {
var setup = try setupCache();
defer {
setup.cache.deinit();
setup.tmp.cleanup();
}
const cache = &setup.cache;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const now = lp.datetime.timestamp(.real);
const meta = CachedMetadata{
.url = "https://example.com",
.content_type = "text/html",
.status = 200,
.stored_at = now,
.age_at_store = 0,
.cache_control = .{ .max_age = 600 },
.headers = &.{},
.vary_headers = &.{
.{ .name = "Accept-Encoding", .value = "gzip" },
},
};
try cache.put(meta, "hello world");
const result = cache.get(arena.allocator(), .{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{
.{ .name = "Accept-Encoding", .value = "gzip" },
},
}) orelse return error.CacheMiss;
result.data.file.file.close(lp.io);
try testing.expectEqual(null, cache.get(arena.allocator(), .{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{
.{ .name = "Accept-Encoding", .value = "br" },
},
}));
try testing.expectEqual(null, cache.get(arena.allocator(), .{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{},
}));
const result2 = cache.get(arena.allocator(), .{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{
.{ .name = "Accept-Encoding", .value = "gzip" },
},
}) orelse return error.CacheMiss;
result2.data.file.file.close(lp.io);
}
test "FsCache: vary multiple headers" {
var setup = try setupCache();
defer {
setup.cache.deinit();
setup.tmp.cleanup();
}
const cache = &setup.cache;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const now = lp.datetime.timestamp(.real);
const meta = CachedMetadata{
.url = "https://example.com",
.content_type = "text/html",
.status = 200,
.stored_at = now,
.age_at_store = 0,
.cache_control = .{ .max_age = 600 },
.headers = &.{},
.vary_headers = &.{
.{ .name = "Accept-Encoding", .value = "gzip" },
.{ .name = "Accept-Language", .value = "en" },
},
};
try cache.put(meta, "hello world");
const result = cache.get(arena.allocator(), .{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{
.{ .name = "Accept-Encoding", .value = "gzip" },
.{ .name = "Accept-Language", .value = "en" },
},
}) orelse return error.CacheMiss;
result.data.file.file.close(lp.io);
try testing.expectEqual(null, cache.get(arena.allocator(), .{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{
.{ .name = "Accept-Encoding", .value = "gzip" },
.{ .name = "Accept-Language", .value = "fr" },
},
}));
}
test "FsCache: clear removes all entries" {
var setup = try setupCache();
defer {
setup.cache.deinit();
setup.tmp.cleanup();
}
const cache = &setup.cache;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const now = lp.datetime.timestamp(.real);
const base_meta_a = CachedMetadata{
.url = "https://example.com/a",
.status = 200,
.stored_at = now,
.age_at_store = 0,
.cache_control = .{ .max_age = 600 },
.headers = &.{},
.vary_headers = &.{},
.content_type = "text/html",
};
const base_meta_b = CachedMetadata{
.url = "https://example.com/b",
.status = 200,
.stored_at = now,
.age_at_store = 0,
.cache_control = .{ .max_age = 600 },
.headers = &.{},
.vary_headers = &.{},
.content_type = "text/html",
};
try cache.put(base_meta_a, "body a");
try cache.put(base_meta_b, "body b");
// Sanity check: both are cached
const r1 = cache.get(
arena.allocator(),
.{
.url = "https://example.com/a",
.timestamp = now,
.request_headers = &.{},
},
);
try testing.expect(r1 != null);
r1.?.data.file.file.close(lp.io);
const r2 = cache.get(
arena.allocator(),
.{
.url = "https://example.com/b",
.timestamp = now,
.request_headers = &.{},
},
);
try testing.expect(r2 != null);
r2.?.data.file.file.close(lp.io);
try cache.clear();
try testing.expectEqual(null, cache.get(
arena.allocator(),
.{
.url = "https://example.com/a",
.timestamp = now,
.request_headers = &.{},
},
));
try testing.expectEqual(null, cache.get(
arena.allocator(),
.{
.url = "https://example.com/b",
.timestamp = now,
.request_headers = &.{},
},
));
}
test "FsCache: put after clear works" {
var setup = try setupCache();
defer {
setup.cache.deinit();
setup.tmp.cleanup();
}
const cache = &setup.cache;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const now = lp.datetime.timestamp(.real);
const meta = CachedMetadata{
.url = "https://example.com",
.content_type = "text/html",
.status = 200,
.stored_at = now,
.age_at_store = 0,
.cache_control = .{ .max_age = 600 },
.headers = &.{},
.vary_headers = &.{},
};
try cache.put(meta, "before clear");
try cache.clear();
// Should be a miss after clear
try testing.expectEqual(
null,
cache.get(
arena.allocator(),
.{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{},
},
),
);
// Put again after clear — should work normally
try cache.put(meta, "after clear");
const result = cache.get(
arena.allocator(),
.{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{},
},
) orelse return error.CacheMiss;
const f = result.data.file;
defer f.file.close(lp.io);
var buf: [64]u8 = undefined;
var file_reader = f.file.reader(lp.io, &buf);
try file_reader.seekTo(f.offset);
const read_buf = try file_reader.interface.readAlloc(testing.allocator, f.len);
defer testing.allocator.free(read_buf);
try testing.expectEqualStrings("after clear", read_buf);
}
test "FsCache: evict removes entry" {
var setup = try setupCache();
defer {
setup.cache.deinit();
setup.tmp.cleanup();
}
const cache = &setup.cache;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const now = lp.datetime.timestamp(.real);
const meta = CachedMetadata{
.url = "https://example.com",
.content_type = "text/html",
.status = 200,
.stored_at = now,
.age_at_store = 0,
.cache_control = .{ .max_age = 600 },
.headers = &.{},
.vary_headers = &.{},
};
try cache.put(meta, "hello world");
const result = cache.get(
arena.allocator(),
.{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{},
},
) orelse return error.CacheMiss;
result.data.file.file.close(lp.io);
cache.evict("https://example.com");
try testing.expectEqual(null, cache.get(
arena.allocator(),
.{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{},
},
));
}
test "FsCache: renew refreshes expiry" {
var setup = try setupCache();
defer {
setup.cache.deinit();
setup.tmp.cleanup();
}
const cache = &setup.cache;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const now: u64 = 5000;
const max_age: u64 = 1000;
const meta = CachedMetadata{
.url = "https://example.com",
.content_type = "text/html",
.status = 200,
.stored_at = now,
.age_at_store = 0,
.cache_control = .{ .max_age = max_age },
.headers = &.{},
.vary_headers = &.{},
};
try cache.put(meta, "hello world");
// renew while still fresh at now+500
try cache.renew(
arena.allocator(),
.{
.url = "https://example.com",
.timestamp = now + 500,
.headers = &.{},
},
);
// Without revalidation would expire at now+1000, but clock reset to now+500
// so still fresh at now+1200
const r1 = cache.get(
arena.allocator(),
.{
.url = "https://example.com",
.timestamp = now + 1200,
.request_headers = &.{},
},
) orelse return error.CacheMiss;
r1.data.file.file.close(lp.io);
// Expires at now+500+1000 = now+1500
const stale1 = cache.get(
arena.allocator(),
.{
.url = "https://example.com",
.timestamp = now + 1500,
.request_headers = &.{},
},
) orelse return error.CacheMiss;
stale1.data.file.file.close(lp.io);
try testing.expectEqual(true, stale1.expired);
}
test "FsCache: renew preserves body" {
var setup = try setupCache();
defer {
setup.cache.deinit();
setup.tmp.cleanup();
}
const cache = &setup.cache;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const now = lp.datetime.timestamp(.real);
const meta = CachedMetadata{
.url = "https://example.com",
.content_type = "text/html",
.status = 200,
.stored_at = now,
.age_at_store = 0,
.cache_control = .{ .max_age = 600 },
.headers = &.{},
.vary_headers = &.{},
};
const body = "original body";
try cache.put(meta, body);
try cache.renew(
arena.allocator(),
.{
.url = "https://example.com",
.timestamp = now + 100,
.headers = &.{},
},
);
const result = cache.get(arena.allocator(), .{
.url = "https://example.com",
.timestamp = now + 100,
.request_headers = &.{},
}) orelse return error.CacheMiss;
const f = result.data.file;
defer f.file.close(lp.io);
var buf: [64]u8 = undefined;
var file_reader = f.file.reader(lp.io, &buf);
try file_reader.seekTo(f.offset);
const read_buf = try file_reader.interface.readAlloc(testing.allocator, f.len);
defer testing.allocator.free(read_buf);
try testing.expectEqualStrings(body, read_buf);
}
+8 -4
View File
@@ -71,6 +71,13 @@ pub const Header = struct {
value: []const u8,
};
pub fn normalize(self: Header, allocator: std.mem.Allocator) !Header {
return .{
.name = try std.ascii.allocLowerString(allocator, self.name),
.value = try allocator.dupe(u8, self.value),
};
}
pub fn parse(header_str: []const u8) ?Header {
const colon_pos = std.mem.indexOfScalar(u8, header_str, ':') orelse return null;
@@ -147,10 +154,7 @@ pub const HeaderIterator = union(enum) {
var list: std.ArrayList(Header) = .empty;
while (self.next()) |hdr| {
try list.append(allocator, .{
.name = try allocator.dupe(u8, hdr.name),
.value = try allocator.dupe(u8, hdr.value),
});
try list.append(allocator, try hdr.normalize(allocator));
}
return list;
+35
View File
@@ -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> {
+2 -1
View File
@@ -975,7 +975,8 @@ fn stringToOwned(
self.env.isolate.handle,
buf.ptr,
buf.len,
v8.NO_NULL_TERMINATION | v8.REPLACE_INVALID_UTF8,
v8.WRITE_REPLACE_INVALID_UTF8,
null,
);
return buf[0..written];
}
+349
View File
@@ -2221,6 +2221,352 @@ test "server: HTTP element commands" {
}
}
test "server: HTTP element input" {
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/input.html";
try testing.expectEqual("{\"value\":null}", responseBody(try sessionCommand(&c, "POST", &session_id, "/url", "{\"url\":\"" ++ url ++ "\"}")));
const take_events = "var e = window.events.join(' '); window.events = []; return e;";
const btn = try findElement(&c, &session_id, "css selector", "#btn");
try testing.expectEqual("{\"value\":null}", responseBody(try elementPost(&c, &session_id, btn, "/click", "{}")));
try testing.expectEqual("{\"value\":\"focus@btn click@btn\"}", try executeSync(&c, &session_id, take_events, "[]"));
{
const gone = try findElement(&c, &session_id, "css selector", "#gone");
const res = try elementPost(&c, &session_id, gone, "/click", "{}");
try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 400 Bad Request\r\n"));
try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"element not interactable\"") != null);
}
// clear: focus, the edit's input and change, blur
const name = try findElement(&c, &session_id, "css selector", "#name");
try testing.expectEqual("{\"value\":null}", responseBody(try elementPost(&c, &session_id, name, "/clear", "{}")));
try testing.expectEqual("{\"value\":\"\"}", try elementCommand(&c, &session_id, name, "/property/value"));
try testing.expectEqual("{\"value\":\"blur@btn focus@name input@name change@name blur@name\"}", try executeSync(&c, &session_id, take_events, "[]"));
// already empty: nothing happens at all
try testing.expectEqual("{\"value\":null}", responseBody(try elementPost(&c, &session_id, name, "/clear", "{}")));
try testing.expectEqual("{\"value\":\"\"}", try executeSync(&c, &session_id, take_events, "[]"));
for ([_][]const u8{ "#ro", "#check" }) |selector| {
const element = try findElement(&c, &session_id, "css selector", selector);
const res = try elementPost(&c, &session_id, element, "/clear", "{}");
try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 400 Bad Request\r\n"));
try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"invalid element state\"") != null);
}
// a modifier stays down until it's typed again
try testing.expectEqual("{\"value\":null}", responseBody(try elementPost(&c, &session_id, name, "/value", "{\"text\":\"\\uE008a\\uE008b\"}")));
try testing.expectEqual("{\"value\":\"Ab\"}", try elementCommand(&c, &session_id, name, "/property/value"));
try testing.expectEqual(
"{\"value\":\"focus@name keydown:S-Shift@name keydown:S-A@name input@name keydown:b@name input@name\"}",
try executeSync(&c, &session_id, take_events, "[]"),
);
// focusing puts the caret at the end
{
const area = try findElement(&c, &session_id, "css selector", "#area");
try testing.expectEqual("{\"value\":null}", responseBody(try elementPost(&c, &session_id, area, "/value", "{\"text\":\"!\"}")));
try testing.expectEqual("{\"value\":\"text!\"}", try elementCommand(&c, &session_id, area, "/property/value"));
}
{
const plain = try findElement(&c, &session_id, "css selector", "#plain");
const res = try elementPost(&c, &session_id, plain, "/value", "{\"text\":\"x\"}");
try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 400 Bad Request\r\n"));
try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"element not interactable\"") != null);
}
// an option is clicked by selecting it
{
_ = try executeSync(&c, &session_id, take_events, "[]");
const opt_b = try findElement(&c, &session_id, "css selector", "#opt_b");
try testing.expectEqual("{\"value\":null}", responseBody(try elementPost(&c, &session_id, opt_b, "/click", "{}")));
try testing.expectEqual("{\"value\":true}", try elementCommand(&c, &session_id, opt_b, "/selected"));
const opt_a = try findElement(&c, &session_id, "css selector", "#opt_a");
try testing.expectEqual("{\"value\":false}", try elementCommand(&c, &session_id, opt_a, "/selected"));
try testing.expectEqual("{\"value\":\"blur@area focus@pick click@opt_b input@pick change@pick\"}", try executeSync(&c, &session_id, take_events, "[]"));
}
// a click that navigates is answered once the new page has loaded
{
const next = try findElement(&c, &session_id, "css selector", "#next");
try testing.expectEqual("{\"value\":null}", responseBody(try elementPost(&c, &session_id, next, "/click", "{}")));
try testing.expectEqual("{\"value\":\"webdriver elements\"}", responseBody(try sessionCommand(&c, "GET", &session_id, "/title", "")));
}
// and so is Enter in a form
{
try testing.expectEqual("{\"value\":null}", responseBody(try sessionCommand(&c, "POST", &session_id, "/url", "{\"url\":\"" ++ url ++ "\"}")));
const q = try findElement(&c, &session_id, "css selector", "#q");
try testing.expectEqual("{\"value\":null}", responseBody(try elementPost(&c, &session_id, q, "/value", "{\"text\":\"hi\\n\"}")));
try testing.expectEqual(
"{\"value\":\"http://127.0.0.1:9582/src/browser/tests/webdriver/elements.html?q=hi\"}",
responseBody(try sessionCommand(&c, "GET", &session_id, "/url", "")),
);
}
}
fn elementPost(c: *TestClient, session_id: *const [36]u8, id: []const u8, suffix: []const u8, body: []const u8) ![]const u8 {
const path = try std.fmt.allocPrint(testing.arena_allocator, "/element/{s}{s}", .{ id, suffix });
return sessionCommand(c, "POST", session_id, path, body);
}
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 node in another document is stale, connected or not
{
const body = try executeSync(&c, &session_id, "return document.getElementById('child').contentDocument.getElementById('inner');", "[]");
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;
const path = try std.fmt.allocPrint(testing.arena_allocator, "/element/{s}/text", .{reference});
const res = try sessionCommand(&c, "GET", &session_id, path, "");
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\":\"stale element reference\"") != null);
const args = try std.fmt.allocPrint(testing.arena_allocator, "[{{\"" ++ http_command.element_key ++ "\":\"{s}\"}}]", .{reference});
const arg_res = try executeRaw(&c, &session_id, "sync", "return 1;", args);
try testing.expect(std.mem.indexOf(u8, arg_res, "\"error\":\"stale element reference\"") != 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);
// the assertion only means anything if the stale resolve actually ran
var ran = false;
for (0..200) |_| {
if (std.mem.eql(u8, "{\"value\":true}", try executeSync(&c, &session_id, "return window.__late === true;", "[]"))) {
ran = true;
break;
}
lp.io.sleep(.fromMilliseconds(10), .awake) catch {};
}
try testing.expect(ran);
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));
@@ -2773,6 +3119,9 @@ fn createTestClient() !TestClient {
});
try posix.setsockopt(socket, posix.SOL.SOCKET, posix.SO.RCVTIMEO, &timeout);
try posix.setsockopt(socket, posix.SOL.SOCKET, posix.SO.SNDTIMEO, &timeout);
if (@hasDecl(posix.TCP, "NODELAY")) {
try posix.setsockopt(socket, posix.IPPROTO.TCP, posix.TCP.NODELAY, &std.mem.toBytes(@as(c_int, 1)));
}
return .{
.socket = socket,
.reader = .{
+18
View File
@@ -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| {
+12
View File
@@ -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;
@@ -267,6 +268,16 @@ fn startNavigation(cmd: *BiDi.Command, ctx: *Context, frame: *Frame, url: [:0]co
}
}
// For commands that start a navigation, e.g. clicking a link.
pub fn answerAfterNavigation(cmd: *BiDi.Command, ctx: *Context, frame: *const Frame) !void {
if (frame._queued_navigation == null) {
return cmd.sendDone();
}
// There was an already queued navigation, reject the previous pending command
try rejectPending(cmd.bidi, ctx, "navigation superseded");
ctx.pending_navigate = .{ .reply = cmd.takeReply(), .until = .complete };
}
fn close(cmd: *BiDi.Command) !void {
const p = try cmd.params(struct {
context: []const u8,
@@ -547,6 +558,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 {
+581
View File
@@ -0,0 +1,581 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// 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, frame) 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 not in the current 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,
frame: *const Frame,
) !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, frame), .{}) == 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, frame);
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.*, frame);
if (try object.set(entry.key_ptr.*, item, .{}) == false) {
return error.InvalidArgument;
}
}
return object.toValue();
},
}
}
+308 -41
View File
@@ -26,14 +26,17 @@ 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");
const Html = Node.Element.Html;
const Allocator = std.mem.Allocator;
// The key of a WebDriver element reference: {"element-6066-…": "<sharedId>"}
@@ -63,14 +66,13 @@ 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,
element_click: ElementId,
element_clear: ElementId,
element_send_keys: SendKeys,
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
@@ -225,9 +227,16 @@ const routes = [_]Route{
.init(.GET, "/element/{id}/rect", .get_element_rect),
.init(.GET, "/element/{id}/enabled", .is_element_enabled),
.init(.GET, "/element/{id}/selected", .is_element_selected),
.init(.POST, "/element/{id}/click", .element_click),
.init(.POST, "/element/{id}/clear", .element_clear),
.init(.POST, "/element/{id}/value", .element_send_keys),
.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 +332,20 @@ 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),
.element_click => |p| return elementClick(cmd, p),
.element_clear => |p| return elementClear(cmd, p),
.element_send_keys => |p| return elementSendKeys(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 +398,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 +420,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 +431,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);
}
@@ -417,7 +439,7 @@ fn findElements(cmd: *BiDi.Command, using: Using, value: []const u8, from: ?[]co
// The document, or the element a "from element" search starts at.
fn findRoot(cmd: *BiDi.Command, frame: *Frame, from: ?[]const u8) !?*Node {
const id = from orelse return frame.window._document.asNode();
const element = (try requireElement(cmd, id)) orelse return null;
const element = (try requireElement(cmd, id, frame)) orelse return null;
return element.asNode();
}
@@ -441,13 +463,13 @@ 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.
fn getElementText(cmd: *BiDi.Command, p: ElementId) !void {
const frame = (try currentFrame(cmd)) orelse return;
const element = (try requireElement(cmd, p.id)) orelse return;
const element = (try requireElement(cmd, p.id, frame)) orelse return;
var aw: std.Io.Writer.Allocating = .init(cmd.arena);
element.getInnerText(&aw.writer, frame) catch |err| switch (err) {
@@ -459,14 +481,15 @@ fn getElementText(cmd: *BiDi.Command, p: ElementId) !void {
// GET /session/{id}/element/{id}/name. Lowercase, like every other driver.
fn getElementTagName(cmd: *BiDi.Command, p: ElementId) !void {
const element = (try requireElement(cmd, p.id)) orelse return;
const frame = (try currentFrame(cmd)) orelse return;
const element = (try requireElement(cmd, p.id, frame)) orelse return;
return cmd.sendResult(element.getTagNameLower());
}
// GET /session/{id}/element/{id}/attribute/{name}
fn getElementAttribute(cmd: *BiDi.Command, p: ElementName) !void {
const frame = (try currentFrame(cmd)) orelse return;
const element = (try requireElement(cmd, p.id)) orelse return;
const element = (try requireElement(cmd, p.id, frame)) orelse return;
if (isBooleanAttribute(p.name)) {
// a boolean attribute is "true" or nothing at all, never its value
@@ -484,7 +507,7 @@ fn getElementAttribute(cmd: *BiDi.Command, p: ElementName) !void {
// GET /session/{id}/element/{id}/property/{name}
fn getElementProperty(cmd: *BiDi.Command, p: ElementName) !void {
const frame = (try currentFrame(cmd)) orelse return;
const element = (try requireElement(cmd, p.id)) orelse return;
const element = (try requireElement(cmd, p.id, frame)) orelse return;
var scope: js.Local.Scope = undefined;
frame.js.localScope(&scope);
@@ -500,7 +523,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));
}
}
}
@@ -510,7 +533,7 @@ fn getElementProperty(cmd: *BiDi.Command, p: ElementName) !void {
// GET /session/{id}/element/{id}/css/{name}
fn getElementCssValue(cmd: *BiDi.Command, p: ElementName) !void {
const frame = (try currentFrame(cmd)) orelse return;
const element = (try requireElement(cmd, p.id)) orelse return;
const element = (try requireElement(cmd, p.id, frame)) orelse return;
const style = try frame.window.getComputedStyle(element, null, frame);
return cmd.sendResult(style.asCSSStyleDeclaration().getPropertyValue(p.name, frame));
}
@@ -519,7 +542,7 @@ fn getElementCssValue(cmd: *BiDi.Command, p: ElementName) !void {
// however far the page is scrolled.
fn getElementRect(cmd: *BiDi.Command, p: ElementId) !void {
const frame = (try currentFrame(cmd)) orelse return;
const element = (try requireElement(cmd, p.id)) orelse return;
const element = (try requireElement(cmd, p.id, frame)) orelse return;
const rect = try element.getBoundingClientRect(frame);
const window = frame.window;
return cmd.sendResult(.{
@@ -532,13 +555,15 @@ fn getElementRect(cmd: *BiDi.Command, p: ElementId) !void {
// GET /session/{id}/element/{id}/enabled
fn isElementEnabled(cmd: *BiDi.Command, p: ElementId) !void {
const element = (try requireElement(cmd, p.id)) orelse return;
const frame = (try currentFrame(cmd)) orelse return;
const element = (try requireElement(cmd, p.id, frame)) orelse return;
return cmd.sendResult(element.isDisabled() == false);
}
// GET /session/{id}/element/{id}/selected
fn isElementSelected(cmd: *BiDi.Command, p: ElementId) !void {
const element = (try requireElement(cmd, p.id)) orelse return;
const frame = (try currentFrame(cmd)) orelse return;
const element = (try requireElement(cmd, p.id, frame)) orelse return;
if (element.is(Node.Element.Html.Input)) |input_element| {
return cmd.sendResult(switch (input_element._input_type) {
@@ -552,11 +577,207 @@ fn isElementSelected(cmd: *BiDi.Command, p: ElementId) !void {
return cmd.sendResult(false);
}
// POST /session/{id}/element/{id}/click.
fn elementClick(cmd: *BiDi.Command, p: ElementId) !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("invalid argument", "a file input can't be clicked");
}
}
if ((try requireInteractable(cmd, element, frame)) == false) {
return;
}
if (element.is(Html.Option)) |option| blk: {
const select = option.ownerSelect() orelse break :blk;
const select_element = select.asElement();
if (select_element.isDisabled() or element.isDisabled()) {
break :blk;
}
Frame.user_input.updateHoverTarget(frame, element, .{ .with_pointer = true });
try Frame.user_input.triggerClick(frame, element, .{});
// a multiple <select> 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;
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-…": "<sharedId>"}: 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 +786,36 @@ 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,
};
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 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 {
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;
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 not in the current 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 +900,45 @@ 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);
}
{
// 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", "{}"));
// 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);
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff. Show more