From 4dd7535e6d564e094a7800cfc18e9cd171b96d03 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 2 Jun 2026 12:39:19 +0800 Subject: [PATCH 1/8] Don't allow document.close during document.write 56181bbe6cff14701940ec3db29eae96867f90a3 protected against a document.write generating a document.write. This protects against a document.write generating a document.close. We cannot immediately close, so we 'queue' the close (via a boolean) and defer it until the write is complete. Fixes crash in WPT: /html/webappapis/dynamic-markup-insertion/document-write/iframe_010.html From what I can tell, this is the last one of these. --- .../reentrant_document_close.html | 58 +++++++++++++++++++ src/browser/webapi/Document.zig | 29 +++++++++- 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 src/browser/tests/custom_elements/reentrant_document_close.html diff --git a/src/browser/tests/custom_elements/reentrant_document_close.html b/src/browser/tests/custom_elements/reentrant_document_close.html new file mode 100644 index 00000000..02fc5491 --- /dev/null +++ b/src/browser/tests/custom_elements/reentrant_document_close.html @@ -0,0 +1,58 @@ + + + + + + + + + diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index eff8a70b..93f3dc38 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -63,6 +63,7 @@ _implementation: ?*DOMImplementation = null, _fonts: ?*FontFaceSet = null, _write_insertion_point: ?*Node = null, _script_created_parser: ?Parser.Streaming = null, +_close_requested: bool = false, _adopted_style_sheets: ?js.Object.Global = null, _selection: Selection = .{ ._rc = .init(1) }, @@ -764,9 +765,22 @@ fn writeInternal(self: *Document, text: []const []const u8, append_newline: bool log.warn(.dom, "flush after parser panic", .{ .err = flush_err }); }; self._script_created_parser = null; + self._close_requested = false; }; } } + + if (self._close_requested) { + // document.close was executed during a document.write. We couldn't + // execute that during the write, but we can now. + if (self._script_created_parser) |*parser| { + if (parser.feeding == false) { + try self.finishScriptCreatedParser(frame); + } + } else { + self._close_requested = false; + } + } return; } @@ -891,10 +905,23 @@ pub fn close(self: *Document, call_frame: *Frame) !void { return error.InvalidStateError; } - if (self._script_created_parser == null) { + if (self._script_created_parser) |*parser| { + if (parser.feeding) { + // we're currently in a document.write, we cannot close. We flag + // the close and process it at the next safe spot. + self._close_requested = true; + return; + } + } else { return; } + try self.finishScriptCreatedParser(frame); +} + +fn finishScriptCreatedParser(self: *Document, frame: *Frame) !void { + self._close_requested = false; + // done() finishes html5ever's handle and runs the final flushPendingText. // Even if flushPendingText errors, the handle is already finished and we // must not retain the Streaming — defer so the error path also drops it. From a7355a57625a4cf4ec7b9b79fc6f91f2a26a25ca Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 2 Jun 2026 13:00:17 +0800 Subject: [PATCH 2/8] Less arena-reuse (retain) in Debug Arena reuse/retain can hide UAF issues, often resulting in a crash that is more symptom than cause (far from where the error actually is). Removing this, lets us better utilize the DebugAllocator's UAF-detection. Also, when running WPT tests (-Dwpt_extensions) limit console logging to 100 values (a few tests writer millions of values, which is annoying and just destroys the terminal). --- src/ArenaPool.zig | 12 +++++++++--- src/browser/webapi/Console.zig | 15 ++++++++++++--- src/cdp/domains/page.zig | 2 +- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/ArenaPool.zig b/src/ArenaPool.zig index 36f7c71d..29d2a0da 100644 --- a/src/ArenaPool.zig +++ b/src/ArenaPool.zig @@ -28,6 +28,9 @@ const ArenaPool = @This(); const IS_DEBUG = builtin.mode == .Debug; +// In Debug, disable pooling to better catch UAF. +const SAFETY = IS_DEBUG == true and builtin.is_test == false; + pub const BucketSize = enum { tiny, small, medium, large }; const Bucket = struct { @@ -187,7 +190,8 @@ pub fn release(self: *ArenaPool, allocator: Allocator) void { } } - if (bucket.free_list_len >= bucket.free_list_max) { + if ((comptime SAFETY) or bucket.free_list_len >= bucket.free_list_max) { + // In Debug, we never pool. It can mask UAF bugs. arena.deinit(); self.entry_pool.destroy(entry); return; @@ -200,12 +204,14 @@ pub fn release(self: *ArenaPool, allocator: Allocator) void { pub fn reset(_: *const ArenaPool, allocator: Allocator, retain: usize) void { const arena: *ArenaAllocator = @ptrCast(@alignCast(allocator.ptr)); - _ = arena.reset(.{ .retain_with_limit = retain }); + // In Debug, free_all, it's less likely to hide things + _ = arena.reset(if (comptime SAFETY) .free_all else .{ .retain_with_limit = retain }); } pub fn resetRetain(_: *const ArenaPool, allocator: Allocator) void { const arena: *ArenaAllocator = @ptrCast(@alignCast(allocator.ptr)); - _ = arena.reset(.retain_capacity); + // In Debug, free_all, it's less likely to hide things + _ = arena.reset(if (comptime SAFETY) .free_all else .retain_capacity); } const testing = std.testing; diff --git a/src/browser/webapi/Console.zig b/src/browser/webapi/Console.zig index eea5e700..3d97325a 100644 --- a/src/browser/webapi/Console.zig +++ b/src/browser/webapi/Console.zig @@ -175,7 +175,7 @@ const ValueWriter = struct { stack: ?[]const u8 = null, pub fn format(self: ValueWriter, writer: *std.io.Writer) !void { - for (self.values, 1..) |value, i| { + for (self.valuesToLog(), 1..) |value, i| { try writer.print("\n arg({d}): {f}", .{ i, value }); } if (self.stack) |s| { @@ -185,7 +185,7 @@ const ValueWriter = struct { pub fn logFmt(self: ValueWriter, _: []const u8, writer: anytype) !void { var buf: [32]u8 = undefined; - for (self.values, 0..) |value, i| { + for (self.valuesToLog(), 0..) |value, i| { const name = try std.fmt.bufPrint(&buf, "param.{d}", .{i}); try writer.write(name, value); } @@ -193,11 +193,20 @@ const ValueWriter = struct { pub fn jsonStringify(self: ValueWriter, writer: *std.json.Stringify) !void { try writer.beginArray(); - for (self.values) |value| { + for (self.valuesToLog()) |value| { try writer.write(value); } return writer.endArray(); } + + fn valuesToLog(self: ValueWriter) []js.Value { + if (lp.build_config.wpt_extensions) { + // A few WPT tests print HUGE arrays, it's at best, annoying when + // running it locally + return self.values[0..@min(self.values.len, 100)]; + } + return self.values; + } }; pub const JsApi = struct { diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 71aaeafb..ec8ad40a 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -535,7 +535,7 @@ pub fn frameCreated(bc: *CDP.BrowserContext, frame: *Frame) !void { const in_commit = bc.session.pendingPage() != null; if (!in_commit) { - _ = bc.cdp.frame_arena.reset(.{ .retain_with_limit = 1024 * 512 }); + bc.cdp.browser.arena_pool.reset(bc.frame_arena, 1024 * 512); } for (bc.isolated_worlds.items) |isolated_world| { From e3e3fc25fe6db2571f51d09b0da5da9c3dd7dc41 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 2 Jun 2026 17:33:37 +0800 Subject: [PATCH 3/8] Various HTML attribute tweaks Aimed at improving WPT /html/dom/reflection-obsolete.html Goes from 923 to 2305 passing cases (the remaining failing cases are all for which we don't currently support) Add accessors to Directory, Font and FrameSet. Add HTMLMarqueeElement. Font setColor null -> "" Add new properties to Html (accessKey and autofocus) and improve tabIndex parsing. --- src/browser/Frame.zig | 4 +- src/browser/js/bridge.zig | 1 + src/browser/tests/element/html/font.html | 47 ++++++ .../tests/element/html/htmlelement-props.html | 28 ++++ src/browser/tests/element/html/marquee.html | 100 +++++++++++ src/browser/webapi/Element.zig | 3 + .../webapi/css/CSSStyleDeclaration.zig | 2 +- src/browser/webapi/element/Html.zig | 84 +++++++++- src/browser/webapi/element/html/Directory.zig | 15 ++ src/browser/webapi/element/html/Font.zig | 37 +++++ src/browser/webapi/element/html/FrameSet.zig | 20 +++ src/browser/webapi/element/html/Marquee.zig | 156 ++++++++++++++++++ 12 files changed, 486 insertions(+), 11 deletions(-) create mode 100644 src/browser/tests/element/html/font.html create mode 100644 src/browser/tests/element/html/marquee.html create mode 100644 src/browser/webapi/element/html/Marquee.zig diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 8f4876d2..ffafef0d 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -2586,10 +2586,10 @@ pub fn createElementNS(self: *Frame, namespace: Element.Namespace, name: []const .{ ._proto = undefined }, ), asUint("marquee") => return self.createHtmlElementT( - Element.Html.Generic, + Element.Html.Marquee, namespace, attribute_iterator, - .{ ._proto = undefined, ._tag_name = comptime .wrap("marquee"), ._tag = .marquee }, + .{ ._proto = undefined }, ), asUint("address") => return self.createHtmlElementT( Element.Html.Generic, diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 2335d89a..ef215554 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -866,6 +866,7 @@ pub const PageJsApis = flattenTypes(&.{ @import("../webapi/element/html/LI.zig"), @import("../webapi/element/html/Link.zig"), @import("../webapi/element/html/Map.zig"), + @import("../webapi/element/html/Marquee.zig"), @import("../webapi/element/html/Media.zig"), @import("../webapi/element/html/Meta.zig"), @import("../webapi/element/html/Meter.zig"), diff --git a/src/browser/tests/element/html/font.html b/src/browser/tests/element/html/font.html new file mode 100644 index 00000000..bd3d6d31 --- /dev/null +++ b/src/browser/tests/element/html/font.html @@ -0,0 +1,47 @@ + + + + + + + + diff --git a/src/browser/tests/element/html/htmlelement-props.html b/src/browser/tests/element/html/htmlelement-props.html index a008adc5..6e625d4f 100644 --- a/src/browser/tests/element/html/htmlelement-props.html +++ b/src/browser/tests/element/html/htmlelement-props.html @@ -52,5 +52,33 @@ const textarea = document.createElement('textarea'); testing.expectEqual(0, textarea.tabIndex); + + // tabIndex follows the HTML "rules for parsing integers": skip leading + // ASCII whitespace, take an optional sign and the leading run of digits, + // ignoring any trailing junk. + d3.setAttribute('tabindex', ' 7'); + testing.expectEqual(7, d3.tabIndex); + + d3.setAttribute('tabindex', '\t\n\f\r9'); + testing.expectEqual(9, d3.tabIndex); + + d3.setAttribute('tabindex', '7%'); + testing.expectEqual(7, d3.tabIndex); + + d3.setAttribute('tabindex', '1.5'); + testing.expectEqual(1, d3.tabIndex); + + d3.setAttribute('tabindex', '-3'); + testing.expectEqual(-3, d3.tabIndex); + + d3.setAttribute('tabindex', '+4'); + testing.expectEqual(4, d3.tabIndex); + + // Non-numeric values fall back to the element's default (-1 here). + d3.setAttribute('tabindex', 'foo'); + testing.expectEqual(-1, d3.tabIndex); + + d3.setAttribute('tabindex', ''); + testing.expectEqual(-1, d3.tabIndex); } diff --git a/src/browser/tests/element/html/marquee.html b/src/browser/tests/element/html/marquee.html new file mode 100644 index 00000000..12fb4d99 --- /dev/null +++ b/src/browser/tests/element/html/marquee.html @@ -0,0 +1,100 @@ + + + + + + + + + + + + diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 3a26798e..d898dbd7 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -233,6 +233,7 @@ pub fn getTagNameLower(self: *const Element) []const u8 { .li => "li", .link => "link", .map => "map", + .marquee => "marquee", .media => |m| switch (m._type) { .audio => "audio", .video => "video", @@ -313,6 +314,7 @@ pub fn getTagNameSpec(self: *const Element, buf: []u8) []const u8 { .li => "LI", .link => "LINK", .map => "MAP", + .marquee => "MARQUEE", .meta => "META", .media => |m| switch (m._type) { .audio => "AUDIO", @@ -1507,6 +1509,7 @@ pub fn getTag(self: *const Element) Tag { .legend => .legend, .li => .li, .map => .map, + .marquee => .marquee, .ul => .ul, .ol => .ol, .object => .object, diff --git a/src/browser/webapi/css/CSSStyleDeclaration.zig b/src/browser/webapi/css/CSSStyleDeclaration.zig index 323bf3af..12833c41 100644 --- a/src/browser/webapi/css/CSSStyleDeclaration.zig +++ b/src/browser/webapi/css/CSSStyleDeclaration.zig @@ -722,7 +722,7 @@ fn getDefaultDisplay(element: *const Element) []const u8 { .html => |html| { return switch (html._type) { .anchor, .br, .span, .label, .time, .font, .mod, .quote => "inline", - .body, .div, .dl, .p, .heading, .form, .button, .canvas, .details, .dialog, .embed, .head, .html, .hr, .iframe, .img, .input, .li, .link, .meta, .ol, .option, .script, .select, .slot, .style, .template, .textarea, .title, .ul, .media, .area, .base, .datalist, .directory, .fieldset, .frameset, .legend, .map, .meter, .object, .optgroup, .output, .param, .picture, .pre, .progress, .source, .table, .table_caption, .table_cell, .table_col, .table_row, .table_section, .track => "block", + .body, .div, .dl, .p, .heading, .form, .button, .canvas, .details, .dialog, .embed, .head, .html, .hr, .iframe, .img, .input, .li, .link, .meta, .ol, .option, .script, .select, .slot, .style, .template, .textarea, .title, .ul, .media, .area, .base, .datalist, .directory, .fieldset, .frameset, .legend, .map, .marquee, .meter, .object, .optgroup, .output, .param, .picture, .pre, .progress, .source, .table, .table_caption, .table_cell, .table_col, .table_row, .table_section, .track => "block", .generic, .custom, .unknown, .data => blk: { const tag = element.getTagNameLower(); if (isInlineTag(tag)) break :blk "inline"; diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index 9a0a9f0c..96a49cfc 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -61,6 +61,7 @@ pub const Legend = @import("html/Legend.zig"); pub const LI = @import("html/LI.zig"); pub const Link = @import("html/Link.zig"); pub const Map = @import("html/Map.zig"); +pub const Marquee = @import("html/Marquee.zig"); pub const Media = @import("html/Media.zig"); pub const Meta = @import("html/Meta.zig"); pub const Meter = @import("html/Meter.zig"); @@ -151,6 +152,7 @@ pub const Type = union(enum) { li: *LI, link: *Link, map: *Map, + marquee: *Marquee, media: *Media, meta: *Meta, meter: *Meter, @@ -342,14 +344,50 @@ pub fn setHidden(self: *HtmlElement, hidden: bool, frame: *Frame) !void { } pub fn getTabIndex(self: *HtmlElement) i32 { - const attr = self.asElement().getAttributeSafe(comptime .wrap("tabindex")) orelse { - // Per spec, interactive/focusable elements default to 0 when tabindex is absent - return switch (self._type) { - .anchor, .area, .button, .input, .select, .textarea, .iframe => 0, - else => -1, - }; + const default: i32 = switch (self._type) { + .anchor, .area, .button, .input, .select, .textarea, .iframe => 0, + else => -1, }; - return std.fmt.parseInt(i32, attr, 10) catch -1; + const attr = self.asElement().getAttributeSafe(comptime .wrap("tabindex")) orelse return default; + return parseInteger(attr) orelse default; +} + +// HTML integer parsing is lax +pub fn parseInteger(input: []const u8) ?i32 { + var normalized = std.mem.trimStart(u8, input, "\t\n\r\x0c "); + if (normalized.len == 0) { + return null; + } + + var negative = false; + if (normalized[0] == '-') { + negative = true; + normalized = normalized[1..]; + } else if (normalized[0] == '+') { + normalized = normalized[1..]; + } + + if (normalized.len == 0 or std.ascii.isDigit(normalized[0]) == false) { + return null; + } + + var i: usize = 0; + var value: i64 = 0; + while (i < normalized.len and std.ascii.isDigit(normalized[i])) : (i += 1) { + value = value * 10 + (normalized[i] - '0'); + if (value > 2147483648) { + return null; + } + } + + if (negative) { + value = -value; + } + + if (value < -2147483648 or value > 2147483647) { + return null; + } + return @intCast(value); } pub fn setTabIndex(self: *HtmlElement, value: i32, frame: *Frame) !void { @@ -359,13 +397,41 @@ pub fn setTabIndex(self: *HtmlElement, value: i32, frame: *Frame) !void { } pub fn getDir(self: *HtmlElement) []const u8 { - return self.asElement().getAttributeSafe(comptime .wrap("dir")) orelse ""; + // `dir` reflects as a "limited to only known values" enumerated + // attribute: the getter returns the canonical (lowercase) keyword only + // when the content attribute matches one ASCII-case-insensitively, + // otherwise the empty string. + const attr = self.asElement().getAttributeSafe(comptime .wrap("dir")) orelse return ""; + inline for (.{ "ltr", "rtl", "auto" }) |keyword| { + if (std.ascii.eqlIgnoreCase(attr, keyword)) return keyword; + } + return ""; } pub fn setDir(self: *HtmlElement, value: []const u8, frame: *Frame) !void { try self.asElement().setAttributeSafe(comptime .wrap("dir"), .wrap(value), frame); } +pub fn getAccessKey(self: *HtmlElement) []const u8 { + return self.asElement().getAttributeSafe(comptime .wrap("accesskey")) orelse ""; +} + +pub fn setAccessKey(self: *HtmlElement, value: []const u8, frame: *Frame) !void { + try self.asElement().setAttributeSafe(comptime .wrap("accesskey"), .wrap(value), frame); +} + +pub fn getAutofocus(self: *HtmlElement) bool { + return self.asElement().getAttributeSafe(comptime .wrap("autofocus")) != null; +} + +pub fn setAutofocus(self: *HtmlElement, autofocus: bool, frame: *Frame) !void { + if (autofocus) { + try self.asElement().setAttributeSafe(comptime .wrap("autofocus"), .wrap(""), frame); + } else { + try self.asElement().removeAttribute(comptime .wrap("autofocus"), frame); + } +} + pub fn getLang(self: *HtmlElement) []const u8 { return self.asElement().getAttributeSafe(comptime .wrap("lang")) orelse ""; } @@ -1243,6 +1309,8 @@ pub const JsApi = struct { pub const insertAdjacentHTML = bridge.function(HtmlElement.insertAdjacentHTML, .{ .dom_exception = true, .ce_reactions = true }); pub const click = bridge.function(HtmlElement.click, .{}); + pub const accessKey = bridge.accessor(HtmlElement.getAccessKey, HtmlElement.setAccessKey, .{ .ce_reactions = true }); + pub const autofocus = bridge.accessor(HtmlElement.getAutofocus, HtmlElement.setAutofocus, .{ .ce_reactions = true }); pub const dir = bridge.accessor(HtmlElement.getDir, HtmlElement.setDir, .{ .ce_reactions = true }); pub const hidden = bridge.accessor(HtmlElement.getHidden, HtmlElement.setHidden, .{ .ce_reactions = true }); pub const isContentEditable = bridge.accessor(HtmlElement.getIsContentEditable, null, .{}); diff --git a/src/browser/webapi/element/html/Directory.zig b/src/browser/webapi/element/html/Directory.zig index cd9f44d0..e0c488e7 100644 --- a/src/browser/webapi/element/html/Directory.zig +++ b/src/browser/webapi/element/html/Directory.zig @@ -1,4 +1,5 @@ const js = @import("../../../js/js.zig"); +const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); @@ -14,6 +15,18 @@ pub fn asNode(self: *Directory) *Node { return self.asElement().asNode(); } +pub fn getCompact(self: *Directory) bool { + return self.asElement().getAttributeSafe(comptime .wrap("compact")) != null; +} + +pub fn setCompact(self: *Directory, compact: bool, frame: *Frame) !void { + if (compact) { + try self.asElement().setAttributeSafe(comptime .wrap("compact"), .wrap(""), frame); + } else { + try self.asElement().removeAttribute(comptime .wrap("compact"), frame); + } +} + pub const JsApi = struct { pub const bridge = js.Bridge(Directory); @@ -22,4 +35,6 @@ pub const JsApi = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; + + pub const compact = bridge.accessor(Directory.getCompact, Directory.setCompact, .{ .ce_reactions = true }); }; diff --git a/src/browser/webapi/element/html/Font.zig b/src/browser/webapi/element/html/Font.zig index 3c6dde77..fe0a7bea 100644 --- a/src/browser/webapi/element/html/Font.zig +++ b/src/browser/webapi/element/html/Font.zig @@ -1,4 +1,5 @@ const js = @import("../../../js/js.zig"); +const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); @@ -14,6 +15,33 @@ pub fn asNode(self: *Font) *Node { return self.asElement().asNode(); } +pub fn getColor(self: *Font) []const u8 { + return self.asElement().getAttributeSafe(comptime .wrap("color")) orelse ""; +} + +pub fn setColor(self: *Font, value: js.Value, frame: *Frame) !void { + // color is `[LegacyNullToEmptyString] DOMString`: a JS null becomes "", + // not the string "null". + const str: []const u8 = if (value.isNull()) "" else try value.toZig([]const u8); + try self.asElement().setAttributeSafe(comptime .wrap("color"), .wrap(str), frame); +} + +pub fn getFace(self: *Font) []const u8 { + return self.asElement().getAttributeSafe(comptime .wrap("face")) orelse ""; +} + +pub fn setFace(self: *Font, value: []const u8, frame: *Frame) !void { + try self.asElement().setAttributeSafe(comptime .wrap("face"), .wrap(value), frame); +} + +pub fn getSize(self: *Font) []const u8 { + return self.asElement().getAttributeSafe(comptime .wrap("size")) orelse ""; +} + +pub fn setSize(self: *Font, value: []const u8, frame: *Frame) !void { + try self.asElement().setAttributeSafe(comptime .wrap("size"), .wrap(value), frame); +} + pub const JsApi = struct { pub const bridge = js.Bridge(Font); @@ -22,4 +50,13 @@ pub const JsApi = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; + + pub const color = bridge.accessor(Font.getColor, Font.setColor, .{ .ce_reactions = true }); + pub const face = bridge.accessor(Font.getFace, Font.setFace, .{ .ce_reactions = true }); + pub const size = bridge.accessor(Font.getSize, Font.setSize, .{ .ce_reactions = true }); }; + +const testing = @import("../../../../testing.zig"); +test "WebApi: HTML.Font" { + try testing.htmlRunner("element/html/font.html", .{}); +} diff --git a/src/browser/webapi/element/html/FrameSet.zig b/src/browser/webapi/element/html/FrameSet.zig index cafec13d..4e84a25c 100644 --- a/src/browser/webapi/element/html/FrameSet.zig +++ b/src/browser/webapi/element/html/FrameSet.zig @@ -1,4 +1,5 @@ const js = @import("../../../js/js.zig"); +const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); @@ -14,6 +15,22 @@ pub fn asNode(self: *FrameSet) *Node { return self.asElement().asNode(); } +pub fn getCols(self: *FrameSet) []const u8 { + return self.asElement().getAttributeSafe(comptime .wrap("cols")) orelse ""; +} + +pub fn setCols(self: *FrameSet, value: []const u8, frame: *Frame) !void { + try self.asElement().setAttributeSafe(comptime .wrap("cols"), .wrap(value), frame); +} + +pub fn getRows(self: *FrameSet) []const u8 { + return self.asElement().getAttributeSafe(comptime .wrap("rows")) orelse ""; +} + +pub fn setRows(self: *FrameSet, value: []const u8, frame: *Frame) !void { + try self.asElement().setAttributeSafe(comptime .wrap("rows"), .wrap(value), frame); +} + pub const JsApi = struct { pub const bridge = js.Bridge(FrameSet); @@ -22,6 +39,9 @@ pub const JsApi = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; + + pub const cols = bridge.accessor(FrameSet.getCols, FrameSet.setCols, .{ .ce_reactions = true }); + pub const rows = bridge.accessor(FrameSet.getRows, FrameSet.setRows, .{ .ce_reactions = true }); }; const testing = @import("../../../../testing.zig"); diff --git a/src/browser/webapi/element/html/Marquee.zig b/src/browser/webapi/element/html/Marquee.zig new file mode 100644 index 00000000..4d0c4950 --- /dev/null +++ b/src/browser/webapi/element/html/Marquee.zig @@ -0,0 +1,156 @@ +const std = @import("std"); + +const js = @import("../../../js/js.zig"); +const Frame = @import("../../../Frame.zig"); +const Node = @import("../../Node.zig"); +const Element = @import("../../Element.zig"); +const HtmlElement = @import("../Html.zig"); + +const Marquee = @This(); + +_proto: *HtmlElement, + +pub fn asElement(self: *Marquee) *Element { + return self._proto._proto; +} +pub fn asNode(self: *Marquee) *Node { + return self.asElement().asNode(); +} + +pub fn getBehavior(self: *Marquee) []const u8 { + return getEnumerated(self, "behavior", &.{ "scroll", "slide", "alternate" }, "scroll"); +} + +pub fn setBehavior(self: *Marquee, value: []const u8, frame: *Frame) !void { + try self.asElement().setAttributeSafe(comptime .wrap("behavior"), .wrap(value), frame); +} + +pub fn getDirection(self: *Marquee) []const u8 { + return getEnumerated(self, "direction", &.{ "up", "right", "down", "left" }, "left"); +} + +pub fn setDirection(self: *Marquee, value: []const u8, frame: *Frame) !void { + try self.asElement().setAttributeSafe(comptime .wrap("direction"), .wrap(value), frame); +} + +pub fn getBgColor(self: *Marquee) []const u8 { + return self.asElement().getAttributeSafe(comptime .wrap("bgcolor")) orelse ""; +} + +pub fn setBgColor(self: *Marquee, value: []const u8, frame: *Frame) !void { + try self.asElement().setAttributeSafe(comptime .wrap("bgcolor"), .wrap(value), frame); +} + +pub fn getHeight(self: *Marquee) []const u8 { + return self.asElement().getAttributeSafe(comptime .wrap("height")) orelse ""; +} + +pub fn setHeight(self: *Marquee, value: []const u8, frame: *Frame) !void { + try self.asElement().setAttributeSafe(comptime .wrap("height"), .wrap(value), frame); +} + +pub fn getWidth(self: *Marquee) []const u8 { + return self.asElement().getAttributeSafe(comptime .wrap("width")) orelse ""; +} + +pub fn setWidth(self: *Marquee, value: []const u8, frame: *Frame) !void { + try self.asElement().setAttributeSafe(comptime .wrap("width"), .wrap(value), frame); +} + +pub fn getHspace(self: *Marquee) u32 { + return getU32(self, "hspace", 0); +} + +pub fn setHspace(self: *Marquee, value: u32, frame: *Frame) !void { + try setU32(self, "hspace", value, 0, frame); +} + +pub fn getVspace(self: *Marquee) u32 { + return getU32(self, "vspace", 0); +} + +pub fn setVspace(self: *Marquee, value: u32, frame: *Frame) !void { + try setU32(self, "vspace", value, 0, frame); +} + +pub fn getScrollAmount(self: *Marquee) u32 { + return getU32(self, "scrollamount", 6); +} + +pub fn setScrollAmount(self: *Marquee, value: u32, frame: *Frame) !void { + try setU32(self, "scrollamount", value, 6, frame); +} + +pub fn getScrollDelay(self: *Marquee) u32 { + return getU32(self, "scrolldelay", 85); +} + +pub fn setScrollDelay(self: *Marquee, value: u32, frame: *Frame) !void { + try setU32(self, "scrolldelay", value, 85, frame); +} + +pub fn getTrueSpeed(self: *Marquee) bool { + return self.asElement().getAttributeSafe(comptime .wrap("truespeed")) != null; +} + +pub fn setTrueSpeed(self: *Marquee, truespeed: bool, frame: *Frame) !void { + if (truespeed) { + try self.asElement().setAttributeSafe(comptime .wrap("truespeed"), .wrap(""), frame); + } else { + try self.asElement().removeAttribute(comptime .wrap("truespeed"), frame); + } +} + +fn getEnumerated(self: *Marquee, comptime attr: []const u8, keywords: []const []const u8, default: []const u8) []const u8 { + const value = self.asElement().getAttributeSafe(comptime .wrap(attr)) orelse return default; + for (keywords) |keyword| { + if (std.ascii.eqlIgnoreCase(value, keyword)) return keyword; + } + return default; +} + +// Reflects an `unsigned long` content attribute: parses with the "rules for +// parsing non-negative integers" (the lax integer parser, then rejecting a +// negative result), so a valid value lands in [0, 2147483647]. +fn getU32(self: *Marquee, comptime attr: []const u8, default: u32) u32 { + const value = self.asElement().getAttributeSafe(comptime .wrap(attr)) orelse return default; + const parsed = HtmlElement.parseInteger(value) orelse return default; + + if (parsed < 0) { + return default; + } + return @intCast(parsed); +} + +fn setU32(self: *Marquee, comptime attr: []const u8, value: u32, default: u32, frame: *Frame) !void { + const written = if (value > 2147483647) default else value; + var buf: [10]u8 = undefined; + const str = std.fmt.bufPrint(&buf, "{d}", .{written}) catch unreachable; + try self.asElement().setAttributeSafe(comptime .wrap(attr), .wrap(str), frame); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Marquee); + + pub const Meta = struct { + pub const name = "HTMLMarqueeElement"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const behavior = bridge.accessor(Marquee.getBehavior, Marquee.setBehavior, .{ .ce_reactions = true }); + pub const bgColor = bridge.accessor(Marquee.getBgColor, Marquee.setBgColor, .{ .ce_reactions = true }); + pub const direction = bridge.accessor(Marquee.getDirection, Marquee.setDirection, .{ .ce_reactions = true }); + pub const height = bridge.accessor(Marquee.getHeight, Marquee.setHeight, .{ .ce_reactions = true }); + pub const hspace = bridge.accessor(Marquee.getHspace, Marquee.setHspace, .{ .ce_reactions = true }); + pub const scrollAmount = bridge.accessor(Marquee.getScrollAmount, Marquee.setScrollAmount, .{ .ce_reactions = true }); + pub const scrollDelay = bridge.accessor(Marquee.getScrollDelay, Marquee.setScrollDelay, .{ .ce_reactions = true }); + pub const trueSpeed = bridge.accessor(Marquee.getTrueSpeed, Marquee.setTrueSpeed, .{ .ce_reactions = true }); + pub const vspace = bridge.accessor(Marquee.getVspace, Marquee.setVspace, .{ .ce_reactions = true }); + pub const width = bridge.accessor(Marquee.getWidth, Marquee.setWidth, .{ .ce_reactions = true }); +}; + +const testing = @import("../../../../testing.zig"); +test "WebApi: HTML.Marquee" { + try testing.htmlRunner("element/html/marquee.html", .{}); +} From 3d00061287eec09382946c840888ce944b537eff Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 2 Jun 2026 18:05:37 +0800 Subject: [PATCH 4/8] Fix UAF on FinalizerCallback.Identity memory From a real case involving a modified version of our bing integration. Our `releaseRef` (release from v8) destroys the FinalizerCallback.Identity but kept it in the identities list. `releaseRef` now removes it from the list, so that when FC.deinit is called, it doesn't try to access the freed identity. --- src/browser/js/Local.zig | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index ef15c4a1..15fc3d1e 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -34,6 +34,7 @@ const v8 = js.v8; const log = lp.log; const CallOpts = Caller.CallOpts; const FinalizerCallback = js.FinalizerCallback; +const IS_DEBUG = @import("builtin").mode == .Debug; // Where js.Context has a lifetime tied to the frame, and holds the // v8::Global, this has a much shorter lifetime and holds a @@ -1271,15 +1272,35 @@ fn resolveT(comptime T: type, value: *T) Resolved { const finalizer_ptr_id = identity_finalizer.finalizer_ptr_id; const fc = page.finalizer_callbacks.get(finalizer_ptr_id) orelse return; - const identity_count = fc.identity_count; - if (identity_count == 1) { + { + // Unlink this identity from the FC's intrusive list + var prev: ?*FinalizerCallback.Identity = null; + var node = fc.identities; + while (node) |n| { + if (n == identity_finalizer) { + if (prev) |p| { + p.next = n.next; + } else { + fc.identities = n.next; + } + fc.identity_count -= 1; + break; + } + prev = n; + node = n.next; + } else { + if (comptime IS_DEBUG) { + std.debug.assert(false); + } + } + } + + if (fc.identity_count == 0) { // Last identity - clean up the FC. // Remove from map before releaseRef to prevent address reuse issues. _ = page.finalizer_callbacks.remove(finalizer_ptr_id); FT.releaseRef(@ptrFromInt(finalizer_ptr_id), page); page.releaseArena(fc.arena); - } else { - fc.identity_count = identity_count - 1; } } From 8c8bcac053c8d024ee5f43e0b1e4a6c93ce95373 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 2 Jun 2026 18:43:31 +0800 Subject: [PATCH 5/8] More HTML attributes Stacked on https://github.com/lightpanda-io/browser/pull/2604 and driven by a different WPT test: /html/dom/reflection-metadata.html Goes from 1527 to 3074 passing cases. Largely just adding more attributes and adding validation to attributes where necessary. --- src/browser/tests/element/html/style.html | 2 +- src/browser/webapi/element/Html.zig | 108 ++++++++++--------- src/browser/webapi/element/html/Base.zig | 9 ++ src/browser/webapi/element/html/Link.zig | 111 ++++++++++++++++++-- src/browser/webapi/element/html/Marquee.zig | 18 ++-- src/browser/webapi/element/html/Meta.zig | 9 ++ src/browser/webapi/element/html/Style.zig | 2 +- 7 files changed, 193 insertions(+), 66 deletions(-) diff --git a/src/browser/tests/element/html/style.html b/src/browser/tests/element/html/style.html index 5a5cb4be..45aeed4e 100644 --- a/src/browser/tests/element/html/style.html +++ b/src/browser/tests/element/html/style.html @@ -49,7 +49,7 @@ + + + + + + diff --git a/src/browser/webapi/element/html/Param.zig b/src/browser/webapi/element/html/Param.zig index 41d909f5..b7cb9d47 100644 --- a/src/browser/webapi/element/html/Param.zig +++ b/src/browser/webapi/element/html/Param.zig @@ -1,4 +1,5 @@ const js = @import("../../../js/js.zig"); +const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); @@ -14,6 +15,22 @@ pub fn asNode(self: *Param) *Node { return self.asElement().asNode(); } +pub fn getName(self: *Param) []const u8 { + return self.asElement().getAttributeSafe(comptime .wrap("name")) orelse ""; +} + +pub fn setName(self: *Param, value: []const u8, frame: *Frame) !void { + try self.asElement().setAttributeSafe(comptime .wrap("name"), .wrap(value), frame); +} + +pub fn getValue(self: *Param) []const u8 { + return self.asElement().getAttributeSafe(comptime .wrap("value")) orelse ""; +} + +pub fn setValue(self: *Param, value: []const u8, frame: *Frame) !void { + try self.asElement().setAttributeSafe(comptime .wrap("value"), .wrap(value), frame); +} + pub const JsApi = struct { pub const bridge = js.Bridge(Param); @@ -22,4 +39,12 @@ pub const JsApi = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; + + pub const name = bridge.accessor(Param.getName, Param.setName, .{ .ce_reactions = true }); + pub const value = bridge.accessor(Param.getValue, Param.setValue, .{ .ce_reactions = true }); }; + +const testing = @import("../../../../testing.zig"); +test "WebApi: HTML.Param" { + try testing.htmlRunner("element/html/param.html", .{}); +}