From 4d7b7d1d42acbf70750eee9091fb5a43cc7e95fa Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 19 Feb 2026 12:05:45 -0800 Subject: [PATCH 01/23] add web bot auth args --- src/Config.zig | 52 ++++++++++++++++++++++++++++++++++++++ src/network/WebBotAuth.zig | 23 +++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 src/network/WebBotAuth.zig diff --git a/src/Config.zig b/src/Config.zig index 73c7f3a7..680a99a8 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -23,6 +23,8 @@ const Allocator = std.mem.Allocator; const log = @import("log.zig"); const dump = @import("browser/dump.zig"); +const WebBotAuth = @import("browser/WebBotAuth.zig"); + pub const RunMode = enum { help, fetch, @@ -161,6 +163,17 @@ pub fn cdpTimeout(self: *const Config) usize { }; } +pub fn webBotAuth(self: *const Config) ?WebBotAuth { + return switch (self.mode) { + inline .serve, .fetch, .mcp => |opts| WebBotAuth{ + .key_file = opts.common.web_bot_auth_key_file orelse return null, + .keyid = opts.common.web_bot_auth_keyid orelse return null, + .domain = opts.common.web_bot_auth_domain orelse return null, + }, + .help, .version => null, + }; +} + pub fn maxConnections(self: *const Config) u16 { return switch (self.mode) { .serve => |opts| opts.cdp_max_connections, @@ -227,6 +240,10 @@ pub const Common = struct { log_format: ?log.Format = null, log_filter_scopes: ?[]log.Scope = null, user_agent_suffix: ?[]const u8 = null, + + web_bot_auth_key_file: ?[]const u8 = null, + web_bot_auth_keyid: ?[]const u8 = null, + web_bot_auth_domain: ?[]const u8 = null, }; /// Pre-formatted HTTP headers for reuse across Http and Client. @@ -334,6 +351,14 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\--user_agent_suffix \\ Suffix to append to the Lightpanda/X.Y User-Agent \\ + \\--web_bot_auth_key_file + \\ Path to the Ed25519 private key PEM file. + \\ + \\--web_bot_auth_keyid + \\ The JWK thumbprint of your public key. + \\ + \\--web_bot_auth_directory + \\ Your domain e.g. yourdomain.com ; // MAX_HELP_LEN| @@ -855,5 +880,32 @@ fn parseCommonArg( return true; } + if (std.mem.eql(u8, "--web_bot_auth_key_file", opt)) { + const str = args.next() orelse { + log.fatal(.app, "missing argument value", .{ .arg = "--web_bot_auth_key_file" }); + return error.InvalidArgument; + }; + common.web_bot_auth_key_file = try allocator.dupe(u8, str); + return true; + } + + if (std.mem.eql(u8, "--web_bot_auth_keyid", opt)) { + const str = args.next() orelse { + log.fatal(.app, "missing argument value", .{ .arg = "--web_bot_auth_keyid" }); + return error.InvalidArgument; + }; + common.web_bot_auth_keyid = try allocator.dupe(u8, str); + return true; + } + + if (std.mem.eql(u8, "--web_bot_auth_domain", opt)) { + const str = args.next() orelse { + log.fatal(.app, "missing argument value", .{ .arg = "--web_bot_auth_domain" }); + return error.InvalidArgument; + }; + common.web_bot_auth_domain = try allocator.dupe(u8, str); + return true; + } + return false; } diff --git a/src/network/WebBotAuth.zig b/src/network/WebBotAuth.zig new file mode 100644 index 00000000..4af598fb --- /dev/null +++ b/src/network/WebBotAuth.zig @@ -0,0 +1,23 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const WebBotAuth = @This(); + +key_file: []const u8, +keyid: []const u8, +domain: []const u8, From 6cd8202310d2c3b6815f92bf001ec1c16b99e2d0 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 19 Feb 2026 12:29:50 -0800 Subject: [PATCH 02/23] add WebBotAuth and support for ed25119 to crypto --- src/crypto.zig | 10 +++ src/network/WebBotAuth.zig | 149 ++++++++++++++++++++++++++++++++++++- 2 files changed, 157 insertions(+), 2 deletions(-) diff --git a/src/crypto.zig b/src/crypto.zig index 012ca96e..58f6349f 100644 --- a/src/crypto.zig +++ b/src/crypto.zig @@ -228,6 +228,8 @@ pub extern fn X25519_keypair(out_public_value: *[32]u8, out_private_key: *[32]u8 pub const NID_X25519 = @as(c_int, 948); pub const EVP_PKEY_X25519 = NID_X25519; +pub const NID_ED25519 = 949; +pub const EVP_PKEY_ED25519 = NID_ED25519; pub extern fn EVP_PKEY_new_raw_private_key(@"type": c_int, unused: ?*ENGINE, in: [*c]const u8, len: usize) [*c]EVP_PKEY; pub extern fn EVP_PKEY_new_raw_public_key(@"type": c_int, unused: ?*ENGINE, in: [*c]const u8, len: usize) [*c]EVP_PKEY; @@ -236,3 +238,11 @@ pub extern fn EVP_PKEY_CTX_free(ctx: ?*EVP_PKEY_CTX) void; pub extern fn EVP_PKEY_derive_init(ctx: ?*EVP_PKEY_CTX) c_int; pub extern fn EVP_PKEY_derive(ctx: ?*EVP_PKEY_CTX, key: [*c]u8, out_key_len: [*c]usize) c_int; pub extern fn EVP_PKEY_derive_set_peer(ctx: ?*EVP_PKEY_CTX, peer: [*c]EVP_PKEY) c_int; +pub extern fn EVP_PKEY_free(pkey: ?*EVP_PKEY) void; + +pub extern fn EVP_DigestSignInit(ctx: ?*EVP_MD_CTX, pctx: ?*?*EVP_PKEY_CTX, typ: ?*const EVP_MD, e: ?*ENGINE, pkey: ?*EVP_PKEY) c_int; +pub extern fn EVP_DigestSign(ctx: ?*EVP_MD_CTX, sig: [*c]u8, sig_len: *usize, data: [*c]const u8, data_len: usize) c_int; +pub extern fn EVP_MD_CTX_new() ?*EVP_MD_CTX; +pub extern fn EVP_MD_CTX_free(ctx: ?*EVP_MD_CTX) void; +pub const struct_evp_md_ctx_st = opaque {}; +pub const EVP_MD_CTX = struct_evp_md_ctx_st; diff --git a/src/network/WebBotAuth.zig b/src/network/WebBotAuth.zig index 4af598fb..842f02d2 100644 --- a/src/network/WebBotAuth.zig +++ b/src/network/WebBotAuth.zig @@ -16,8 +16,153 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const std = @import("std"); +const crypto = @import("../crypto.zig"); + +const Http = @import("../http/Http.zig"); + const WebBotAuth = @This(); -key_file: []const u8, +pkey: *crypto.EVP_PKEY, keyid: []const u8, -domain: []const u8, +directory_url: [:0]const u8, + +pub const Config = struct { + key_file: []const u8, + keyid: []const u8, + domain: []const u8, +}; + +fn parsePemPrivateKey(pem: []const u8) !*crypto.EVP_PKEY { + const begin = "-----BEGIN PRIVATE KEY-----"; + const end = "-----END PRIVATE KEY-----"; + const start_idx = std.mem.indexOf(u8, pem, begin) orelse return error.InvalidPem; + const end_idx = std.mem.indexOf(u8, pem, end) orelse return error.InvalidPem; + + const b64 = std.mem.trim(u8, pem[start_idx + begin.len .. end_idx], &std.ascii.whitespace); + + // strip newlines from b64 + var clean: [4096]u8 = undefined; + var clean_len: usize = 0; + for (b64) |ch| { + if (ch != '\n' and ch != '\r') { + clean[clean_len] = ch; + clean_len += 1; + } + } + + var der: [128]u8 = undefined; + const decoded_len = try std.base64.standard.Decoder.calcSizeForSlice(clean[0..clean_len]); + try std.base64.standard.Decoder.decode(der[0..decoded_len], clean[0..clean_len]); + + // Ed25519 PKCS#8: key bytes are at offset 16, 32 bytes long + const key_bytes = der[16..48]; + + const pkey = crypto.EVP_PKEY_new_raw_private_key(crypto.EVP_PKEY_ED25519, null, key_bytes.ptr, 32); + return pkey orelse error.InvalidKey; +} + +fn signEd25519(pkey: *crypto.EVP_PKEY, message: []const u8, out: *[64]u8) !void { + const ctx = crypto.EVP_MD_CTX_new() orelse return error.OutOfMemory; + defer crypto.EVP_MD_CTX_free(ctx); + + if (crypto.EVP_DigestSignInit(ctx, null, null, null, pkey) != 1) + return error.SignInit; + + var sig_len: usize = 64; + if (crypto.EVP_DigestSign(ctx, out.ptr, &sig_len, message.ptr, message.len) != 1) + return error.SignFailed; +} + +pub fn fromConfig(allocator: std.mem.Allocator, config: *const Config) !WebBotAuth { + const pem = try std.fs.cwd().readFileAlloc(allocator, config.key_file, 1024 * 4); + defer allocator.free(pem); + + const pkey = try parsePemPrivateKey(pem); + errdefer crypto.EVP_PKEY_free(pkey); + + const directory_url = try std.fmt.allocPrintSentinel( + allocator, + "https://{s}/.well-known/http-message-signatures-directory", + .{config.domain}, + 0, + ); + errdefer allocator.free(directory_url); + + return .{ + .pkey = pkey, + // Owned by the Config so it's okay. + .keyid = config.keyid, + .directory_url = directory_url, + }; +} + +pub fn signRequest( + self: *const WebBotAuth, + allocator: std.mem.Allocator, + headers: *Http.Headers, + authority: []const u8, +) !void { + const now = std.time.timestamp(); + const expires = now + 60; + + // build the signature-input value (without the sig1= label) + const sig_input_value = try std.fmt.allocPrint( + allocator, + "(\"@authority\" \"signature-agent\");created={d};expires={d};keyid=\"{s}\";alg=\"ed25519\";tag=\"web-bot-auth\"", + .{ now, expires, self.keyid }, + ); + defer allocator.free(sig_input_value); + + // build the canonical string to sign + const canonical = try std.fmt.allocPrint( + allocator, + "\"@authority\": {s}\n\"signature-agent\": \"{s}\"\n\"@signature-params\": {s}", + .{ authority, self.directory_url, sig_input_value }, + ); + defer allocator.free(canonical); + + // sign it + var sig: [64]u8 = undefined; + try signEd25519(self.pkey, canonical, &sig); + + // base64 encode + const encoded_len = std.base64.standard.Encoder.calcSize(sig.len); + const encoded = try allocator.alloc(u8, encoded_len); + defer allocator.free(encoded); + _ = std.base64.standard.Encoder.encode(encoded, &sig); + + // build the 3 headers and add them + const sig_agent = try std.fmt.allocPrintSentinel( + allocator, + "Signature-Agent: \"{s}\"", + .{self.directory_url}, + 0, + ); + defer allocator.free(sig_agent); + + const sig_input = try std.fmt.allocPrintSentinel( + allocator, + "Signature-Input: sig1={s}", + .{sig_input_value}, + 0, + ); + defer allocator.free(sig_input); + + const signature = try std.fmt.allocPrintSentinel( + allocator, + "Signature: sig1=:{s}:", + .{encoded}, + 0, + ); + defer allocator.free(signature); + + try headers.add(sig_agent); + try headers.add(sig_input); + try headers.add(signature); +} + +pub fn deinit(self: WebBotAuth, allocator: std.mem.Allocator) void { + crypto.EVP_PKEY_free(self.pkey); + allocator.free(self.directory_url); +} From 02198de455710094c36938577ff029ad2a39b82d Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 19 Feb 2026 12:30:03 -0800 Subject: [PATCH 03/23] add support for WebBotAuth in Client --- src/Config.zig | 6 +++--- src/browser/HttpClient.zig | 7 +++++++ src/network/Runtime.zig | 11 +++++++++++ src/network/WebBotAuth.zig | 2 +- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index 680a99a8..21fdeed4 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -23,7 +23,7 @@ const Allocator = std.mem.Allocator; const log = @import("log.zig"); const dump = @import("browser/dump.zig"); -const WebBotAuth = @import("browser/WebBotAuth.zig"); +const WebBotAuthConfig = @import("network/WebBotAuth.zig").Config; pub const RunMode = enum { help, @@ -163,9 +163,9 @@ pub fn cdpTimeout(self: *const Config) usize { }; } -pub fn webBotAuth(self: *const Config) ?WebBotAuth { +pub fn webBotAuth(self: *const Config) ?WebBotAuthConfig { return switch (self.mode) { - inline .serve, .fetch, .mcp => |opts| WebBotAuth{ + inline .serve, .fetch, .mcp => |opts| WebBotAuthConfig{ .key_file = opts.common.web_bot_auth_key_file orelse return null, .keyid = opts.common.web_bot_auth_keyid orelse return null, .domain = opts.common.web_bot_auth_domain orelse return null, diff --git a/src/browser/HttpClient.zig b/src/browser/HttpClient.zig index 41b5ea39..05d5ddde 100644 --- a/src/browser/HttpClient.zig +++ b/src/browser/HttpClient.zig @@ -30,6 +30,7 @@ const Notification = @import("../Notification.zig"); const CookieJar = @import("../browser/webapi/storage/Cookie.zig").Jar; const Robots = @import("../network/Robots.zig"); const RobotStore = Robots.RobotStore; +const WebBotAuth = @import("../network/WebBotAuth.zig"); const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; @@ -702,6 +703,12 @@ fn makeRequest(self: *Client, conn: *Net.Connection, transfer: *Transfer) anyerr try conn.secretHeaders(&header_list, &self.network.config.http_headers); // Add headers that must be hidden from intercepts try conn.setHeaders(&header_list); + // If we have WebBotAuth, sign our request. + if (self.network.web_bot_auth) |*wba| { + const authority = URL.getHost(req.url); + try wba.signRequest(self.allocator, &header_list, authority); + } + // Add cookies. if (header_list.cookies) |cookies| { try conn.setCookies(cookies); diff --git a/src/network/Runtime.zig b/src/network/Runtime.zig index b3cd6a06..d6a08f59 100644 --- a/src/network/Runtime.zig +++ b/src/network/Runtime.zig @@ -28,6 +28,7 @@ const libcurl = @import("../sys/libcurl.zig"); const net_http = @import("http.zig"); const RobotStore = @import("Robots.zig").RobotStore; +const WebBotAuth = @import("WebBotAuth.zig"); const Runtime = @This(); @@ -42,6 +43,7 @@ allocator: Allocator, config: *const Config, ca_blob: ?net_http.Blob, robot_store: RobotStore, +web_bot_auth: ?WebBotAuth, connections: []net_http.Connection, available: std.DoublyLinkedList = .{}, @@ -205,6 +207,11 @@ pub fn init(allocator: Allocator, config: *const Config) !Runtime { available.append(&connections[i].node); } + const web_bot_auth = if (config.webBotAuth()) |wba_cfg| + try WebBotAuth.fromConfig(allocator, &wba_cfg) + else + null; + return .{ .allocator = allocator, .config = config, @@ -212,6 +219,7 @@ pub fn init(allocator: Allocator, config: *const Config) !Runtime { .robot_store = RobotStore.init(allocator), .connections = connections, .available = available, + .web_bot_auth = web_bot_auth, .pollfds = pollfds, .wakeup_pipe = pipe, }; @@ -238,6 +246,9 @@ pub fn deinit(self: *Runtime) void { self.allocator.free(self.connections); self.robot_store.deinit(); + if (self.web_bot_auth) |wba| { + wba.deinit(self.allocator); + } globalDeinit(); } diff --git a/src/network/WebBotAuth.zig b/src/network/WebBotAuth.zig index 842f02d2..0abdad82 100644 --- a/src/network/WebBotAuth.zig +++ b/src/network/WebBotAuth.zig @@ -19,7 +19,7 @@ const std = @import("std"); const crypto = @import("../crypto.zig"); -const Http = @import("../http/Http.zig"); +const Http = @import("../network/http.zig"); const WebBotAuth = @This(); From c38d9a309815f164ca8db53120af7fed95831ed0 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Fri, 27 Feb 2026 09:00:27 -0800 Subject: [PATCH 04/23] auth challenge only on use_proxy --- src/browser/HttpClient.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/HttpClient.zig b/src/browser/HttpClient.zig index 05d5ddde..2e832077 100644 --- a/src/browser/HttpClient.zig +++ b/src/browser/HttpClient.zig @@ -1338,7 +1338,7 @@ pub const Transfer = struct { } transfer._redirecting = false; - if (status == 401 or status == 407) { + if ((status == 401 or status == 407) and transfer.client.use_proxy) { // The auth challenge must be parsed from a following // WWW-Authenticate or Proxy-Authenticate header. transfer._auth_challenge = .{ From 9971816711d3eb8501d4ee38dc34f4f4f87cb9a1 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 4 Mar 2026 05:48:01 -0800 Subject: [PATCH 05/23] use transfer arena to sign webbotauth request --- src/browser/HttpClient.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/HttpClient.zig b/src/browser/HttpClient.zig index 2e832077..181b3923 100644 --- a/src/browser/HttpClient.zig +++ b/src/browser/HttpClient.zig @@ -706,7 +706,7 @@ fn makeRequest(self: *Client, conn: *Net.Connection, transfer: *Transfer) anyerr // If we have WebBotAuth, sign our request. if (self.network.web_bot_auth) |*wba| { const authority = URL.getHost(req.url); - try wba.signRequest(self.allocator, &header_list, authority); + try wba.signRequest(transfer.arena.allocator(), &header_list, authority); } // Add cookies. From a1fb11ae339ba561233ae86628f67763c9147ed7 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 4 Mar 2026 05:52:32 -0800 Subject: [PATCH 06/23] make pem private key buffers smaller with comments --- src/network/WebBotAuth.zig | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/network/WebBotAuth.zig b/src/network/WebBotAuth.zig index 0abdad82..3513c206 100644 --- a/src/network/WebBotAuth.zig +++ b/src/network/WebBotAuth.zig @@ -41,8 +41,8 @@ fn parsePemPrivateKey(pem: []const u8) !*crypto.EVP_PKEY { const b64 = std.mem.trim(u8, pem[start_idx + begin.len .. end_idx], &std.ascii.whitespace); - // strip newlines from b64 - var clean: [4096]u8 = undefined; + // Ed25519 PKCS#8 DER is always 48 bytes, which base64-encodes to exactly 64 chars + var clean: [64]u8 = undefined; var clean_len: usize = 0; for (b64) |ch| { if (ch != '\n' and ch != '\r') { @@ -51,11 +51,12 @@ fn parsePemPrivateKey(pem: []const u8) !*crypto.EVP_PKEY { } } - var der: [128]u8 = undefined; + // decode base64 into 48-byte DER buffer + var der: [48]u8 = undefined; const decoded_len = try std.base64.standard.Decoder.calcSizeForSlice(clean[0..clean_len]); try std.base64.standard.Decoder.decode(der[0..decoded_len], clean[0..clean_len]); - // Ed25519 PKCS#8: key bytes are at offset 16, 32 bytes long + // Ed25519 PKCS#8 structure always places the 32-byte raw private key at offset 16. const key_bytes = der[16..48]; const pkey = crypto.EVP_PKEY_new_raw_private_key(crypto.EVP_PKEY_ED25519, null, key_bytes.ptr, 32); From 1ed61d4783283b6f9170f9f1a935a7d98747803d Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 4 Mar 2026 05:59:02 -0800 Subject: [PATCH 07/23] simplify parsePemPrivateKey --- src/network/WebBotAuth.zig | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/src/network/WebBotAuth.zig b/src/network/WebBotAuth.zig index 3513c206..bd3f4946 100644 --- a/src/network/WebBotAuth.zig +++ b/src/network/WebBotAuth.zig @@ -41,20 +41,9 @@ fn parsePemPrivateKey(pem: []const u8) !*crypto.EVP_PKEY { const b64 = std.mem.trim(u8, pem[start_idx + begin.len .. end_idx], &std.ascii.whitespace); - // Ed25519 PKCS#8 DER is always 48 bytes, which base64-encodes to exactly 64 chars - var clean: [64]u8 = undefined; - var clean_len: usize = 0; - for (b64) |ch| { - if (ch != '\n' and ch != '\r') { - clean[clean_len] = ch; - clean_len += 1; - } - } - // decode base64 into 48-byte DER buffer var der: [48]u8 = undefined; - const decoded_len = try std.base64.standard.Decoder.calcSizeForSlice(clean[0..clean_len]); - try std.base64.standard.Decoder.decode(der[0..decoded_len], clean[0..clean_len]); + try std.base64.standard.Decoder.decode(der[0..48], b64); // Ed25519 PKCS#8 structure always places the 32-byte raw private key at offset 16. const key_bytes = der[16..48]; From d365240f91ed825273c20cf29f91d05a9fcb174b Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 4 Mar 2026 06:01:51 -0800 Subject: [PATCH 08/23] fix cli argument for WebBotAuth domain --- src/Config.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Config.zig b/src/Config.zig index 21fdeed4..629df32b 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -357,7 +357,7 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\--web_bot_auth_keyid \\ The JWK thumbprint of your public key. \\ - \\--web_bot_auth_directory + \\--web_bot_auth_domain \\ Your domain e.g. yourdomain.com ; From fca29a8be2b48da1f373c239f68c21786e5a7e20 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 5 Mar 2026 21:38:16 -0800 Subject: [PATCH 09/23] add WebBotAuth unit tests --- src/network/WebBotAuth.zig | 126 +++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/src/network/WebBotAuth.zig b/src/network/WebBotAuth.zig index bd3f4946..418ee3cb 100644 --- a/src/network/WebBotAuth.zig +++ b/src/network/WebBotAuth.zig @@ -156,3 +156,129 @@ pub fn deinit(self: WebBotAuth, allocator: std.mem.Allocator) void { crypto.EVP_PKEY_free(self.pkey); allocator.free(self.directory_url); } + +test "parsePemPrivateKey: valid Ed25519 PKCS#8 PEM" { + const pem = + \\-----BEGIN PRIVATE KEY----- + \\MC4CAQAwBQYDK2VwBCIEIBuCRBIEFNtXcMBsyOOkFBFTJcEWTkbgSwKExhOjKFHT + \\-----END PRIVATE KEY----- + \\ + ; + + const pkey = try parsePemPrivateKey(pem); + defer crypto.EVP_PKEY_free(pkey); +} + +test "parsePemPrivateKey: missing BEGIN marker returns error" { + const bad_pem = "-----END PRIVATE KEY-----\n"; + try std.testing.expectError(error.InvalidPem, parsePemPrivateKey(bad_pem)); +} + +test "parsePemPrivateKey: missing END marker returns error" { + const bad_pem = "-----BEGIN PRIVATE KEY-----\nMC4CAQA=\n"; + try std.testing.expectError(error.InvalidPem, parsePemPrivateKey(bad_pem)); +} + +test "signEd25519: signature length is always 64 bytes" { + const pem = + \\-----BEGIN PRIVATE KEY----- + \\MC4CAQAwBQYDK2VwBCIEIBuCRBIEFNtXcMBsyOOkFBFTJcEWTkbgSwKExhOjKFHT + \\-----END PRIVATE KEY----- + \\ + ; + const pkey = try parsePemPrivateKey(pem); + defer crypto.EVP_PKEY_free(pkey); + + var sig: [64]u8 = @splat(0); + try signEd25519(pkey, "hello world", &sig); + + var all_zero = true; + for (sig) |b| if (b != 0) { + all_zero = false; + break; + }; + try std.testing.expect(!all_zero); +} + +test "signEd25519: same key + message produces same signature (deterministic)" { + const pem = + \\-----BEGIN PRIVATE KEY----- + \\MC4CAQAwBQYDK2VwBCIEIBuCRBIEFNtXcMBsyOOkFBFTJcEWTkbgSwKExhOjKFHT + \\-----END PRIVATE KEY----- + \\ + ; + const pkey = try parsePemPrivateKey(pem); + defer crypto.EVP_PKEY_free(pkey); + + var sig1: [64]u8 = undefined; + var sig2: [64]u8 = undefined; + try signEd25519(pkey, "deterministic test", &sig1); + try signEd25519(pkey, "deterministic test", &sig2); + + try std.testing.expectEqualSlices(u8, &sig1, &sig2); +} + +test "signEd25519: same key + diff message produces different signature (deterministic)" { + const pem = + \\-----BEGIN PRIVATE KEY----- + \\MC4CAQAwBQYDK2VwBCIEIBuCRBIEFNtXcMBsyOOkFBFTJcEWTkbgSwKExhOjKFHT + \\-----END PRIVATE KEY----- + \\ + ; + const pkey = try parsePemPrivateKey(pem); + defer crypto.EVP_PKEY_free(pkey); + + var sig1: [64]u8 = undefined; + var sig2: [64]u8 = undefined; + try signEd25519(pkey, "msg 1", &sig1); + try signEd25519(pkey, "msg 2", &sig2); + + try std.testing.expect(!std.mem.eql(u8, &sig1, &sig2)); +} + +test "signRequest: adds headers with correct names" { + const allocator = std.testing.allocator; + + const pem = + \\-----BEGIN PRIVATE KEY----- + \\MC4CAQAwBQYDK2VwBCIEIBuCRBIEFNtXcMBsyOOkFBFTJcEWTkbgSwKExhOjKFHT + \\-----END PRIVATE KEY----- + \\ + ; + const pkey = try parsePemPrivateKey(pem); + + const directory_url = try allocator.dupeZ( + u8, + "https://example.com/.well-known/http-message-signatures-directory", + ); + + var auth = WebBotAuth{ + .pkey = pkey, + .keyid = "test-key-id", + .directory_url = directory_url, + }; + defer auth.deinit(allocator); + + var headers = try Http.Headers.init("User-Agent: Test-Agent"); + defer headers.deinit(); + + try auth.signRequest(allocator, &headers, "example.com"); + + var it = headers.iterator(); + var found_sig_agent = false; + var found_sig_input = false; + var found_signature = false; + var count: usize = 0; + + while (it.next()) |h| { + count += 1; + if (std.ascii.eqlIgnoreCase(h.name, "Signature-Agent")) found_sig_agent = true; + if (std.ascii.eqlIgnoreCase(h.name, "Signature-Input")) found_sig_input = true; + if (std.ascii.eqlIgnoreCase(h.name, "Signature")) found_signature = true; + } + + try std.testing.expect(count >= 3); + try std.testing.expect(found_sig_agent); + try std.testing.expect(found_sig_input); + try std.testing.expect(found_signature); +} From c6c0492c33e700a32fd47a67cff767972f08e8c0 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 10 Mar 2026 20:21:43 -0700 Subject: [PATCH 10/23] fix request authentication with web bot auth --- src/browser/HttpClient.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/HttpClient.zig b/src/browser/HttpClient.zig index 181b3923..5b71b3b0 100644 --- a/src/browser/HttpClient.zig +++ b/src/browser/HttpClient.zig @@ -1338,7 +1338,7 @@ pub const Transfer = struct { } transfer._redirecting = false; - if ((status == 401 or status == 407) and transfer.client.use_proxy) { + if (status == 401 or status == 407) { // The auth challenge must be parsed from a following // WWW-Authenticate or Proxy-Authenticate header. transfer._auth_challenge = .{ From 172481dd7256e6013b1011a9a648f8de8e9351d5 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 10 Mar 2026 20:24:12 -0700 Subject: [PATCH 11/23] add e2e tests w/ web bot auth --- .github/workflows/e2e-test.yml | 92 ++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 74eda203..95fabb7f 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -117,6 +117,98 @@ jobs: BASE_URL=https://demo-browser.lightpanda.io/ node playwright/proxy_auth.js kill `cat LPD.pid` `cat PROXY.id` + # e2e tests w/ web-bot-auth configuration on. + wba-demo-scripts: + name: wba-demo-scripts + needs: zig-build-release + + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v4 + with: + repository: 'lightpanda-io/demo' + fetch-depth: 0 + + - run: npm install + + - name: download artifact + uses: actions/download-artifact@v4 + with: + name: lightpanda-build-release + + - run: chmod a+x ./lightpanda + + - run: echo "${{ secrets.WBA_PRIVATE_KEY_PEM }}" > private_key.pem + + - name: run end to end tests + run: | + ./lightpanda serve \ + --web_bot_auth_key_file private_key.pem \ + --web_bot_auth_keyid ${{ vars.WBA_KEY_ID }} \ + --web_bot_auth_domain ${{ vars.WBA_DOMAIN }} \ + & echo $! > LPD.pid + go run runner/main.go + kill `cat LPD.pid` + + - name: build proxy + run: | + cd proxy + go build + + - name: run end to end tests through proxy + run: | + ./proxy/proxy & echo $! > PROXY.id + ./lightpanda serve \ + --web_bot_auth_key_file private_key.pem \ + --web_bot_auth_keyid ${{ vars.WBA_KEY_ID }} \ + --web_bot_auth_domain ${{ vars.WBA_DOMAIN }} \ + --http_proxy 'http://127.0.0.1:3000' \ + & echo $! > LPD.pid + go run runner/main.go + kill `cat LPD.pid` `cat PROXY.id` + + - name: run request interception through proxy + run: | + export PROXY_USERNAME=username PROXY_PASSWORD=password + ./proxy/proxy & echo $! > PROXY.id + ./lightpanda serve & echo $! > LPD.pid + URL=https://demo-browser.lightpanda.io/campfire-commerce/ node puppeteer/proxy_auth.js + BASE_URL=https://demo-browser.lightpanda.io/ node playwright/proxy_auth.js + kill `cat LPD.pid` `cat PROXY.id` + + wba-test: + name: wba-test + needs: zig-build-release + + env: + LIGHTPANDA_DISABLE_TELEMETRY: true + + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: download artifact + uses: actions/download-artifact@v4 + with: + name: lightpanda-build-release + + - run: chmod a+x ./lightpanda + + - run: echo "${{ secrets.WBA_PRIVATE_KEY_PEM }}" > private_key.pem + + - run: | + ./lightpanda fetch https://crawltest.com/cdn-cgi/web-bot-auth \ + --log_level error \ + --web_bot_auth_key_file private_key.pem \ + --web_bot_auth_keyid ${{ vars.WBA_KEY_ID }} \ + --web_bot_auth_domain ${{ vars.WBA_DOMAIN }} \ + --dump markdown \ + | tee output.log + + - run: cat output.log | grep -q "unknown public key or unknown verified bot ID for keyid" + cdp-and-hyperfine-bench: name: cdp-and-hyperfine-bench needs: zig-build-release From bf0be60b890f87da1de7fcd9add7cc8a227a292b Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 10 Mar 2026 21:44:41 -0700 Subject: [PATCH 12/23] use new validator for e2e test --- .github/workflows/e2e-test.yml | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 95fabb7f..fa13a613 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -186,9 +186,16 @@ jobs: LIGHTPANDA_DISABLE_TELEMETRY: true runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 5 steps: + - uses: actions/checkout@v4 + with: + repository: 'lightpanda-io/demo' + fetch-depth: 0 + + - run: echo "${{ secrets.WBA_PRIVATE_KEY_PEM }}" > private_key.pem + - name: download artifact uses: actions/download-artifact@v4 with: @@ -196,18 +203,18 @@ jobs: - run: chmod a+x ./lightpanda - - run: echo "${{ secrets.WBA_PRIVATE_KEY_PEM }}" > private_key.pem + - name: run wba test + run: | + node webbotauth/validator.js & + VALIDATOR_PID=$! + sleep 1 - - run: | - ./lightpanda fetch https://crawltest.com/cdn-cgi/web-bot-auth \ - --log_level error \ + ./lightpanda fetch http://127.0.0.1:8989/ \ --web_bot_auth_key_file private_key.pem \ --web_bot_auth_keyid ${{ vars.WBA_KEY_ID }} \ - --web_bot_auth_domain ${{ vars.WBA_DOMAIN }} \ - --dump markdown \ - | tee output.log + --web_bot_auth_domain ${{ vars.WBA_DOMAIN }} - - run: cat output.log | grep -q "unknown public key or unknown verified bot ID for keyid" + wait $VALIDATOR_PID cdp-and-hyperfine-bench: name: cdp-and-hyperfine-bench From 8db64772b78a847cec95b7fd806e92a0b6ec84fa Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 12 Mar 2026 09:02:48 -0700 Subject: [PATCH 13/23] add URL getHost test --- src/browser/URL.zig | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/browser/URL.zig b/src/browser/URL.zig index 19b87333..996f69d2 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -1400,3 +1400,12 @@ test "URL: unescape" { try testing.expectEqual("hello%2", result); } } + +test "URL: getHost" { + try testing.expectEqualSlices(u8, "example.com:8080", getHost("https://example.com:8080/path")); + try testing.expectEqualSlices(u8, "example.com", getHost("https://example.com/path")); + try testing.expectEqualSlices(u8, "example.com:443", getHost("https://example.com:443/")); + try testing.expectEqualSlices(u8, "example.com", getHost("https://user:pass@example.com/page")); + try testing.expectEqualSlices(u8, "example.com:8080", getHost("https://user:pass@example.com:8080/page")); + try testing.expectEqualSlices(u8, "", getHost("not-a-url")); +} From 4f8a6b62b8dd281e82b76ecb10b0969b291f6d17 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 12 Mar 2026 14:00:55 +0800 Subject: [PATCH 14/23] Add window.structuredClone Depends on https://github.com/lightpanda-io/zig-v8-fork/pull/156 Uses V8::Serializer and V8::Deserializer which handles built-in types, e.g. regex. But it doesn't handle Zig types by default. This is something we need to hook in, using the delegate callbacks. Which we can do after. Meant to replace https://github.com/lightpanda-io/browser/pull/1785 --- src/browser/js/Value.zig | 40 ++++++++ src/browser/tests/window/window.html | 137 +++++++++++++++++++++++++++ src/browser/webapi/Window.zig | 5 + 3 files changed, 182 insertions(+) diff --git a/src/browser/js/Value.zig b/src/browser/js/Value.zig index 309bdb6b..8e05690b 100644 --- a/src/browser/js/Value.zig +++ b/src/browser/js/Value.zig @@ -245,6 +245,46 @@ pub fn toJson(self: Value, allocator: Allocator) ![]u8 { return js.String.toSliceWithAlloc(.{ .local = local, .handle = str_handle }, allocator); } +// Currently does not support host objects (Blob, File, etc.) or transferables +// which require delegate callbacks to be implemented. +pub fn structuredClone(self: Value) !Value { + const local = self.local; + const v8_context = local.handle; + const v8_isolate = local.isolate.handle; + + const size, const data = blk: { + const serializer = v8.v8__ValueSerializer__New(v8_isolate, null) orelse return error.JsException; + defer v8.v8__ValueSerializer__DELETE(serializer); + + var write_result: v8.MaybeBool = undefined; + v8.v8__ValueSerializer__WriteHeader(serializer); + v8.v8__ValueSerializer__WriteValue(serializer, v8_context, self.handle, &write_result); + if (!write_result.has_value or !write_result.value) { + return error.JsException; + } + + var size: usize = undefined; + const data = v8.v8__ValueSerializer__Release(serializer, &size) orelse return error.JsException; + break :blk .{ size, data }; + }; + + defer v8.v8__ValueSerializer__FreeBuffer(data); + + const cloned_handle = blk: { + const deserializer = v8.v8__ValueDeserializer__New(v8_isolate, data, size, null) orelse return error.JsException; + defer v8.v8__ValueDeserializer__DELETE(deserializer); + + var read_header_result: v8.MaybeBool = undefined; + v8.v8__ValueDeserializer__ReadHeader(deserializer, v8_context, &read_header_result); + if (!read_header_result.has_value or !read_header_result.value) { + return error.JsException; + } + break :blk v8.v8__ValueDeserializer__ReadValue(deserializer, v8_context) orelse return error.JsException; + }; + + return .{ .local = local, .handle = cloned_handle }; +} + pub fn persist(self: Value) !Global { return self._persist(true); } diff --git a/src/browser/tests/window/window.html b/src/browser/tests/window/window.html index 5506e327..01025b86 100644 --- a/src/browser/tests/window/window.html +++ b/src/browser/tests/window/window.html @@ -125,6 +125,143 @@ testing.expectEqual(screen, window.screen); + + + + + + + + + + + diff --git a/src/browser/webapi/CData.zig b/src/browser/webapi/CData.zig index 4fb6de6f..4e806c85 100644 --- a/src/browser/webapi/CData.zig +++ b/src/browser/webapi/CData.zig @@ -151,8 +151,13 @@ pub fn asNode(self: *CData) *Node { pub fn is(self: *CData, comptime T: type) ?*T { inline for (@typeInfo(Type).@"union".fields) |f| { - if (f.type == T and @field(Type, f.name) == self._type) { - return &@field(self._type, f.name); + if (@field(Type, f.name) == self._type) { + if (f.type == T) { + return &@field(self._type, f.name); + } + if (f.type == *T) { + return @field(self._type, f.name); + } } } return null; diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 3673a1e7..2eca4047 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -285,6 +285,19 @@ pub fn getTextContentAlloc(self: *Node, allocator: Allocator) error{WriteFailed} return data[0 .. data.len - 1 :0]; } +/// Returns the "child text content" which is the concatenation of the data +/// of all the Text node children of the node, in tree order. +/// This differs from textContent which includes all descendant text. +/// See: https://dom.spec.whatwg.org/#concept-child-text-content +pub fn getChildTextContent(self: *Node, writer: *std.Io.Writer) error{WriteFailed}!void { + var it = self.childrenIterator(); + while (it.next()) |child| { + if (child.is(CData.Text)) |text| { + try writer.writeAll(text._proto._data.str()); + } + } +} + pub fn setTextContent(self: *Node, data: []const u8, page: *Page) !void { switch (self._type) { .element => |el| { diff --git a/src/browser/webapi/element/html/Script.zig b/src/browser/webapi/element/html/Script.zig index ad3d9ef7..00860d5f 100644 --- a/src/browser/webapi/element/html/Script.zig +++ b/src/browser/webapi/element/html/Script.zig @@ -31,6 +31,8 @@ const Script = @This(); _proto: *HtmlElement, _src: []const u8 = "", _executed: bool = false, +// dynamic scripts are forced to be async by default +_force_async: bool = true, pub fn asElement(self: *Script) *Element { return self._proto._proto; @@ -83,10 +85,11 @@ pub fn setCharset(self: *Script, value: []const u8, page: *Page) !void { } pub fn getAsync(self: *const Script) bool { - return self.asConstElement().getAttributeSafe(comptime .wrap("async")) != null; + return self._force_async or self.asConstElement().getAttributeSafe(comptime .wrap("async")) != null; } pub fn setAsync(self: *Script, value: bool, page: *Page) !void { + self._force_async = false; if (value) { try self.asElement().setAttributeSafe(comptime .wrap("async"), .wrap(""), page); } else { @@ -136,7 +139,12 @@ pub const JsApi = struct { try self.asNode().getTextContent(&buf.writer); return buf.written(); } - pub const text = bridge.accessor(_innerText, Script.setInnerText, .{}); + pub const text = bridge.accessor(_text, Script.setInnerText, .{}); + fn _text(self: *Script, page: *const Page) ![]const u8 { + var buf = std.Io.Writer.Allocating.init(page.call_arena); + try self.asNode().getChildTextContent(&buf.writer); + return buf.written(); + } }; pub const Build = struct { From 867745c71d30adf93f4f7cb74f48d1db5d75750b Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 13 Mar 2026 15:21:41 +0800 Subject: [PATCH 18/23] Tweak CDP startup messages. 1 - When Target.setAutoAttach is called, send the `Target.attachedToTarget` event before sending the response. This matches Chrome's behavior and it stops playwright from thinking there's no target and making extra calls, e.g. to Target.attachedToTarget. 2 - Use the same dummy frameId for all startup messages. I'm not sure why we have STARTUP-P and STARTUP-B. Using the same frame (a) makes more sense to me (b) doesn't break any existing integration tests, and (c) improves this scenario: https://github.com/lightpanda-io/browser/issues/1800 --- src/cdp/cdp.zig | 2 +- src/cdp/domains/target.zig | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig index 78e5ab50..08055d86 100644 --- a/src/cdp/cdp.zig +++ b/src/cdp/cdp.zig @@ -196,7 +196,7 @@ pub fn CDPT(comptime TypeProvider: type) type { return command.sendResult(.{ .frameTree = .{ .frame = .{ - .id = "TID-STARTUP-B", + .id = "TID-STARTUP", .loaderId = "LOADERID24DD2FD56CF1EF33C965C79C", .securityOrigin = URL_BASE, .url = "about:blank", diff --git a/src/cdp/domains/target.zig b/src/cdp/domains/target.zig index b1e2be42..80dc1512 100644 --- a/src/cdp/domains/target.zig +++ b/src/cdp/domains/target.zig @@ -340,7 +340,7 @@ fn getTargetInfo(cmd: anytype) !void { return cmd.sendResult(.{ .targetInfo = TargetInfo{ - .targetId = "TID-STARTUP-B", + .targetId = "TID-STARTUP", .type = "browser", .title = "", .url = "about:blank", @@ -424,14 +424,13 @@ fn setAutoAttach(cmd: anytype) !void { // set a flag to send Target.attachedToTarget events cmd.cdp.target_auto_attach = params.autoAttach; - try cmd.sendResult(null, .{}); - if (cmd.cdp.target_auto_attach == false) { // detach from all currently attached targets. if (cmd.browser_context) |bc| { bc.session_id = null; // TODO should we send a Target.detachedFromTarget event? } + try cmd.sendResult(null, .{}); return; } @@ -444,7 +443,7 @@ fn setAutoAttach(cmd: anytype) !void { try doAttachtoTarget(cmd, &bc.target_id.?); } } - // should we send something here? + try cmd.sendResult(null, .{}); return; } @@ -460,12 +459,14 @@ fn setAutoAttach(cmd: anytype) !void { .sessionId = "STARTUP", .targetInfo = TargetInfo{ .type = "page", - .targetId = "TID-STARTUP-P", + .targetId = "TID-STARTUP", .title = "", .url = "about:blank", .browserContextId = "BID-STARTUP", }, }, .{}); + + try cmd.sendResult(null, .{}); } fn doAttachtoTarget(cmd: anytype, target_id: []const u8) !void { From 0e48f317cb69f975841be504350bfae53ef4b645 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Fri, 13 Mar 2026 12:22:48 +0100 Subject: [PATCH 19/23] ci: add a longer sleep to wait for node start on wba test --- .github/workflows/e2e-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index fa13a613..675dd36b 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -207,7 +207,7 @@ jobs: run: | node webbotauth/validator.js & VALIDATOR_PID=$! - sleep 1 + sleep 2 ./lightpanda fetch http://127.0.0.1:8989/ \ --web_bot_auth_key_file private_key.pem \ From cc6587d6e5c47c21e192c5f185b8fa5fa51881e7 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 13 Mar 2026 18:49:26 +0300 Subject: [PATCH 20/23] make `body.onload` getter/setter alias to `window.onload` --- src/browser/webapi/element/html/Body.zig | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/browser/webapi/element/html/Body.zig b/src/browser/webapi/element/html/Body.zig index dccb892d..79512baa 100644 --- a/src/browser/webapi/element/html/Body.zig +++ b/src/browser/webapi/element/html/Body.zig @@ -36,6 +36,16 @@ pub fn asNode(self: *Body) *Node { return self.asElement().asNode(); } +/// Special-case: `body.onload` is actually an alias for `window.onload`. +pub fn setOnLoad(_: *Body, callback: ?js.Function.Global, page: *Page) !void { + page.window._on_load = callback; +} + +/// Special-case: `body.onload` is actually an alias for `window.onload`. +pub fn getOnLoad(_: *Body, page: *Page) ?js.Function.Global { + return page.window._on_load; +} + pub const JsApi = struct { pub const bridge = js.Bridge(Body); @@ -44,6 +54,8 @@ pub const JsApi = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; + + pub const onload = bridge.accessor(getOnLoad, setOnLoad, .{ .null_as_undefined = false }); }; pub const Build = struct { From 7fe26bc9668d416196696702c144d0545321c89e Mon Sep 17 00:00:00 2001 From: hobostay Date: Sat, 14 Mar 2026 13:10:11 +0800 Subject: [PATCH 21/23] Fix TrackingAllocator reallocation_count being incremented on failed operations The reallocation_count counter was being incremented regardless of whether the resize/remap operations succeeded. This led to inaccurate memory allocation statistics. - resize: Only increment when rawResize returns true (success) - remap: Only increment when rawRemap returns non-null (success) This fixes the TODO comments that were present in the code. Co-Authored-By: Claude Opus 4.6 --- src/test_runner.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test_runner.zig b/src/test_runner.zig index 287e06b4..5d9d1cdd 100644 --- a/src/test_runner.zig +++ b/src/test_runner.zig @@ -501,7 +501,7 @@ pub const TrackingAllocator = struct { defer self.mutex.unlock(); const result = self.parent_allocator.rawResize(old_mem, alignment, new_len, ra); - self.reallocation_count += 1; // TODO: only if result is not null? + if (result) self.reallocation_count += 1; return result; } @@ -531,7 +531,7 @@ pub const TrackingAllocator = struct { defer self.mutex.unlock(); const result = self.parent_allocator.rawRemap(memory, alignment, new_len, ret_addr); - self.reallocation_count += 1; // TODO: only if result is not null? + if (result != null) self.reallocation_count += 1; return result; } }; From c4176a282fac76fcb474d864656a81ef13b75b4e Mon Sep 17 00:00:00 2001 From: sjhddh Date: Sat, 14 Mar 2026 06:50:26 +0000 Subject: [PATCH 22/23] fix: resolve memory leak in Option.getText() by using page arena --- src/SemanticTree.zig | 4 ++-- src/browser/webapi/element/html/Option.zig | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig index 8f5eb755..3433ca6b 100644 --- a/src/SemanticTree.zig +++ b/src/SemanticTree.zig @@ -231,7 +231,7 @@ fn extractSelectOptions(node: *Node, page: *Page, arena: std.mem.Allocator) ![]O if (child.is(Element)) |el| { if (el.getTag() == .option) { if (el.is(Element.Html.Option)) |opt| { - const text = opt.getText(); + const text = opt.getText(page); const value = opt.getValue(page); const selected = opt.getSelected(); try options.append(arena, .{ .text = text, .value = value, .selected = selected }); @@ -240,7 +240,7 @@ fn extractSelectOptions(node: *Node, page: *Page, arena: std.mem.Allocator) ![]O var group_it = child.childrenIterator(); while (group_it.next()) |group_child| { if (group_child.is(Element.Html.Option)) |opt| { - const text = opt.getText(); + const text = opt.getText(page); const value = opt.getValue(page); const selected = opt.getSelected(); try options.append(arena, .{ .text = text, .value = value, .selected = selected }); diff --git a/src/browser/webapi/element/html/Option.zig b/src/browser/webapi/element/html/Option.zig index c93e6820..5a275970 100644 --- a/src/browser/webapi/element/html/Option.zig +++ b/src/browser/webapi/element/html/Option.zig @@ -61,10 +61,9 @@ pub fn setValue(self: *Option, value: []const u8, page: *Page) !void { self._value = owned; } -pub fn getText(self: *const Option) []const u8 { +pub fn getText(self: *const Option, page: *Page) []const u8 { const node: *Node = @constCast(self.asConstElement().asConstNode()); - const allocator = std.heap.page_allocator; // TODO: use proper allocator - return node.getTextContentAlloc(allocator) catch ""; + return node.getTextContentAlloc(page.call_arena) catch ""; } pub fn setText(self: *Option, value: []const u8, page: *Page) !void { From 4d6d8d9a836223c28096039b6d9f87e360abc2ca Mon Sep 17 00:00:00 2001 From: sjhddh Date: Sat, 14 Mar 2026 06:57:04 +0000 Subject: [PATCH 23/23] fix(test): properly count successful reallocations in TrackingAllocator --- src/test_runner.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test_runner.zig b/src/test_runner.zig index 287e06b4..5d9d1cdd 100644 --- a/src/test_runner.zig +++ b/src/test_runner.zig @@ -501,7 +501,7 @@ pub const TrackingAllocator = struct { defer self.mutex.unlock(); const result = self.parent_allocator.rawResize(old_mem, alignment, new_len, ra); - self.reallocation_count += 1; // TODO: only if result is not null? + if (result) self.reallocation_count += 1; return result; } @@ -531,7 +531,7 @@ pub const TrackingAllocator = struct { defer self.mutex.unlock(); const result = self.parent_allocator.rawRemap(memory, alignment, new_len, ret_addr); - self.reallocation_count += 1; // TODO: only if result is not null? + if (result != null) self.reallocation_count += 1; return result; } };