mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-03-23 05:04:42 +00:00
Merge branch 'main' into css-improvements
This commit is contained in:
99
.github/workflows/e2e-test.yml
vendored
99
.github/workflows/e2e-test.yml
vendored
@@ -117,6 +117,105 @@ jobs:
|
|||||||
BASE_URL=https://demo-browser.lightpanda.io/ node playwright/proxy_auth.js
|
BASE_URL=https://demo-browser.lightpanda.io/ node playwright/proxy_auth.js
|
||||||
kill `cat LPD.pid` `cat PROXY.id`
|
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: 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:
|
||||||
|
name: lightpanda-build-release
|
||||||
|
|
||||||
|
- run: chmod a+x ./lightpanda
|
||||||
|
|
||||||
|
- name: run wba test
|
||||||
|
run: |
|
||||||
|
node webbotauth/validator.js &
|
||||||
|
VALIDATOR_PID=$!
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
./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 }}
|
||||||
|
|
||||||
|
wait $VALIDATOR_PID
|
||||||
|
|
||||||
cdp-and-hyperfine-bench:
|
cdp-and-hyperfine-bench:
|
||||||
name: cdp-and-hyperfine-bench
|
name: cdp-and-hyperfine-bench
|
||||||
needs: zig-build-release
|
needs: zig-build-release
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ const Allocator = std.mem.Allocator;
|
|||||||
const log = @import("log.zig");
|
const log = @import("log.zig");
|
||||||
const dump = @import("browser/dump.zig");
|
const dump = @import("browser/dump.zig");
|
||||||
|
|
||||||
|
const WebBotAuthConfig = @import("network/WebBotAuth.zig").Config;
|
||||||
|
|
||||||
pub const RunMode = enum {
|
pub const RunMode = enum {
|
||||||
help,
|
help,
|
||||||
fetch,
|
fetch,
|
||||||
@@ -161,6 +163,17 @@ pub fn cdpTimeout(self: *const Config) usize {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn webBotAuth(self: *const Config) ?WebBotAuthConfig {
|
||||||
|
return switch (self.mode) {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
.help, .version => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
pub fn maxConnections(self: *const Config) u16 {
|
pub fn maxConnections(self: *const Config) u16 {
|
||||||
return switch (self.mode) {
|
return switch (self.mode) {
|
||||||
.serve => |opts| opts.cdp_max_connections,
|
.serve => |opts| opts.cdp_max_connections,
|
||||||
@@ -227,6 +240,10 @@ pub const Common = struct {
|
|||||||
log_format: ?log.Format = null,
|
log_format: ?log.Format = null,
|
||||||
log_filter_scopes: ?[]log.Scope = null,
|
log_filter_scopes: ?[]log.Scope = null,
|
||||||
user_agent_suffix: ?[]const u8 = 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.
|
/// 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
|
\\--user_agent_suffix
|
||||||
\\ Suffix to append to the Lightpanda/X.Y User-Agent
|
\\ 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_domain
|
||||||
|
\\ Your domain e.g. yourdomain.com
|
||||||
;
|
;
|
||||||
|
|
||||||
// MAX_HELP_LEN|
|
// MAX_HELP_LEN|
|
||||||
@@ -855,5 +880,32 @@ fn parseCommonArg(
|
|||||||
return true;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ fn extractSelectOptions(node: *Node, page: *Page, arena: std.mem.Allocator) ![]O
|
|||||||
if (child.is(Element)) |el| {
|
if (child.is(Element)) |el| {
|
||||||
if (el.getTag() == .option) {
|
if (el.getTag() == .option) {
|
||||||
if (el.is(Element.Html.Option)) |opt| {
|
if (el.is(Element.Html.Option)) |opt| {
|
||||||
const text = opt.getText();
|
const text = opt.getText(page);
|
||||||
const value = opt.getValue(page);
|
const value = opt.getValue(page);
|
||||||
const selected = opt.getSelected();
|
const selected = opt.getSelected();
|
||||||
try options.append(arena, .{ .text = text, .value = value, .selected = selected });
|
try options.append(arena, .{ .text = text, .value = value, .selected = selected });
|
||||||
@@ -242,7 +242,7 @@ fn extractSelectOptions(node: *Node, page: *Page, arena: std.mem.Allocator) ![]O
|
|||||||
var group_it = child.childrenIterator();
|
var group_it = child.childrenIterator();
|
||||||
while (group_it.next()) |group_child| {
|
while (group_it.next()) |group_child| {
|
||||||
if (group_child.is(Element.Html.Option)) |opt| {
|
if (group_child.is(Element.Html.Option)) |opt| {
|
||||||
const text = opt.getText();
|
const text = opt.getText(page);
|
||||||
const value = opt.getValue(page);
|
const value = opt.getValue(page);
|
||||||
const selected = opt.getSelected();
|
const selected = opt.getSelected();
|
||||||
try options.append(arena, .{ .text = text, .value = value, .selected = selected });
|
try options.append(arena, .{ .text = text, .value = value, .selected = selected });
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const Notification = @import("../Notification.zig");
|
|||||||
const CookieJar = @import("../browser/webapi/storage/Cookie.zig").Jar;
|
const CookieJar = @import("../browser/webapi/storage/Cookie.zig").Jar;
|
||||||
const Robots = @import("../network/Robots.zig");
|
const Robots = @import("../network/Robots.zig");
|
||||||
const RobotStore = Robots.RobotStore;
|
const RobotStore = Robots.RobotStore;
|
||||||
|
const WebBotAuth = @import("../network/WebBotAuth.zig");
|
||||||
|
|
||||||
const Allocator = std.mem.Allocator;
|
const Allocator = std.mem.Allocator;
|
||||||
const ArenaAllocator = std.heap.ArenaAllocator;
|
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.secretHeaders(&header_list, &self.network.config.http_headers); // Add headers that must be hidden from intercepts
|
||||||
try conn.setHeaders(&header_list);
|
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(transfer.arena.allocator(), &header_list, authority);
|
||||||
|
}
|
||||||
|
|
||||||
// Add cookies.
|
// Add cookies.
|
||||||
if (header_list.cookies) |cookies| {
|
if (header_list.cookies) |cookies| {
|
||||||
try conn.setCookies(cookies);
|
try conn.setCookies(cookies);
|
||||||
@@ -926,7 +933,7 @@ pub const RequestCookie = struct {
|
|||||||
|
|
||||||
if (arr.items.len > 0) {
|
if (arr.items.len > 0) {
|
||||||
try arr.append(temp, 0); //null terminate
|
try arr.append(temp, 0); //null terminate
|
||||||
headers.cookies = @ptrCast(arr.items.ptr);
|
headers.cookies = @as([*c]const u8, @ptrCast(arr.items.ptr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1010,6 +1010,14 @@ pub fn scriptAddedCallback(self: *Page, comptime from_parser: bool, script: *Ele
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (comptime from_parser) {
|
||||||
|
// parser-inserted scripts have force-async set to false, but only if
|
||||||
|
// they have src or non-empty content
|
||||||
|
if (script._src.len > 0 or script.asNode().firstChild() != null) {
|
||||||
|
script._force_async = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self._script_manager.addFromElement(from_parser, script, "parsing") catch |err| {
|
self._script_manager.addFromElement(from_parser, script, "parsing") catch |err| {
|
||||||
log.err(.page, "page.scriptAddedCallback", .{
|
log.err(.page, "page.scriptAddedCallback", .{
|
||||||
.err = err,
|
.err = err,
|
||||||
@@ -2643,6 +2651,8 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parent_is_connected = parent.isConnected();
|
||||||
|
|
||||||
// Tri-state behavior for mutations:
|
// Tri-state behavior for mutations:
|
||||||
// 1. from_parser=true, parse_mode=document -> no mutations (initial document parse)
|
// 1. from_parser=true, parse_mode=document -> no mutations (initial document parse)
|
||||||
// 2. from_parser=true, parse_mode=fragment -> mutations (innerHTML additions)
|
// 2. from_parser=true, parse_mode=fragment -> mutations (innerHTML additions)
|
||||||
@@ -2658,6 +2668,15 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod
|
|||||||
// When the parser adds the node, nodeIsReady is only called when the
|
// When the parser adds the node, nodeIsReady is only called when the
|
||||||
// nodeComplete() callback is executed.
|
// nodeComplete() callback is executed.
|
||||||
try self.nodeIsReady(false, child);
|
try self.nodeIsReady(false, child);
|
||||||
|
|
||||||
|
// Check if text was added to a script that hasn't started yet.
|
||||||
|
if (child._type == .cdata and parent_is_connected) {
|
||||||
|
if (parent.is(Element.Html.Script)) |script| {
|
||||||
|
if (!script._executed) {
|
||||||
|
try self.nodeIsReady(false, parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify mutation observers about childList change
|
// Notify mutation observers about childList change
|
||||||
@@ -2696,7 +2715,6 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parent_in_shadow = parent.is(ShadowRoot) != null or parent.isInShadowTree();
|
const parent_in_shadow = parent.is(ShadowRoot) != null or parent.isInShadowTree();
|
||||||
const parent_is_connected = parent.isConnected();
|
|
||||||
|
|
||||||
if (!parent_in_shadow and !parent_is_connected) {
|
if (!parent_in_shadow and !parent_is_connected) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -159,7 +159,6 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
|
|||||||
// <script> has already been processed.
|
// <script> has already been processed.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
script_element._executed = true;
|
|
||||||
|
|
||||||
const element = script_element.asElement();
|
const element = script_element.asElement();
|
||||||
if (element.getAttributeSafe(comptime .wrap("nomodule")) != null) {
|
if (element.getAttributeSafe(comptime .wrap("nomodule")) != null) {
|
||||||
@@ -204,10 +203,22 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
|
|||||||
source = .{ .remote = .{} };
|
source = .{ .remote = .{} };
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const inline_source = try element.asNode().getTextContentAlloc(page.arena);
|
var buf = std.Io.Writer.Allocating.init(page.arena);
|
||||||
|
try element.asNode().getChildTextContent(&buf.writer);
|
||||||
|
try buf.writer.writeByte(0);
|
||||||
|
const data = buf.written();
|
||||||
|
const inline_source: [:0]const u8 = data[0 .. data.len - 1 :0];
|
||||||
|
if (inline_source.len == 0) {
|
||||||
|
// we haven't set script_element._executed = true yet, which is good.
|
||||||
|
// If content is appended to the script, we will execute it then.
|
||||||
|
return;
|
||||||
|
}
|
||||||
source = .{ .@"inline" = inline_source };
|
source = .{ .@"inline" = inline_source };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only set _executed (already-started) when we actually have content to execute
|
||||||
|
script_element._executed = true;
|
||||||
|
|
||||||
const script = try self.script_pool.create();
|
const script = try self.script_pool.create();
|
||||||
errdefer self.script_pool.destroy(script);
|
errdefer self.script_pool.destroy(script);
|
||||||
|
|
||||||
|
|||||||
@@ -1405,3 +1405,12 @@ test "URL: unescape" {
|
|||||||
try testing.expectEqual("hello%2", result);
|
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"));
|
||||||
|
}
|
||||||
|
|||||||
@@ -245,6 +245,46 @@ pub fn toJson(self: Value, allocator: Allocator) ![]u8 {
|
|||||||
return js.String.toSliceWithAlloc(.{ .local = local, .handle = str_handle }, allocator);
|
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 {
|
pub fn persist(self: Value) !Global {
|
||||||
return self._persist(true);
|
return self._persist(true);
|
||||||
}
|
}
|
||||||
|
|||||||
61
src/browser/tests/element/html/script/async_text.html
Normal file
61
src/browser/tests/element/html/script/async_text.html
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<script src="../../../testing.js"></script>
|
||||||
|
|
||||||
|
<script id=force_async>
|
||||||
|
{
|
||||||
|
// Dynamically created scripts have async=true by default
|
||||||
|
let s = document.createElement('script');
|
||||||
|
testing.expectEqual(true, s.async);
|
||||||
|
|
||||||
|
// Setting async=false clears the force async flag and removes attribute
|
||||||
|
s.async = false;
|
||||||
|
testing.expectEqual(false, s.async);
|
||||||
|
testing.expectEqual(false, s.hasAttribute('async'));
|
||||||
|
|
||||||
|
// Setting async=true adds the attribute
|
||||||
|
s.async = true;
|
||||||
|
testing.expectEqual(true, s.async);
|
||||||
|
testing.expectEqual(true, s.hasAttribute('async'));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script></script>
|
||||||
|
<script id=empty>
|
||||||
|
{
|
||||||
|
// Empty parser-inserted script should have async=true (force async retained)
|
||||||
|
let scripts = document.getElementsByTagName('script');
|
||||||
|
let emptyScript = scripts[scripts.length - 2];
|
||||||
|
testing.expectEqual(true, emptyScript.async);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id=text_content>
|
||||||
|
{
|
||||||
|
let s = document.createElement('script');
|
||||||
|
s.appendChild(document.createComment('COMMENT'));
|
||||||
|
s.appendChild(document.createTextNode(' TEXT '));
|
||||||
|
s.appendChild(document.createProcessingInstruction('P', 'I'));
|
||||||
|
let a = s.appendChild(document.createElement('a'));
|
||||||
|
a.appendChild(document.createTextNode('ELEMENT'));
|
||||||
|
|
||||||
|
// script.text should return only direct Text node children
|
||||||
|
testing.expectEqual(' TEXT ', s.text);
|
||||||
|
// script.textContent should return all descendant text
|
||||||
|
testing.expectEqual(' TEXT ELEMENT', s.textContent);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script id=lazy_inline>
|
||||||
|
{
|
||||||
|
// Empty script in DOM, then append text - should execute
|
||||||
|
window.lazyScriptRan = false;
|
||||||
|
let s = document.createElement('script');
|
||||||
|
document.head.appendChild(s);
|
||||||
|
// Script is in DOM but empty, so not yet executed
|
||||||
|
testing.expectEqual(false, window.lazyScriptRan);
|
||||||
|
// Append text node with code
|
||||||
|
s.appendChild(document.createTextNode('window.lazyScriptRan = true;'));
|
||||||
|
// Now it should have executed
|
||||||
|
testing.expectEqual(true, window.lazyScriptRan);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
{
|
{
|
||||||
let f5 = document.createElement('iframe');
|
let f5 = document.createElement('iframe');
|
||||||
f5.id = 'f5';
|
f5.id = 'f5';
|
||||||
f5.src = "support/sub 1.html";
|
f5.src = "support/page.html";
|
||||||
document.documentElement.appendChild(f5);
|
document.documentElement.appendChild(f5);
|
||||||
f5.src = "about:blank";
|
f5.src = "about:blank";
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,143 @@
|
|||||||
testing.expectEqual(screen, window.screen);
|
testing.expectEqual(screen, window.screen);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script id=structuredClone>
|
||||||
|
// Basic types
|
||||||
|
testing.expectEqual(42, structuredClone(42));
|
||||||
|
testing.expectEqual('hello', structuredClone('hello'));
|
||||||
|
testing.expectEqual(true, structuredClone(true));
|
||||||
|
testing.expectEqual(null, structuredClone(null));
|
||||||
|
testing.expectEqual(undefined, structuredClone(undefined));
|
||||||
|
|
||||||
|
// Objects and arrays (these work with JSON too, but verify they're cloned)
|
||||||
|
const obj = { a: 1, b: { c: 2 } };
|
||||||
|
const clonedObj = structuredClone(obj);
|
||||||
|
testing.expectEqual(1, clonedObj.a);
|
||||||
|
testing.expectEqual(2, clonedObj.b.c);
|
||||||
|
clonedObj.b.c = 999;
|
||||||
|
testing.expectEqual(2, obj.b.c); // original unchanged
|
||||||
|
|
||||||
|
const arr = [1, [2, 3]];
|
||||||
|
const clonedArr = structuredClone(arr);
|
||||||
|
testing.expectEqual(1, clonedArr[0]);
|
||||||
|
testing.expectEqual(2, clonedArr[1][0]);
|
||||||
|
clonedArr[1][0] = 999;
|
||||||
|
testing.expectEqual(2, arr[1][0]); // original unchanged
|
||||||
|
|
||||||
|
// Date - JSON would stringify to ISO string
|
||||||
|
const date = new Date('2024-01-15T12:30:00Z');
|
||||||
|
const clonedDate = structuredClone(date);
|
||||||
|
testing.expectEqual(true, clonedDate instanceof Date);
|
||||||
|
testing.expectEqual(date.getTime(), clonedDate.getTime());
|
||||||
|
testing.expectEqual(date.toISOString(), clonedDate.toISOString());
|
||||||
|
|
||||||
|
// RegExp - JSON would stringify to {}
|
||||||
|
const regex = /test\d+/gi;
|
||||||
|
const clonedRegex = structuredClone(regex);
|
||||||
|
testing.expectEqual(true, clonedRegex instanceof RegExp);
|
||||||
|
testing.expectEqual(regex.source, clonedRegex.source);
|
||||||
|
testing.expectEqual(regex.flags, clonedRegex.flags);
|
||||||
|
testing.expectEqual(true, clonedRegex.test('test123'));
|
||||||
|
|
||||||
|
// Map - JSON can't handle
|
||||||
|
const map = new Map([['a', 1], ['b', 2]]);
|
||||||
|
const clonedMap = structuredClone(map);
|
||||||
|
testing.expectEqual(true, clonedMap instanceof Map);
|
||||||
|
testing.expectEqual(2, clonedMap.size);
|
||||||
|
testing.expectEqual(1, clonedMap.get('a'));
|
||||||
|
testing.expectEqual(2, clonedMap.get('b'));
|
||||||
|
|
||||||
|
// Set - JSON can't handle
|
||||||
|
const set = new Set([1, 2, 3]);
|
||||||
|
const clonedSet = structuredClone(set);
|
||||||
|
testing.expectEqual(true, clonedSet instanceof Set);
|
||||||
|
testing.expectEqual(3, clonedSet.size);
|
||||||
|
testing.expectEqual(true, clonedSet.has(1));
|
||||||
|
testing.expectEqual(true, clonedSet.has(2));
|
||||||
|
testing.expectEqual(true, clonedSet.has(3));
|
||||||
|
|
||||||
|
// ArrayBuffer
|
||||||
|
const buffer = new ArrayBuffer(8);
|
||||||
|
const view = new Uint8Array(buffer);
|
||||||
|
view[0] = 42;
|
||||||
|
view[7] = 99;
|
||||||
|
const clonedBuffer = structuredClone(buffer);
|
||||||
|
testing.expectEqual(true, clonedBuffer instanceof ArrayBuffer);
|
||||||
|
testing.expectEqual(8, clonedBuffer.byteLength);
|
||||||
|
const clonedView = new Uint8Array(clonedBuffer);
|
||||||
|
testing.expectEqual(42, clonedView[0]);
|
||||||
|
testing.expectEqual(99, clonedView[7]);
|
||||||
|
|
||||||
|
// TypedArray
|
||||||
|
const typedArr = new Uint32Array([100, 200, 300]);
|
||||||
|
const clonedTypedArr = structuredClone(typedArr);
|
||||||
|
testing.expectEqual(true, clonedTypedArr instanceof Uint32Array);
|
||||||
|
testing.expectEqual(3, clonedTypedArr.length);
|
||||||
|
testing.expectEqual(100, clonedTypedArr[0]);
|
||||||
|
testing.expectEqual(200, clonedTypedArr[1]);
|
||||||
|
testing.expectEqual(300, clonedTypedArr[2]);
|
||||||
|
|
||||||
|
// Special number values - JSON can't preserve these
|
||||||
|
testing.expectEqual(true, Number.isNaN(structuredClone(NaN)));
|
||||||
|
testing.expectEqual(Infinity, structuredClone(Infinity));
|
||||||
|
testing.expectEqual(-Infinity, structuredClone(-Infinity));
|
||||||
|
|
||||||
|
// Object with undefined value - JSON would omit it
|
||||||
|
const objWithUndef = { a: 1, b: undefined, c: 3 };
|
||||||
|
const clonedObjWithUndef = structuredClone(objWithUndef);
|
||||||
|
testing.expectEqual(1, clonedObjWithUndef.a);
|
||||||
|
testing.expectEqual(undefined, clonedObjWithUndef.b);
|
||||||
|
testing.expectEqual(true, 'b' in clonedObjWithUndef);
|
||||||
|
testing.expectEqual(3, clonedObjWithUndef.c);
|
||||||
|
|
||||||
|
// Error objects
|
||||||
|
const error = new Error('test error');
|
||||||
|
const clonedError = structuredClone(error);
|
||||||
|
testing.expectEqual(true, clonedError instanceof Error);
|
||||||
|
testing.expectEqual('test error', clonedError.message);
|
||||||
|
|
||||||
|
// TypeError
|
||||||
|
const typeError = new TypeError('type error');
|
||||||
|
const clonedTypeError = structuredClone(typeError);
|
||||||
|
testing.expectEqual(true, clonedTypeError instanceof TypeError);
|
||||||
|
testing.expectEqual('type error', clonedTypeError.message);
|
||||||
|
|
||||||
|
// BigInt
|
||||||
|
const bigInt = BigInt('9007199254740993');
|
||||||
|
const clonedBigInt = structuredClone(bigInt);
|
||||||
|
testing.expectEqual(bigInt, clonedBigInt);
|
||||||
|
|
||||||
|
// Circular references ARE supported by structuredClone (unlike JSON)
|
||||||
|
const circular = { a: 1 };
|
||||||
|
circular.self = circular;
|
||||||
|
const clonedCircular = structuredClone(circular);
|
||||||
|
testing.expectEqual(1, clonedCircular.a);
|
||||||
|
testing.expectEqual(clonedCircular, clonedCircular.self); // circular ref preserved
|
||||||
|
|
||||||
|
// Functions cannot be cloned - should throw
|
||||||
|
{
|
||||||
|
let threw = false;
|
||||||
|
try {
|
||||||
|
structuredClone(() => {});
|
||||||
|
} catch (err) {
|
||||||
|
threw = true;
|
||||||
|
// Just verify an error was thrown - V8's message format may vary
|
||||||
|
}
|
||||||
|
testing.expectEqual(true, threw);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Symbols cannot be cloned - should throw
|
||||||
|
{
|
||||||
|
let threw = false;
|
||||||
|
try {
|
||||||
|
structuredClone(Symbol('test'));
|
||||||
|
} catch (err) {
|
||||||
|
threw = true;
|
||||||
|
}
|
||||||
|
testing.expectEqual(true, threw);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<script id=unhandled_rejection>
|
<script id=unhandled_rejection>
|
||||||
{
|
{
|
||||||
let unhandledCalled = 0;
|
let unhandledCalled = 0;
|
||||||
|
|||||||
@@ -151,9 +151,14 @@ pub fn asNode(self: *CData) *Node {
|
|||||||
|
|
||||||
pub fn is(self: *CData, comptime T: type) ?*T {
|
pub fn is(self: *CData, comptime T: type) ?*T {
|
||||||
inline for (@typeInfo(Type).@"union".fields) |f| {
|
inline for (@typeInfo(Type).@"union".fields) |f| {
|
||||||
if (f.type == T and @field(Type, f.name) == self._type) {
|
if (@field(Type, f.name) == self._type) {
|
||||||
|
if (f.type == T) {
|
||||||
return &@field(self._type, f.name);
|
return &@field(self._type, f.name);
|
||||||
}
|
}
|
||||||
|
if (f.type == *T) {
|
||||||
|
return @field(self._type, f.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -285,6 +285,19 @@ pub fn getTextContentAlloc(self: *Node, allocator: Allocator) error{WriteFailed}
|
|||||||
return data[0 .. data.len - 1 :0];
|
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 {
|
pub fn setTextContent(self: *Node, data: []const u8, page: *Page) !void {
|
||||||
switch (self._type) {
|
switch (self._type) {
|
||||||
.element => |el| {
|
.element => |el| {
|
||||||
|
|||||||
@@ -412,6 +412,10 @@ pub fn atob(_: *const Window, input: []const u8, page: *Page) ![]const u8 {
|
|||||||
return decoded;
|
return decoded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn structuredClone(_: *const Window, value: js.Value) !js.Value {
|
||||||
|
return value.structuredClone();
|
||||||
|
}
|
||||||
|
|
||||||
pub fn getFrame(self: *Window, idx: usize) !?*Window {
|
pub fn getFrame(self: *Window, idx: usize) !?*Window {
|
||||||
const page = self._page;
|
const page = self._page;
|
||||||
const frames = page.frames.items;
|
const frames = page.frames.items;
|
||||||
@@ -797,6 +801,7 @@ pub const JsApi = struct {
|
|||||||
pub const btoa = bridge.function(Window.btoa, .{});
|
pub const btoa = bridge.function(Window.btoa, .{});
|
||||||
pub const atob = bridge.function(Window.atob, .{ .dom_exception = true });
|
pub const atob = bridge.function(Window.atob, .{ .dom_exception = true });
|
||||||
pub const reportError = bridge.function(Window.reportError, .{});
|
pub const reportError = bridge.function(Window.reportError, .{});
|
||||||
|
pub const structuredClone = bridge.function(Window.structuredClone, .{});
|
||||||
pub const getComputedStyle = bridge.function(Window.getComputedStyle, .{});
|
pub const getComputedStyle = bridge.function(Window.getComputedStyle, .{});
|
||||||
pub const getSelection = bridge.function(Window.getSelection, .{});
|
pub const getSelection = bridge.function(Window.getSelection, .{});
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,16 @@ pub fn asNode(self: *Body) *Node {
|
|||||||
return self.asElement().asNode();
|
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 JsApi = struct {
|
||||||
pub const bridge = js.Bridge(Body);
|
pub const bridge = js.Bridge(Body);
|
||||||
|
|
||||||
@@ -44,6 +54,8 @@ pub const JsApi = struct {
|
|||||||
pub const prototype_chain = bridge.prototypeChain();
|
pub const prototype_chain = bridge.prototypeChain();
|
||||||
pub var class_id: bridge.ClassId = undefined;
|
pub var class_id: bridge.ClassId = undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub const onload = bridge.accessor(getOnLoad, setOnLoad, .{ .null_as_undefined = false });
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const Build = struct {
|
pub const Build = struct {
|
||||||
|
|||||||
@@ -61,10 +61,9 @@ pub fn setValue(self: *Option, value: []const u8, page: *Page) !void {
|
|||||||
self._value = owned;
|
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 node: *Node = @constCast(self.asConstElement().asConstNode());
|
||||||
const allocator = std.heap.page_allocator; // TODO: use proper allocator
|
return node.getTextContentAlloc(page.call_arena) catch "";
|
||||||
return node.getTextContentAlloc(allocator) catch "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn setText(self: *Option, value: []const u8, page: *Page) !void {
|
pub fn setText(self: *Option, value: []const u8, page: *Page) !void {
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ const Script = @This();
|
|||||||
_proto: *HtmlElement,
|
_proto: *HtmlElement,
|
||||||
_src: []const u8 = "",
|
_src: []const u8 = "",
|
||||||
_executed: bool = false,
|
_executed: bool = false,
|
||||||
|
// dynamic scripts are forced to be async by default
|
||||||
|
_force_async: bool = true,
|
||||||
|
|
||||||
pub fn asElement(self: *Script) *Element {
|
pub fn asElement(self: *Script) *Element {
|
||||||
return self._proto._proto;
|
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 {
|
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 {
|
pub fn setAsync(self: *Script, value: bool, page: *Page) !void {
|
||||||
|
self._force_async = false;
|
||||||
if (value) {
|
if (value) {
|
||||||
try self.asElement().setAttributeSafe(comptime .wrap("async"), .wrap(""), page);
|
try self.asElement().setAttributeSafe(comptime .wrap("async"), .wrap(""), page);
|
||||||
} else {
|
} else {
|
||||||
@@ -136,7 +139,12 @@ pub const JsApi = struct {
|
|||||||
try self.asNode().getTextContent(&buf.writer);
|
try self.asNode().getTextContent(&buf.writer);
|
||||||
return buf.written();
|
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 {
|
pub const Build = struct {
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ pub fn CDPT(comptime TypeProvider: type) type {
|
|||||||
return command.sendResult(.{
|
return command.sendResult(.{
|
||||||
.frameTree = .{
|
.frameTree = .{
|
||||||
.frame = .{
|
.frame = .{
|
||||||
.id = "TID-STARTUP-B",
|
.id = "TID-STARTUP",
|
||||||
.loaderId = "LOADERID24DD2FD56CF1EF33C965C79C",
|
.loaderId = "LOADERID24DD2FD56CF1EF33C965C79C",
|
||||||
.securityOrigin = URL_BASE,
|
.securityOrigin = URL_BASE,
|
||||||
.url = "about:blank",
|
.url = "about:blank",
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ fn getTargetInfo(cmd: anytype) !void {
|
|||||||
|
|
||||||
return cmd.sendResult(.{
|
return cmd.sendResult(.{
|
||||||
.targetInfo = TargetInfo{
|
.targetInfo = TargetInfo{
|
||||||
.targetId = "TID-STARTUP-B",
|
.targetId = "TID-STARTUP",
|
||||||
.type = "browser",
|
.type = "browser",
|
||||||
.title = "",
|
.title = "",
|
||||||
.url = "about:blank",
|
.url = "about:blank",
|
||||||
@@ -424,14 +424,13 @@ fn setAutoAttach(cmd: anytype) !void {
|
|||||||
// set a flag to send Target.attachedToTarget events
|
// set a flag to send Target.attachedToTarget events
|
||||||
cmd.cdp.target_auto_attach = params.autoAttach;
|
cmd.cdp.target_auto_attach = params.autoAttach;
|
||||||
|
|
||||||
try cmd.sendResult(null, .{});
|
|
||||||
|
|
||||||
if (cmd.cdp.target_auto_attach == false) {
|
if (cmd.cdp.target_auto_attach == false) {
|
||||||
// detach from all currently attached targets.
|
// detach from all currently attached targets.
|
||||||
if (cmd.browser_context) |bc| {
|
if (cmd.browser_context) |bc| {
|
||||||
bc.session_id = null;
|
bc.session_id = null;
|
||||||
// TODO should we send a Target.detachedFromTarget event?
|
// TODO should we send a Target.detachedFromTarget event?
|
||||||
}
|
}
|
||||||
|
try cmd.sendResult(null, .{});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,7 +443,7 @@ fn setAutoAttach(cmd: anytype) !void {
|
|||||||
try doAttachtoTarget(cmd, &bc.target_id.?);
|
try doAttachtoTarget(cmd, &bc.target_id.?);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// should we send something here?
|
try cmd.sendResult(null, .{});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,12 +459,14 @@ fn setAutoAttach(cmd: anytype) !void {
|
|||||||
.sessionId = "STARTUP",
|
.sessionId = "STARTUP",
|
||||||
.targetInfo = TargetInfo{
|
.targetInfo = TargetInfo{
|
||||||
.type = "page",
|
.type = "page",
|
||||||
.targetId = "TID-STARTUP-P",
|
.targetId = "TID-STARTUP",
|
||||||
.title = "",
|
.title = "",
|
||||||
.url = "about:blank",
|
.url = "about:blank",
|
||||||
.browserContextId = "BID-STARTUP",
|
.browserContextId = "BID-STARTUP",
|
||||||
},
|
},
|
||||||
}, .{});
|
}, .{});
|
||||||
|
|
||||||
|
try cmd.sendResult(null, .{});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn doAttachtoTarget(cmd: anytype, target_id: []const u8) !void {
|
fn doAttachtoTarget(cmd: anytype, target_id: []const u8) !void {
|
||||||
|
|||||||
@@ -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 NID_X25519 = @as(c_int, 948);
|
||||||
pub const EVP_PKEY_X25519 = NID_X25519;
|
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_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;
|
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_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(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_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;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const libcurl = @import("../sys/libcurl.zig");
|
|||||||
|
|
||||||
const net_http = @import("http.zig");
|
const net_http = @import("http.zig");
|
||||||
const RobotStore = @import("Robots.zig").RobotStore;
|
const RobotStore = @import("Robots.zig").RobotStore;
|
||||||
|
const WebBotAuth = @import("WebBotAuth.zig");
|
||||||
|
|
||||||
const Runtime = @This();
|
const Runtime = @This();
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ allocator: Allocator,
|
|||||||
config: *const Config,
|
config: *const Config,
|
||||||
ca_blob: ?net_http.Blob,
|
ca_blob: ?net_http.Blob,
|
||||||
robot_store: RobotStore,
|
robot_store: RobotStore,
|
||||||
|
web_bot_auth: ?WebBotAuth,
|
||||||
|
|
||||||
connections: []net_http.Connection,
|
connections: []net_http.Connection,
|
||||||
available: std.DoublyLinkedList = .{},
|
available: std.DoublyLinkedList = .{},
|
||||||
@@ -205,6 +207,11 @@ pub fn init(allocator: Allocator, config: *const Config) !Runtime {
|
|||||||
available.append(&connections[i].node);
|
available.append(&connections[i].node);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const web_bot_auth = if (config.webBotAuth()) |wba_cfg|
|
||||||
|
try WebBotAuth.fromConfig(allocator, &wba_cfg)
|
||||||
|
else
|
||||||
|
null;
|
||||||
|
|
||||||
return .{
|
return .{
|
||||||
.allocator = allocator,
|
.allocator = allocator,
|
||||||
.config = config,
|
.config = config,
|
||||||
@@ -212,6 +219,7 @@ pub fn init(allocator: Allocator, config: *const Config) !Runtime {
|
|||||||
.robot_store = RobotStore.init(allocator),
|
.robot_store = RobotStore.init(allocator),
|
||||||
.connections = connections,
|
.connections = connections,
|
||||||
.available = available,
|
.available = available,
|
||||||
|
.web_bot_auth = web_bot_auth,
|
||||||
.pollfds = pollfds,
|
.pollfds = pollfds,
|
||||||
.wakeup_pipe = pipe,
|
.wakeup_pipe = pipe,
|
||||||
};
|
};
|
||||||
@@ -238,6 +246,9 @@ pub fn deinit(self: *Runtime) void {
|
|||||||
self.allocator.free(self.connections);
|
self.allocator.free(self.connections);
|
||||||
|
|
||||||
self.robot_store.deinit();
|
self.robot_store.deinit();
|
||||||
|
if (self.web_bot_auth) |wba| {
|
||||||
|
wba.deinit(self.allocator);
|
||||||
|
}
|
||||||
|
|
||||||
globalDeinit();
|
globalDeinit();
|
||||||
}
|
}
|
||||||
|
|||||||
284
src/network/WebBotAuth.zig
Normal file
284
src/network/WebBotAuth.zig
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
// 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 crypto = @import("../crypto.zig");
|
||||||
|
|
||||||
|
const Http = @import("../network/http.zig");
|
||||||
|
|
||||||
|
const WebBotAuth = @This();
|
||||||
|
|
||||||
|
pkey: *crypto.EVP_PKEY,
|
||||||
|
keyid: []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);
|
||||||
|
|
||||||
|
// decode base64 into 48-byte DER buffer
|
||||||
|
var der: [48]u8 = undefined;
|
||||||
|
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];
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -501,7 +501,7 @@ pub const TrackingAllocator = struct {
|
|||||||
defer self.mutex.unlock();
|
defer self.mutex.unlock();
|
||||||
|
|
||||||
const result = self.parent_allocator.rawResize(old_mem, alignment, new_len, ra);
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,7 +531,7 @@ pub const TrackingAllocator = struct {
|
|||||||
defer self.mutex.unlock();
|
defer self.mutex.unlock();
|
||||||
|
|
||||||
const result = self.parent_allocator.rawRemap(memory, alignment, new_len, ret_addr);
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user