diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml
index 97c3349c1..d752a0b49 100644
--- a/.github/actions/install/action.yml
+++ b/.github/actions/install/action.yml
@@ -13,11 +13,11 @@ inputs:
zig-v8:
description: 'zig v8 version to install'
required: false
- default: 'v0.4.8'
+ default: 'v0.4.9'
v8:
description: 'v8 version to install'
required: false
- default: '14.0.365.4'
+ default: '14.9.207.35'
cache-dir:
description: 'cache dir to use'
required: false
diff --git a/.github/actions/v8-snapshot/action.yml b/.github/actions/v8-snapshot/action.yml
index 84a61acaa..7e3365f83 100644
--- a/.github/actions/v8-snapshot/action.yml
+++ b/.github/actions/v8-snapshot/action.yml
@@ -13,7 +13,7 @@ inputs:
zig-v8:
description: 'zig-v8 release tag the prebuilt lib came from'
required: false
- default: 'v0.4.8'
+ default: 'v0.4.9'
runs:
using: "composite"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 1c177cd68..5550e8a81 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -161,6 +161,12 @@ jobs:
with:
name: lightpanda-x86_64-linux
+ - uses: docker/login-action@v4
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
- name: "Generate and publish versions.json"
run: |
chmod +x lightpanda-x86_64-linux
diff --git a/Dockerfile b/Dockerfile
index 8cecd404b..889c2a78d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,9 +1,10 @@
+
FROM debian:stable-slim
ARG MINISIG=0.12
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
-ARG V8=14.0.365.4
-ARG ZIG_V8=v0.4.8
+ARG V8=14.9.207.35
+ARG ZIG_V8=v0.4.9
ARG TARGETPLATFORM
RUN apt-get update -yq && \
diff --git a/build.zig b/build.zig
index eb165690c..c0dd98445 100644
--- a/build.zig
+++ b/build.zig
@@ -511,6 +511,7 @@ fn buildCurl(
.USE_OPENSSL = true,
.OPENSSL_IS_BORINGSSL = true,
+ .CURL_BORINGSSL_VERSION = null,
.CURL_CA_PATH = null,
.CURL_CA_BUNDLE = null,
.CURL_CA_FALLBACK = false,
@@ -607,6 +608,7 @@ fn buildCurl(
// general environment
.CURL_KRB5_VERSION = null,
+ .CURL_PATCHSTAMP = null,
.HAVE_ALARM = !is_windows,
.HAVE_ARC4RANDOM = is_android,
.HAVE_ATOMIC = true,
@@ -626,6 +628,7 @@ fn buildCurl(
.HAVE_MEMRCHR = !is_darwin and !is_windows,
.HAVE_POSIX_STRERROR_R = !is_gnu and !is_windows,
.HAVE_PTHREAD_H = !is_windows,
+ .HAVE_THREADS_POSIX = !is_windows,
.HAVE_SETLOCALE = true,
.HAVE_SETRLIMIT = !is_windows,
.HAVE_SIGACTION = !is_windows,
@@ -647,6 +650,7 @@ fn buildCurl(
.HAVE_WRITABLE_ARGV = !is_windows,
.HAVE__SETMODE = is_windows,
.USE_THREADS_POSIX = !is_windows,
+ .USE_RESOLV_THREADED = !is_windows,
// filesystem, network
.HAVE_ACCEPT4 = is_linux or is_freebsd or is_netbsd or is_openbsd,
@@ -734,66 +738,52 @@ fn buildCurl(
},
.files = &.{
// You can include all files from lib, libcurl uses #ifdef-guards to exclude code for disabled functions
- "altsvc.c", "amigaos.c", "asyn-ares.c",
- "asyn-base.c", "asyn-thrdd.c", "bufq.c",
- "bufref.c", "cf-h1-proxy.c", "cf-h2-proxy.c",
- "cf-haproxy.c", "cf-https-connect.c", "cf-ip-happy.c",
- "cf-socket.c", "cfilters.c", "conncache.c",
- "connect.c", "content_encoding.c", "cookie.c",
- "cshutdn.c", "curl_addrinfo.c", "curl_endian.c",
- "curl_fnmatch.c", "curl_fopen.c", "curl_get_line.c",
- "curl_gethostname.c", "curl_gssapi.c", "curl_memrchr.c",
- "curl_ntlm_core.c", "curl_range.c", "curl_rtmp.c",
- "curl_sasl.c", "curl_sha512_256.c", "curl_share.c",
- "curl_sspi.c", "curl_threads.c", "curl_trc.c",
- "curlx/base64.c", "curlx/dynbuf.c", "curlx/fopen.c",
- "curlx/inet_ntop.c", "curlx/inet_pton.c", "curlx/multibyte.c",
- "curlx/nonblock.c", "curlx/strcopy.c", "curlx/strerr.c",
- "curlx/strparse.c", "curlx/timediff.c", "curlx/timeval.c",
- "curlx/version_win32.c", "curlx/wait.c", "curlx/warnless.c",
- "curlx/winapi.c", "cw-out.c", "cw-pause.c",
- "dict.c", "dllmain.c", "doh.c",
- "dynhds.c", "easy.c", "easygetopt.c",
- "easyoptions.c", "escape.c", "fake_addrinfo.c",
- "file.c", "fileinfo.c", "formdata.c",
- "ftp.c", "ftplistparser.c", "getenv.c",
- "getinfo.c", "gopher.c", "hash.c",
- "headers.c", "hmac.c", "hostip.c",
- "hostip4.c", "hostip6.c", "hsts.c",
- "http.c", "http1.c", "http2.c",
- "http_aws_sigv4.c", "http_chunks.c", "http_digest.c",
- "http_negotiate.c", "http_ntlm.c", "http_proxy.c",
- "httpsrr.c", "idn.c", "if2ip.c",
- "imap.c", "ldap.c", "llist.c",
- "macos.c", "md4.c", "md5.c",
- "memdebug.c", "mime.c", "mprintf.c",
- "mqtt.c", "multi.c", "multi_ev.c",
- "multi_ntfy.c", "netrc.c", "noproxy.c",
- "openldap.c", "parsedate.c", "pingpong.c",
- "pop3.c", "progress.c", "psl.c",
- "rand.c", "ratelimit.c", "request.c",
- "rtsp.c", "select.c", "sendf.c",
- "setopt.c", "sha256.c", "slist.c",
- "smb.c", "smtp.c", "socketpair.c",
- "socks.c", "socks_gssapi.c", "socks_sspi.c",
- "splay.c", "strcase.c", "strdup.c",
- "strequal.c", "strerror.c", "system_win32.c",
- "telnet.c", "tftp.c", "transfer.c",
- "uint-bset.c", "uint-hash.c", "uint-spbset.c",
- "uint-table.c", "url.c", "urlapi.c",
- "vauth/cleartext.c", "vauth/cram.c", "vauth/digest.c",
- "vauth/digest_sspi.c", "vauth/gsasl.c", "vauth/krb5_gssapi.c",
- "vauth/krb5_sspi.c", "vauth/ntlm.c", "vauth/ntlm_sspi.c",
- "vauth/oauth2.c", "vauth/spnego_gssapi.c", "vauth/spnego_sspi.c",
- "vauth/vauth.c", "version.c", "vquic/curl_ngtcp2.c",
- "vquic/curl_osslq.c", "vquic/curl_quiche.c", "vquic/vquic-tls.c",
- "vquic/vquic.c", "vssh/libssh.c", "vssh/libssh2.c",
- "vssh/vssh.c", "vtls/apple.c", "vtls/cipher_suite.c",
- "vtls/gtls.c", "vtls/hostcheck.c", "vtls/keylog.c",
- "vtls/mbedtls.c", "vtls/openssl.c", "vtls/rustls.c",
- "vtls/schannel.c", "vtls/schannel_verify.c", "vtls/vtls.c",
- "vtls/vtls_scache.c", "vtls/vtls_spack.c", "vtls/wolfssl.c",
- "vtls/x509asn1.c", "ws.c",
+ "cf-dns.c", "dnscache.c", "protocol.c", "curlx/strdup.c",
+ "thrdpool.c", "thrdqueue.c", "altsvc.c", "amigaos.c",
+ "asyn-ares.c", "asyn-base.c", "asyn-thrdd.c", "bufq.c",
+ "bufref.c", "cf-h1-proxy.c", "cf-h2-proxy.c", "cf-haproxy.c",
+ "cf-https-connect.c", "cf-ip-happy.c", "cf-socket.c", "cfilters.c",
+ "conncache.c", "connect.c", "content_encoding.c", "cookie.c",
+ "cshutdn.c", "curl_addrinfo.c", "curl_endian.c", "curl_fnmatch.c",
+ "curl_fopen.c", "curl_get_line.c", "curl_gethostname.c", "curl_gssapi.c",
+ "curl_memrchr.c", "curl_ntlm_core.c", "curl_range.c", "curl_sasl.c",
+ "curl_sha512_256.c", "curl_share.c", "curl_sspi.c", "curl_threads.c",
+ "curl_trc.c", "curlx/base64.c", "curlx/dynbuf.c", "curlx/fopen.c",
+ "curlx/inet_ntop.c", "curlx/inet_pton.c", "curlx/multibyte.c", "curlx/nonblock.c",
+ "curlx/strcopy.c", "curlx/strerr.c", "curlx/strparse.c", "curlx/timediff.c",
+ "curlx/timeval.c", "curlx/version_win32.c", "curlx/wait.c", "curlx/warnless.c",
+ "curlx/winapi.c", "cw-out.c", "cw-pause.c", "dict.c",
+ "dllmain.c", "doh.c", "dynhds.c", "easy.c",
+ "easygetopt.c", "easyoptions.c", "escape.c", "fake_addrinfo.c",
+ "file.c", "fileinfo.c", "formdata.c", "ftp.c",
+ "ftplistparser.c", "getenv.c", "getinfo.c", "gopher.c",
+ "hash.c", "headers.c", "hmac.c", "hostip.c",
+ "hostip4.c", "hostip6.c", "hsts.c", "http.c",
+ "http1.c", "http2.c", "http_aws_sigv4.c", "http_chunks.c",
+ "http_digest.c", "http_negotiate.c", "http_ntlm.c", "http_proxy.c",
+ "httpsrr.c", "idn.c", "if2ip.c", "imap.c",
+ "ldap.c", "llist.c", "macos.c", "md4.c",
+ "md5.c", "memdebug.c", "mime.c", "mprintf.c",
+ "mqtt.c", "multi.c", "multi_ev.c", "multi_ntfy.c",
+ "netrc.c", "noproxy.c", "openldap.c", "parsedate.c",
+ "pingpong.c", "pop3.c", "progress.c", "psl.c",
+ "rand.c", "ratelimit.c", "request.c", "rtsp.c",
+ "select.c", "sendf.c", "setopt.c", "sha256.c",
+ "slist.c", "smb.c", "smtp.c", "socketpair.c",
+ "socks.c", "socks_gssapi.c", "socks_sspi.c", "splay.c",
+ "strcase.c", "strequal.c", "strerror.c", "system_win32.c",
+ "telnet.c", "tftp.c", "transfer.c", "uint-bset.c",
+ "uint-hash.c", "uint-spbset.c", "uint-table.c", "url.c",
+ "urlapi.c", "vauth/cleartext.c", "vauth/cram.c", "vauth/digest.c",
+ "vauth/digest_sspi.c", "vauth/gsasl.c", "vauth/krb5_gssapi.c", "vauth/krb5_sspi.c",
+ "vauth/ntlm.c", "vauth/ntlm_sspi.c", "vauth/oauth2.c", "vauth/spnego_gssapi.c",
+ "vauth/spnego_sspi.c", "vauth/vauth.c", "version.c", "vquic/curl_ngtcp2.c",
+ "vquic/curl_quiche.c", "vquic/vquic-tls.c", "vquic/vquic.c", "vssh/libssh.c",
+ "vssh/libssh2.c", "vssh/vssh.c", "vtls/apple.c", "vtls/cipher_suite.c",
+ "vtls/gtls.c", "vtls/hostcheck.c", "vtls/keylog.c", "vtls/mbedtls.c",
+ "vtls/openssl.c", "vtls/rustls.c", "vtls/schannel.c", "vtls/schannel_verify.c",
+ "vtls/vtls.c", "vtls/vtls_scache.c", "vtls/vtls_spack.c", "vtls/wolfssl.c",
+ "vtls/x509asn1.c", "ws.c",
},
});
diff --git a/build.zig.zon b/build.zig.zon
index b69d1ef42..ce222f12f 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -5,8 +5,8 @@
.minimum_zig_version = "0.15.2",
.dependencies = .{
.v8 = .{
- .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.4.8.tar.gz",
- .hash = "v8-0.0.0-xddH65qdBAA1hmB-gqEmhEzO0-aXKyEsmo1s34mssemb",
+ .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.4.9.tar.gz",
+ .hash = "v8-0.0.0-xddH63jnAgB8guO5jPzJD4pnX-CI5od-g4v-fIIjmQXr",
},
// .v8 = .{ .path = "../zig-v8-fork" },
.brotli = .{
@@ -19,16 +19,16 @@
.hash = "N-V-__8AAJ2cNgAgfBtAw33Bxfu1IWISDeKKSr3DAqoAysIJ",
},
.nghttp2 = .{
- .url = "https://github.com/nghttp2/nghttp2/releases/download/v1.68.0/nghttp2-1.68.0.tar.gz",
- .hash = "N-V-__8AAL15vQCI63ZL6Zaz5hJg6JTEgYXGbLnMFSnf7FT3",
+ .url = "https://github.com/nghttp2/nghttp2/releases/download/v1.69.0/nghttp2-1.69.0.tar.gz",
+ .hash = "N-V-__8AAMHpvwChC6orFCDB8MeiumT6OlfmKfmrWlak7Int",
},
.@"boringssl-zig" = .{
.url = "git+https://github.com/Syndica/boringssl-zig.git#c53df00d06b02b755ad88bbf4d1202ed9687b096",
.hash = "boringssl-0.1.0-VtJeWehMAAA4RNnwRnzEvKcS9rjsR1QVRw1uJrwXxmVK",
},
.curl = .{
- .url = "https://github.com/curl/curl/releases/download/curl-8_18_0/curl-8.18.0.tar.gz",
- .hash = "N-V-__8AALp9QAGn6CCHZ6fK_FfMyGtG824LSHYHHasM3w-y",
+ .url = "https://github.com/curl/curl/releases/download/curl-8_20_0/curl-8.20.0.tar.gz",
+ .hash = "N-V-__8AAHM9RQHjzJo8Wn4dbGPqNOC21zZJJA9PcCj7FPF5",
},
.sqlite3 = .{
.url = "https://github.com/allyourcodebase/sqlite3/archive/8f840560eae88ab66668c6827c64ffbd0d74ef37.tar.gz",
diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig
index 6f74483ce..248ededab 100644
--- a/src/browser/Factory.zig
+++ b/src/browser/Factory.zig
@@ -32,7 +32,6 @@ const MouseEvent = @import("webapi/event/MouseEvent.zig");
const Element = @import("webapi/Element.zig");
const Document = @import("webapi/Document.zig");
const EventTarget = @import("webapi/EventTarget.zig");
-const WorkerGlobalScope = @import("webapi/WorkerGlobalScope.zig");
const XMLHttpRequestEventTarget = @import("webapi/net/XMLHttpRequestEventTarget.zig");
const Blob = @import("webapi/Blob.zig");
const AbstractRange = @import("webapi/AbstractRange.zig");
diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig
index a97db6bd7..4f0207366 100644
--- a/src/browser/Frame.zig
+++ b/src/browser/Frame.zig
@@ -40,7 +40,6 @@ const FileList = @import("webapi/FileList.zig");
const Node = @import("webapi/Node.zig");
const Event = @import("webapi/Event.zig");
const EventTarget = @import("webapi/EventTarget.zig");
-const Text = @import("webapi/cdata/Text.zig");
const Element = @import("webapi/Element.zig");
const HtmlElement = @import("webapi/element/Html.zig");
const Window = @import("webapi/Window.zig");
@@ -51,8 +50,6 @@ const Performance = @import("webapi/Performance.zig");
const Screen = @import("webapi/Screen.zig");
const VisualViewport = @import("webapi/VisualViewport.zig");
const AbstractRange = @import("webapi/AbstractRange.zig");
-const MutationObserver = @import("webapi/MutationObserver.zig");
-const IntersectionObserver = @import("webapi/IntersectionObserver.zig");
const Worker = @import("webapi/Worker.zig");
const CSSStyleSheet = @import("webapi/css/CSSStyleSheet.zig");
const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig");
@@ -817,7 +814,7 @@ fn scheduleNavigationWithArena(originator: *Frame, arena: Allocator, request_url
arena,
frame_base,
request_url,
- .{ .always_dupe = true, .encoding = originator.charset },
+ .{ .encoding = originator.charset },
);
break :blk .{ u, false };
};
@@ -1769,7 +1766,7 @@ pub fn openPopup(self: *Frame, opts: OpenPopupOpts) !*Frame {
frame = frame.parent orelse break :base_blk "";
}
};
- break :blk try URL.resolve(self.call_arena, frame_base, opts.url, .{ .always_dupe = true, .encoding = self.charset });
+ break :blk try URL.resolve(self.call_arena, frame_base, opts.url, .{ .encoding = self.charset });
};
const popup = try page.frame_arena.create(Frame);
diff --git a/src/browser/HttpClient.zig b/src/browser/HttpClient.zig
index d272055cd..de9f9ca77 100644
--- a/src/browser/HttpClient.zig
+++ b/src/browser/HttpClient.zig
@@ -1048,7 +1048,7 @@ fn isTeardownMethod(method: []const u8) bool {
std.mem.eql(u8, method, "Page.close");
}
-fn isRedirectStatus(status: u16) bool {
+pub fn isRedirectStatus(status: u16) bool {
return switch (status) {
301, 302, 303, 307, 308 => true,
else => false,
@@ -1937,12 +1937,6 @@ pub const Transfer = struct {
fn handleRedirect(transfer: *Transfer, location: []const u8) !void {
const req = &transfer.req;
const conn = transfer._conn.?;
- const arena = transfer.arena;
-
- transfer._redirect_count += 1;
- if (transfer._redirect_count > transfer.client.network.config.httpMaxRedirects()) {
- return error.TooManyRedirects;
- }
// retrieve cookies from the redirect's response.
if (req.cookie_jar) |jar| {
@@ -1956,18 +1950,32 @@ pub const Transfer = struct {
}
}
+ // base_url and location are owned by curl; applyRedirectTarget resolves a
+ // fresh arena-owned copy that gets stored in transfer.req.url.
+ const base_url = try conn.getEffectiveUrl();
+ const status = try conn.getResponseCode();
+ try transfer.applyRedirectTarget(std.mem.span(base_url), location, status);
+ }
+
+ // Called above (in handleRedirect) and by a CDP fulfill request which redirects
+ pub fn applyRedirectTarget(transfer: *Transfer, base: [:0]const u8, location: []const u8, status: u16) !void {
+ const req = &transfer.req;
+ const arena = transfer.arena;
+
+ transfer._redirect_count += 1;
+ if (transfer._redirect_count > transfer.client.network.config.httpMaxRedirects()) {
+ return error.TooManyRedirects;
+ }
+
// resolve the redirect target.
const url: [:0]const u8 = blk: {
if (location.len == 0) {
// Might seem silly, but URL.resovle will return location as-is
- // if empty, and location is memory owned by libcurl.
+ // if empty, and location may be memory owned by libcurl.
break :blk "";
}
- const base_url = try conn.getEffectiveUrl();
- // base_url and location are owned by curl. The returned value
- // will be stored in transfer.req.url, hence the always_dupe.
- const resolved = try URL.resolve(arena, std.mem.span(base_url), location, .{ .always_dupe = true });
+ const resolved = try URL.resolve(arena, base, location, .{});
// RFC 7231 §7.1.2: if the Location value has no fragment, the redirect
// inherits the fragment from the URI used to generate the request.
@@ -1985,7 +1993,6 @@ pub const Transfer = struct {
try transfer.updateURL(url);
// 301, 302, 303 → change to GET, drop body.
// 307, 308 → keep method and body.
- const status = try conn.getResponseCode();
if (status == 301 or status == 302 or status == 303) {
req.method = .GET;
req.body = null;
@@ -2478,6 +2485,196 @@ test "HttpClient: fulfillRequest survives a done_callback that tears down the ow
try testing.expectEqual(null, owner.transfers.first);
}
+test "HttpClient: fulfillRequest follows a 3xx redirect" {
+ // Regression for #2828: a CDP Fetch.fulfillRequest with a 3xx status + a
+ // Location header must be followed like a real network redirect (re-issued
+ // down the chain to the resolved target), not delivered as a final response.
+ var pool = ArenaPool.init(testing.allocator, .{});
+ defer pool.deinit();
+
+ // Only network.config is read (httpMaxRedirects, which ignores its config),
+ // so a pointer to an otherwise-undefined Network is safe here.
+ var net: Network = undefined;
+
+ var client: Client = undefined;
+ client.allocator = testing.allocator;
+ client.arena_pool = &pool;
+ client.network = &net;
+ client.transfers = .empty;
+ client.queue = .{};
+ client.next_tick_queue = .{};
+ client.next_tick_count = 0;
+ client.performing = false;
+ client.interception_layer = .{};
+ defer client.transfers.deinit(testing.allocator);
+
+ // Capturing stub for interception_layer.next: records the re-issued request
+ // and returns without committing (transfer stays .created; we clean up).
+ const Captor = struct {
+ captured: bool = false,
+ url: []const u8 = "",
+ method: Method = undefined,
+
+ fn request(ptr: *anyopaque, transfer: *Transfer) anyerror!void {
+ const self: *@This() = @ptrCast(@alignCast(ptr));
+ self.captured = true;
+ self.url = transfer.req.url;
+ self.method = transfer.req.method;
+ }
+ };
+ var captor = Captor{};
+ client.interception_layer.next = .{
+ .ptr = &captor,
+ .vtable = &.{ .request = Captor.request },
+ };
+
+ // 302 with a relative Location: rewrite to GET, drop body, resolve target.
+ {
+ const arena = try pool.acquire(.small, "test");
+ const transfer = try arena.create(Transfer);
+ transfer.* = .{
+ .arena = arena,
+ .owner = null,
+ .req = .{
+ .frame_id = 0,
+ .loader_id = 0,
+ .method = .POST,
+ .url = "http://example.com/start",
+ .body = "payload",
+ .headers = .{ .headers = null },
+ .cookie_jar = null,
+ .cookie_origin = "",
+ .resource_type = .document,
+ .notification = undefined,
+ .ctx = undefined,
+ },
+ .client = &client,
+ .id = 1,
+ .start_time = 0,
+ };
+ try client.transfers.putNoClobber(testing.allocator, transfer.id, transfer);
+
+ transfer.park(.intercept_request);
+ client.interception_layer.intercepted += 1;
+
+ try client.interception_layer.fulfillRequest(transfer, 302, &.{
+ .{ .name = "Location", .value = "/end" },
+ }, null);
+
+ try testing.expect(captor.captured);
+ try testing.expectEqual("http://example.com/end", captor.url);
+ try testing.expectEqual(.GET, captor.method);
+ try testing.expectEqual(null, transfer.req.body);
+ // Unparked exactly once; transfer is still alive (re-issued, not deinited).
+ try testing.expectEqual(0, client.interception_layer.intercepted);
+ try testing.expectEqual(1, client.transfers.count());
+ transfer.deinit();
+ }
+
+ // 307 with an absolute Location: keep method and body.
+ captor = .{};
+ {
+ const arena = try pool.acquire(.small, "test");
+ const transfer = try arena.create(Transfer);
+ transfer.* = .{
+ .arena = arena,
+ .owner = null,
+ .req = .{
+ .frame_id = 0,
+ .loader_id = 0,
+ .method = .POST,
+ .url = "http://example.com/start",
+ .body = "payload",
+ .headers = .{ .headers = null },
+ .cookie_jar = null,
+ .cookie_origin = "",
+ .resource_type = .document,
+ .notification = undefined,
+ .ctx = undefined,
+ },
+ .client = &client,
+ .id = 2,
+ .start_time = 0,
+ };
+ try client.transfers.putNoClobber(testing.allocator, transfer.id, transfer);
+
+ transfer.park(.intercept_request);
+ client.interception_layer.intercepted += 1;
+
+ try client.interception_layer.fulfillRequest(transfer, 307, &.{
+ .{ .name = "location", .value = "http://example.com/other" },
+ }, null);
+
+ try testing.expect(captor.captured);
+ try testing.expectEqual("http://example.com/other", captor.url);
+ try testing.expectEqual(.POST, captor.method);
+ try testing.expectEqual("payload", transfer.req.body.?);
+ try testing.expectEqual(0, client.interception_layer.intercepted);
+ transfer.deinit();
+ }
+}
+
+test "HttpClient: fulfillRequest delivers a 3xx without a Location as the response" {
+ // A redirect status with no Location header is not a redirect: the body is
+ // delivered as the final response (matching the real-network path).
+ var pool = ArenaPool.init(testing.allocator, .{});
+ defer pool.deinit();
+
+ var client: Client = undefined;
+ client.allocator = testing.allocator;
+ client.arena_pool = &pool;
+ client.transfers = .empty;
+ client.queue = .{};
+ client.next_tick_queue = .{};
+ client.next_tick_count = 0;
+ client.performing = false;
+ client.interception_layer = .{};
+ defer client.transfers.deinit(testing.allocator);
+
+ const Ctx = struct {
+ done_called: bool = false,
+ fn doneCallback(ctx: *anyopaque) !void {
+ const self: *@This() = @ptrCast(@alignCast(ctx));
+ self.done_called = true;
+ }
+ };
+ var ctx = Ctx{};
+
+ const arena = try pool.acquire(.small, "test");
+ const transfer = try arena.create(Transfer);
+ transfer.* = .{
+ .arena = arena,
+ .owner = null,
+ .req = .{
+ .frame_id = 0,
+ .loader_id = 0,
+ .method = .GET,
+ .url = "http://example.com/",
+ .headers = .{ .headers = null },
+ .cookie_jar = null,
+ .cookie_origin = "",
+ .resource_type = .document,
+ .notification = undefined,
+ .ctx = &ctx,
+ .done_callback = Ctx.doneCallback,
+ },
+ .client = &client,
+ .id = 1,
+ .start_time = 0,
+ };
+ try client.transfers.putNoClobber(testing.allocator, transfer.id, transfer);
+
+ transfer.park(.intercept_request);
+ client.interception_layer.intercepted += 1;
+
+ try client.interception_layer.fulfillRequest(transfer, 302, &.{}, "body");
+
+ // Delivered (done_callback ran) and freed exactly once.
+ try testing.expect(ctx.done_called);
+ try testing.expectEqual(0, client.interception_layer.intercepted);
+ try testing.expectEqual(0, client.transfers.count());
+}
+
test "HttpClient: abortParked survives an error_callback that tears down the owner" {
// Same re-entrancy hazard as fulfillRequest, but on the abort path
// (failRequest / continueWithAuth-cancel / session teardown). abortParked
diff --git a/src/browser/ImportMap.zig b/src/browser/ImportMap.zig
index 3066b95d4..a2c0ee7e7 100644
--- a/src/browser/ImportMap.zig
+++ b/src/browser/ImportMap.zig
@@ -234,7 +234,7 @@ fn parseScopeKey(arena: Allocator, base: [:0]const u8, key: []const u8) ![]const
if (key.len == 0) {
return base;
}
- return URL.resolve(arena, base, key, .{ .always_dupe = true, .encoding = "UTF-8" });
+ return URL.resolve(arena, base, key, .{ .encoding = "UTF-8" });
}
/// Returns the parsed URL if `specifier` looks like a URL. Else returns null;
@@ -248,7 +248,7 @@ fn parseIfLikeURL(arena: Allocator, base: [:0]const u8, specifier: []const u8) ?
std.mem.startsWith(u8, specifier, "../") or
hasScheme(specifier))
{
- return URL.resolve(arena, base, specifier, .{ .always_dupe = true, .encoding = "UTF-8" }) catch return null;
+ return URL.resolve(arena, base, specifier, .{ .encoding = "UTF-8" }) catch return null;
}
return null;
}
@@ -331,7 +331,7 @@ fn resolveImportsMatch(
const base_addr = entry.resolved orelse return error.SpecifierResolutionFailed;
const after = normalized[entry.specifier.len..];
- const url = URL.resolve(arena, base_addr, after, .{ .always_dupe = true, .encoding = "UTF-8" }) catch {
+ const url = URL.resolve(arena, base_addr, after, .{ .encoding = "UTF-8" }) catch {
return error.SpecifierResolutionFailed;
};
diff --git a/src/browser/Runner.zig b/src/browser/Runner.zig
index 9c260a469..d07c1b31f 100644
--- a/src/browser/Runner.zig
+++ b/src/browser/Runner.zig
@@ -18,10 +18,8 @@
const std = @import("std");
const lp = @import("lightpanda");
-const builtin = @import("builtin");
const js = @import("js/js.zig");
-const Frame = @import("Frame.zig");
const Browser = @import("Browser.zig");
const Session = @import("Session.zig");
const HttpClient = @import("HttpClient.zig");
@@ -30,7 +28,6 @@ const Node = @import("webapi/Node.zig");
const Selector = @import("webapi/selector/Selector.zig");
const log = lp.log;
-const IS_DEBUG = builtin.mode == .Debug;
const Runner = @This();
diff --git a/src/browser/Session.zig b/src/browser/Session.zig
index 5bded71cc..4d113012f 100644
--- a/src/browser/Session.zig
+++ b/src/browser/Session.zig
@@ -22,7 +22,6 @@ const builtin = @import("builtin");
const App = @import("../App.zig");
-const js = @import("js/js.zig");
const storage = @import("webapi/storage/storage.zig");
const Navigation = @import("webapi/navigation/Navigation.zig");
const History = @import("webapi/History.zig");
diff --git a/src/browser/URL.zig b/src/browser/URL.zig
index f1e7e25c5..fc78e5a3e 100644
--- a/src/browser/URL.zig
+++ b/src/browser/URL.zig
@@ -17,316 +17,52 @@
// along with this program. If not, see
Visit Example.
, \\ - \\Visit [Example](https://example.com). + \\Visit [Example](https://example.com/). \\ ); } diff --git a/src/browser/tests/element/html/anchor.html b/src/browser/tests/element/html/anchor.html index 74bf486c7..ccabf3ee9 100644 --- a/src/browser/tests/element/html/anchor.html +++ b/src/browser/tests/element/html/anchor.html @@ -253,3 +253,37 @@ testing.expectEqual(testing.BASE_URL + 'element/html/over%209000!', a.href); } + + diff --git a/src/browser/tests/frames/cross_origin_message_source.html b/src/browser/tests/frames/cross_origin_message_source.html new file mode 100644 index 000000000..87f553bf6 --- /dev/null +++ b/src/browser/tests/frames/cross_origin_message_source.html @@ -0,0 +1,49 @@ + + + + + + diff --git a/src/browser/tests/frames/support/post_to_parent.html b/src/browser/tests/frames/support/post_to_parent.html new file mode 100644 index 000000000..8de6a0c43 --- /dev/null +++ b/src/browser/tests/frames/support/post_to_parent.html @@ -0,0 +1,4 @@ + + diff --git a/src/browser/tests/net/request.html b/src/browser/tests/net/request.html index 2bfa1829e..440a1aac4 100644 --- a/src/browser/tests/net/request.html +++ b/src/browser/tests/net/request.html @@ -125,7 +125,7 @@ headers: { "Sender": "me", "Target": "you" } } ); - testing.expectEqual("https://google.com", request2.url); + testing.expectEqual("https://google.com/", request2.url); testing.expectEqual("POST", request2.method); testing.expectEqual("omit", request2.credentials); testing.expectEqual("reload", request2.cache); diff --git a/src/browser/tests/net/xhr.html b/src/browser/tests/net/xhr.html index fff72d387..3f45b3715 100644 --- a/src/browser/tests/net/xhr.html +++ b/src/browser/tests/net/xhr.html @@ -481,3 +481,30 @@ testing.expectEqual(false, registered); } + + diff --git a/src/browser/tests/url.html b/src/browser/tests/url.html index cabc2b13e..5b1b0b12b 100644 --- a/src/browser/tests/url.html +++ b/src/browser/tests/url.html @@ -333,31 +333,88 @@ @@ -572,12 +629,13 @@ testing.expectEqual('https://[::1]:9000/path', url.href); } - // A trailing colon clears the port. + // A trailing colon supplies an empty port, which the parser ignores: the + // existing port is left untouched (matches Chrome and Firefox). { const url = new URL('https://example.com:8080/path'); url.host = 'newhost.com:'; - testing.expectEqual('newhost.com', url.host); - testing.expectEqual('https://newhost.com/path', url.href); + testing.expectEqual('newhost.com:8080', url.host); + testing.expectEqual('https://newhost.com:8080/path', url.href); } // An invalid host is silently ignored, leaving it unchanged (the setter must @@ -1263,3 +1321,43 @@ testing.expectEqual('?a=%7E&b=%7E&c=foo', url.search); } + + + + + + + + diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 4b59c8e60..9f02ce08e 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -191,6 +191,31 @@ pub fn setDomain(self: *Document, value: []const u8) !void { try doc_frame.js.setOrigin(key); } +pub fn getCookie(_: *Document, frame: *Frame) ![]const u8 { + var buf: std.ArrayList(u8) = .empty; + try frame._session.cookie_jar.forRequest(frame.url, buf.writer(frame.call_arena), .{ + .is_http = false, + .is_navigation = true, + }); + return buf.items; +} + +pub fn setCookie(_: *Document, cookie_str: []const u8, frame: *Frame) ![]const u8 { + // we use the cookie jar's allocator to parse the cookie because it + // outlives the frame's arena. + const Cookie = @import("storage/Cookie.zig"); + const c = Cookie.parse(frame._session.cookie_jar.allocator, frame.url, cookie_str) catch { + // Invalid cookies should be silently ignored, not throw errors + return ""; + }; + if (c.http_only) { + c.deinit(); + return ""; // HttpOnly cookies cannot be set from JS + } + try frame._session.cookie_jar.add(c, std.time.timestamp(), false); + return cookie_str; +} + // Returns true if the requested domain is valid for the given host fn isRelaxableTo(host: []const u8, requested: []const u8) bool { if (requested.len == 0) { @@ -1254,6 +1279,7 @@ pub const JsApi = struct { pub const fonts = bridge.accessor(Document.getFonts, null, .{}); pub const contentType = bridge.accessor(Document.getContentType, null, .{}); pub const domain = bridge.accessor(Document.getDomain, Document.setDomain, .{ .dom_exception = true }); + pub const cookie = bridge.accessor(Document.getCookie, Document.setCookie, .{}); pub const createElement = bridge.function(Document.createElement, .{ .dom_exception = true }); pub const createElementNS = bridge.function(Document.createElementNS, .{ .dom_exception = true }); pub const createDocumentFragment = bridge.function(Document.createDocumentFragment, .{}); diff --git a/src/browser/webapi/HTMLDocument.zig b/src/browser/webapi/HTMLDocument.zig index ba4d39438..4d2ccb9c2 100644 --- a/src/browser/webapi/HTMLDocument.zig +++ b/src/browser/webapi/HTMLDocument.zig @@ -224,31 +224,6 @@ pub fn getAll(self: *HTMLDocument, frame: *Frame) !*collections.HTMLAllCollectio return frame._factory.create(collections.HTMLAllCollection.init(self.asNode(), frame)); } -pub fn getCookie(_: *HTMLDocument, frame: *Frame) ![]const u8 { - var buf: std.ArrayList(u8) = .empty; - try frame._session.cookie_jar.forRequest(frame.url, buf.writer(frame.call_arena), .{ - .is_http = false, - .is_navigation = true, - }); - return buf.items; -} - -pub fn setCookie(_: *HTMLDocument, cookie_str: []const u8, frame: *Frame) ![]const u8 { - // we use the cookie jar's allocator to parse the cookie because it - // outlives the frame's arena. - const Cookie = @import("storage/Cookie.zig"); - const c = Cookie.parse(frame._session.cookie_jar.allocator, frame.url, cookie_str) catch { - // Invalid cookies should be silently ignored, not throw errors - return ""; - }; - if (c.http_only) { - c.deinit(); - return ""; // HttpOnly cookies cannot be set from JS - } - try frame._session.cookie_jar.add(c, std.time.timestamp(), false); - return cookie_str; -} - pub fn getDocType(self: *HTMLDocument, frame: *Frame) !*DocumentType { if (self._document_type) |dt| { return dt; @@ -302,6 +277,5 @@ pub const JsApi = struct { pub const plugins = bridge.accessor(HTMLDocument.getEmbeds, null, .{}); pub const currentScript = bridge.accessor(HTMLDocument.getCurrentScript, null, .{}); pub const all = bridge.accessor(HTMLDocument.getAll, null, .{}); - pub const cookie = bridge.accessor(HTMLDocument.getCookie, HTMLDocument.setCookie, .{}); pub const doctype = bridge.accessor(HTMLDocument.getDocType, null, .{}); }; diff --git a/src/browser/webapi/History.zig b/src/browser/webapi/History.zig index b09df743e..5f255385c 100644 --- a/src/browser/webapi/History.zig +++ b/src/browser/webapi/History.zig @@ -52,7 +52,7 @@ pub fn setScrollRestoration(self: *History, str: []const u8) void { pub fn pushState(_: *History, state: js.Value, _: ?[]const u8, _url: ?[]const u8, frame: *Frame) !void { const arena = frame._session.arena; const url = if (_url) |u| - try @import("../URL.zig").resolve(arena, frame.url, u, .{ .always_dupe = true }) + try @import("../URL.zig").resolve(arena, frame.url, u, .{}) else try arena.dupeZ(u8, frame.url); @@ -67,7 +67,7 @@ pub fn pushState(_: *History, state: js.Value, _: ?[]const u8, _url: ?[]const u8 pub fn replaceState(_: *History, state: js.Value, _: ?[]const u8, _url: ?[]const u8, frame: *Frame) !void { const arena = frame._session.arena; const url = if (_url) |u| - try @import("../URL.zig").resolve(arena, frame.url, u, .{ .always_dupe = true }) + try @import("../URL.zig").resolve(arena, frame.url, u, .{}) else try arena.dupeZ(u8, frame.url); diff --git a/src/browser/webapi/KeyValueList.zig b/src/browser/webapi/KeyValueList.zig index b8e32234f..363e7330e 100644 --- a/src/browser/webapi/KeyValueList.zig +++ b/src/browser/webapi/KeyValueList.zig @@ -195,14 +195,12 @@ pub fn urlEncode(self: *const KeyValueList, comptime mode: URLEncodeMode, alloca fn urlEncodeEntry(entry: Entry, comptime mode: URLEncodeMode, allocator_: ?Allocator, charset: []const u8, writer: *std.Io.Writer) !void { try urlEncodeValue(entry.name.str(), mode, allocator_, charset, writer); + try writer.writeByte('='); - // for a form, for an empty value, we'll do "spice=" - // but for a query, we do "spice" - if ((comptime mode == .query) and entry.value.len == 0) { + if (entry.value.len == 0) { return; } - try writer.writeByte('='); try urlEncodeValue(entry.value.str(), mode, allocator_, charset, writer); } @@ -260,7 +258,7 @@ fn urlEncodeValue(value: []const u8, comptime mode: URLEncodeMode, allocator_: ? /// Percent-encode a UTF-8 value - bytes >= 0x80 are percent-encoded directly. fn urlEncodeValueUtf8(value: []const u8, comptime mode: URLEncodeMode, writer: *std.Io.Writer) !void { - if (!urlEncodeShouldEscape(value, mode)) { + if (!urlEncodeShouldEscape(value)) { return writer.writeAll(value); } @@ -270,7 +268,7 @@ fn urlEncodeValueUtf8(value: []const u8, comptime mode: URLEncodeMode, writer: * if (comptime mode == .form) { if (try writeFormLineEnd(value, &i, b, writer)) continue; } - if (urlEncodeUnreserved(b, mode)) { + if (urlEncodeUnreserved(b)) { try writer.writeByte(b); } else if (b == ' ') { try writer.writeByte('+'); @@ -288,7 +286,7 @@ fn urlEncodeValueLegacy(value: []const u8, comptime mode: URLEncodeMode, writer: if (comptime mode == .form) { if (try writeFormLineEnd(value, &i, b, writer)) continue; } - if (urlEncodeUnreserved(b, mode)) { + if (urlEncodeUnreserved(b)) { try writer.writeByte(b); } else if (b == ' ') { try writer.writeByte('+'); @@ -320,19 +318,18 @@ fn writeFormLineEnd(value: []const u8, i: *usize, b: u8, writer: *std.Io.Writer) return false; } -fn urlEncodeShouldEscape(value: []const u8, comptime mode: URLEncodeMode) bool { +fn urlEncodeShouldEscape(value: []const u8) bool { for (value) |b| { - if (!urlEncodeUnreserved(b, mode)) { + if (!urlEncodeUnreserved(b)) { return true; } } return false; } -fn urlEncodeUnreserved(b: u8, comptime mode: URLEncodeMode) bool { +fn urlEncodeUnreserved(b: u8) bool { return switch (b) { 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '*' => true, - '~' => comptime mode == .form, else => false, }; } @@ -391,6 +388,19 @@ test "KeyValueList: urlEncode UTF-8" { try testing.expectString("cafe=caf%C3%A9", buf.written()); } +test "KeyValueList: urlEncode .form percent-encodes ~" { + // '~' is in the application/x-www-form-urlencoded percent-encode set, so it + // must become %7E in both .form and .query modes (not left literal). + const allocator = testing.arena_allocator; + var list = KeyValueList.init(); + try list.append(allocator, "q", "a~b"); + + var buf = std.Io.Writer.Allocating.init(allocator); + try list.urlEncode(.form, null, "UTF-8", &buf.writer); + + try testing.expectString("q=a%7Eb", buf.written()); +} + test "KeyValueList: urlEncode UTF-8 CJK" { // Test 3-byte UTF-8 characters (Chinese/Japanese) const allocator = testing.arena_allocator; diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index af1e4395c..e56209dd6 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -35,7 +35,6 @@ pub const DocumentFragment = @import("DocumentFragment.zig"); pub const DocumentType = @import("DocumentType.zig"); pub const ShadowRoot = @import("ShadowRoot.zig"); -const log = lp.log; const String = lp.String; const Allocator = std.mem.Allocator; const LinkedList = std.DoublyLinkedList; @@ -586,6 +585,15 @@ pub fn resolveURL(self: *const Node, url: anytype, frame: *Frame, opts: ResolveU return URL.resolve(allocator, owner_frame.base(), url, .{ .encoding = owner_frame.charset }); } +// Same as `resolveURL` but can't return `TypeError`, this is needed for multiple +// getters throughout codebase. Returns provided `url` on `TypeError`. +pub fn resolveURLReflect(self: *const Node, url: []const u8, frame: *Frame, opts: ResolveURLOpts) ![]const u8 { + return self.resolveURL(url, frame, opts) catch |err| switch (err) { + error.TypeError => url, + else => err, + }; +} + pub fn isSameDocumentAs(self: *const Node, other: *const Node, frame: *const Frame) bool { // Get the root document for each node const self_doc = if (self._type == .document) self._type.document else self.ownerDocument(frame); diff --git a/src/browser/webapi/URL.zig b/src/browser/webapi/URL.zig index bde373ebf..dbffa97f6 100644 --- a/src/browser/webapi/URL.zig +++ b/src/browser/webapi/URL.zig @@ -53,6 +53,11 @@ pub fn init(url: []const u8, maybe_base: ?[]const u8, exec: *const Execution) !* return exec._factory.create(URL{ ._url = u }); } +/// Like the constructor, but returns null instead of throwing when parsing fails. +pub fn parse(url: []const u8, maybe_base: ?[]const u8, exec: *const Execution) ?*URL { + return URL.init(url, maybe_base, exec) catch null; +} + pub fn deinit(self: *URL, _: *Page) void { // Not tracked by arena. U.url_free(self._url); @@ -209,7 +214,7 @@ pub fn setSearch(self: *URL, value: []const u8, exec: *const Execution) !void { const search_params = self._search_params orelse return; var out: [*]const u8 = undefined; var len: usize = 0; - const search_value = if (U.url_get_query(self._url, &out, &len) == 0) out[0..len] else ""; + const search_value = if (U.url_get_query(self._url, &out, &len) == 0) (out - 1)[0 .. len + 1] else ""; try search_params.updateFromString(search_value, exec); } @@ -242,10 +247,11 @@ pub fn getSearchParams(self: *URL, exec: *const Execution) !*URLSearchParams { return sp; } - // Get current search string (omitting '?'). + // Get current search string; rust-url always skips '?' so we have to + // go a byte back to include it. var out: [*]const u8 = undefined; var len: usize = 0; - const search_value = if (U.url_get_query(self._url, &out, &len) == 0) out[0..len] else ""; + const search_value = if (U.url_get_query(self._url, &out, &len) == 0) (out - 1)[0 .. len + 1] else ""; const params = try URLSearchParams.init(.{ .query_string = search_value }, exec); self._search_params = params; @@ -273,7 +279,7 @@ pub fn setHref(self: *URL, value: []const u8, exec: *const Execution) !void { const search_params = self._search_params orelse return; var out: [*]const u8 = undefined; var len: usize = 0; - const search_value = if (U.url_get_query(url, &out, &len) == 0) out[0..len] else ""; + const search_value = if (U.url_get_query(url, &out, &len) == 0) (out - 1)[0 .. len + 1] else ""; try search_params.updateFromString(search_value, exec); } @@ -298,14 +304,6 @@ pub fn toString(self: *const URL, exec: *const Execution) ![]const u8 { return out[0..len]; } -/// Like the constructor, but returns null instead of throwing when parsing fails. -pub fn parse(url: []const u8, maybe_base: ?[]const u8, exec: *const Execution) !?*URL { - return URL.init(url, maybe_base, exec) catch |err| switch (err) { - error.TypeError => null, - else => err, - }; -} - pub fn canParse(url: []const u8, maybe_base: ?[]const u8) bool { if (maybe_base) |base| { return U.url_can_parse_with_base(base.ptr, base.len, url.ptr, url.len); diff --git a/src/browser/webapi/crypto/EC.zig b/src/browser/webapi/crypto/EC.zig index 236b2efd9..afa38259b 100644 --- a/src/browser/webapi/crypto/EC.zig +++ b/src/browser/webapi/crypto/EC.zig @@ -20,7 +20,6 @@ //! agreement. Key bytes live as a libcrypto EVP_PKEY in `CryptoKey._vary.pkey`. const std = @import("std"); -const lp = @import("lightpanda"); const crypto = @import("../../../sys/libcrypto.zig"); const js = @import("../../js/js.zig"); diff --git a/src/browser/webapi/element/html/Anchor.zig b/src/browser/webapi/element/html/Anchor.zig index 65acd0091..0a55c457f 100644 --- a/src/browser/webapi/element/html/Anchor.zig +++ b/src/browser/webapi/element/html/Anchor.zig @@ -43,7 +43,11 @@ pub fn getHref(self: *Anchor, frame: *Frame) ![]const u8 { if (href.len == 0) { return ""; } - return self.asNode().resolveURL(href, frame, .{}); + return self.asNode().resolveURL(href, frame, .{}) catch |err| switch (err) { + // Per spec the getter must not throw; it returns the content attribute. + error.TypeError => href, + else => return err, + }; } pub fn setHref(self: *Anchor, value: []const u8, frame: *Frame) !void { @@ -155,7 +159,7 @@ pub fn setPathname(self: *Anchor, value: []const u8, frame: *Frame) !void { } pub fn getProtocol(self: *Anchor, frame: *Frame) ![]const u8 { - const href = try getResolvedHref(self, frame) orelse return ""; + const href = try getResolvedHref(self, frame) orelse return ":"; return URL.getProtocol(href); } @@ -224,7 +228,12 @@ fn getResolvedHref(self: *Anchor, frame: *Frame) !?[:0]const u8 { if (href.len == 0) { return null; } - return try self.asNode().resolveURL(href, frame, .{}); + return self.asNode().resolveURL(href, frame, .{}) catch |err| switch (err) { + // Unparseable against the base: treat as no resolved URL so the + // component getters return "" instead of throwing. + error.TypeError => null, + else => return err, + }; } pub const JsApi = struct { diff --git a/src/browser/webapi/element/html/Area.zig b/src/browser/webapi/element/html/Area.zig index d00e120e2..b58f5fe44 100644 --- a/src/browser/webapi/element/html/Area.zig +++ b/src/browser/webapi/element/html/Area.zig @@ -38,7 +38,11 @@ pub fn getHref(self: *Area, frame: *Frame) ![]const u8 { if (href.len == 0) { return ""; } - return self.asNode().resolveURL(href, frame, .{}); + return self.asNode().resolveURL(href, frame, .{}) catch |err| switch (err) { + // Per spec the getter must not throw; it returns the content attribute. + error.TypeError => href, + else => return err, + }; } pub fn setHref(self: *Area, value: []const u8, frame: *Frame) !void { @@ -164,7 +168,7 @@ pub fn setPathname(self: *Area, value: []const u8, frame: *Frame) !void { } pub fn getProtocol(self: *Area, frame: *Frame) ![]const u8 { - const href = try getResolvedHref(self, frame) orelse return ""; + const href = try getResolvedHref(self, frame) orelse return ":"; return URL.getProtocol(href); } @@ -179,7 +183,12 @@ fn getResolvedHref(self: *Area, frame: *Frame) !?[:0]const u8 { if (href.len == 0) { return null; } - return try self.asNode().resolveURL(href, frame, .{}); + return self.asNode().resolveURL(href, frame, .{}) catch |err| switch (err) { + // Unparseable against the base: treat as no resolved URL so the + // component getters return "" instead of throwing. + error.TypeError => null, + else => return err, + }; } pub const JsApi = struct { diff --git a/src/browser/webapi/element/html/Button.zig b/src/browser/webapi/element/html/Button.zig index d6fcab0c9..bdf4f15fc 100644 --- a/src/browser/webapi/element/html/Button.zig +++ b/src/browser/webapi/element/html/Button.zig @@ -142,7 +142,7 @@ pub fn getFormAction(self: *Button, frame: *Frame) ![]const u8 { if (action.len == 0) { return owner_url; } - return element.asNode().resolveURL(action, frame, .{}); + return element.asNode().resolveURLReflect(action, frame, .{}); } pub fn setFormAction(self: *Button, value: []const u8, frame: *Frame) !void { diff --git a/src/browser/webapi/element/html/Embed.zig b/src/browser/webapi/element/html/Embed.zig index 6c00c7aaa..250524e0f 100644 --- a/src/browser/webapi/element/html/Embed.zig +++ b/src/browser/webapi/element/html/Embed.zig @@ -41,7 +41,7 @@ pub fn getSrc(self: *const Embed, frame: *Frame) ![]const u8 { if (src.len == 0) { return ""; } - return element.asConstNode().resolveURL(src, frame, .{}); + return element.asConstNode().resolveURLReflect(src, frame, .{}); } pub fn setSrc(self: *Embed, value: []const u8, frame: *Frame) !void { diff --git a/src/browser/webapi/element/html/Form.zig b/src/browser/webapi/element/html/Form.zig index c9999b215..da3d49d38 100644 --- a/src/browser/webapi/element/html/Form.zig +++ b/src/browser/webapi/element/html/Form.zig @@ -120,7 +120,7 @@ pub fn getAction(self: *Form, frame: *Frame) ![]const u8 { if (action.len == 0) { return owner_url; } - return element.asNode().resolveURL(action, frame, .{}); + return element.asNode().resolveURLReflect(action, frame, .{}); } pub fn setAction(self: *Form, value: []const u8, frame: *Frame) !void { diff --git a/src/browser/webapi/element/html/IFrame.zig b/src/browser/webapi/element/html/IFrame.zig index 266fcc1a4..69802598f 100644 --- a/src/browser/webapi/element/html/IFrame.zig +++ b/src/browser/webapi/element/html/IFrame.zig @@ -47,9 +47,9 @@ pub fn getContentDocument(self: *const IFrame) ?*Document { return window._document; } -pub fn getSrc(self: *IFrame, frame: *Frame) ![:0]const u8 { +pub fn getSrc(self: *IFrame, frame: *Frame) ![]const u8 { if (self._src.len == 0) return ""; - return self.asNode().resolveURL(self._src, frame, .{}); + return self.asNode().resolveURLReflect(self._src, frame, .{}); } pub fn setSrc(self: *IFrame, src: []const u8, frame: *Frame) !void { diff --git a/src/browser/webapi/element/html/Image.zig b/src/browser/webapi/element/html/Image.zig index e1b4cf56f..dd75052b0 100644 --- a/src/browser/webapi/element/html/Image.zig +++ b/src/browser/webapi/element/html/Image.zig @@ -39,7 +39,7 @@ pub fn getSrc(self: *const Image, frame: *Frame) ![]const u8 { if (src.len == 0) { return ""; } - return element.asConstNode().resolveURL(src, frame, .{}); + return element.asConstNode().resolveURLReflect(src, frame, .{}); } pub fn setSrc(self: *Image, value: []const u8, frame: *Frame) !void { diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index cca413c20..c665daa96 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -659,7 +659,7 @@ pub fn setSize(self: *Input, size: i32, frame: *Frame) !void { pub fn getSrc(self: *const Input, frame: *Frame) ![]const u8 { const src = self.asConstElement().getAttributeSafe(comptime .wrap("src")) orelse return ""; - return self.asConstElement().asConstNode().resolveURL(src, frame, .{}); + return self.asConstElement().asConstNode().resolveURLReflect(src, frame, .{}); } pub fn setSrc(self: *Input, src: []const u8, frame: *Frame) !void { @@ -928,7 +928,7 @@ pub fn getFormAction(self: *Input, frame: *Frame) ![]const u8 { if (action.len == 0) { return owner_url; } - return element.asNode().resolveURL(action, frame, .{}); + return element.asNode().resolveURLReflect(action, frame, .{}); } pub fn setFormAction(self: *Input, value: []const u8, frame: *Frame) !void { diff --git a/src/browser/webapi/element/html/Link.zig b/src/browser/webapi/element/html/Link.zig index 601d713e3..493b6a735 100644 --- a/src/browser/webapi/element/html/Link.zig +++ b/src/browser/webapi/element/html/Link.zig @@ -49,7 +49,7 @@ pub fn getHref(self: *Link, frame: *Frame) ![]const u8 { if (href.len == 0) { return ""; } - return element.asNode().resolveURL(href, frame, .{}); + return element.asNode().resolveURLReflect(href, frame, .{}); } pub fn setHref(self: *Link, value: []const u8, frame: *Frame) !void { diff --git a/src/browser/webapi/element/html/Media.zig b/src/browser/webapi/element/html/Media.zig index 623b776f6..808e23742 100644 --- a/src/browser/webapi/element/html/Media.zig +++ b/src/browser/webapi/element/html/Media.zig @@ -236,7 +236,7 @@ pub fn getSrc(self: *const Media, frame: *Frame) ![]const u8 { if (src.len == 0) { return ""; } - return element.asConstNode().resolveURL(src, frame, .{}); + return element.asConstNode().resolveURLReflect(src, frame, .{}); } pub fn setSrc(self: *Media, value: []const u8, frame: *Frame) !void { diff --git a/src/browser/webapi/element/html/Quote.zig b/src/browser/webapi/element/html/Quote.zig index 47befe495..2213099a6 100644 --- a/src/browser/webapi/element/html/Quote.zig +++ b/src/browser/webapi/element/html/Quote.zig @@ -24,7 +24,7 @@ pub fn asNode(self: *Quote) *Node { pub fn getCite(self: *Quote, frame: *Frame) ![]const u8 { const attr = self.asElement().getAttributeSafe(comptime .wrap("cite")) orelse return ""; if (attr.len == 0) return ""; - return self.asNode().resolveURL(attr, frame, .{}); + return self.asNode().resolveURLReflect(attr, frame, .{}); } pub fn setCite(self: *Quote, value: []const u8, frame: *Frame) !void { diff --git a/src/browser/webapi/element/html/Script.zig b/src/browser/webapi/element/html/Script.zig index 41e440947..064b706bd 100644 --- a/src/browser/webapi/element/html/Script.zig +++ b/src/browser/webapi/element/html/Script.zig @@ -46,7 +46,7 @@ pub fn asNode(self: *Script) *Node { pub fn getSrc(self: *Script, frame: *Frame) ![]const u8 { if (self._src.len == 0) return ""; - return self.asNode().resolveURL(self._src, frame, .{}); + return self.asNode().resolveURLReflect(self._src, frame, .{}); } pub fn setSrc(self: *Script, src: []const u8, frame: *Frame) !void { diff --git a/src/browser/webapi/element/html/Source.zig b/src/browser/webapi/element/html/Source.zig index 24c24042a..189aec5a0 100644 --- a/src/browser/webapi/element/html/Source.zig +++ b/src/browser/webapi/element/html/Source.zig @@ -25,7 +25,7 @@ pub fn getSrc(self: *const Source, frame: *Frame) ![]const u8 { if (src.len == 0) { return ""; } - return element.asConstNode().resolveURL(src, frame, .{}); + return element.asConstNode().resolveURLReflect(src, frame, .{}); } pub fn setSrc(self: *Source, value: []const u8, frame: *Frame) !void { diff --git a/src/browser/webapi/element/html/Video.zig b/src/browser/webapi/element/html/Video.zig index 07b3341a3..4ba703a2e 100644 --- a/src/browser/webapi/element/html/Video.zig +++ b/src/browser/webapi/element/html/Video.zig @@ -57,7 +57,7 @@ pub fn getPoster(self: *const Video, frame: *Frame) ![]const u8 { if (poster.len == 0) { return ""; } - return element.asConstNode().resolveURL(poster, frame, .{}); + return element.asConstNode().resolveURLReflect(poster, frame, .{}); } pub fn setPoster(self: *Video, value: []const u8, frame: *Frame) !void { diff --git a/src/browser/webapi/event/MessageEvent.zig b/src/browser/webapi/event/MessageEvent.zig index 2bd071472..47188f3b6 100644 --- a/src/browser/webapi/event/MessageEvent.zig +++ b/src/browser/webapi/event/MessageEvent.zig @@ -21,6 +21,7 @@ const lp = @import("lightpanda"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Frame = @import("../../Frame.zig"); const Event = @import("../Event.zig"); const MessagePort = @import("../MessagePort.zig"); @@ -116,8 +117,9 @@ pub fn getOrigin(self: *const MessageEvent) []const u8 { return self._origin; } -pub fn getSource(self: *const MessageEvent) ?*Window { - return self._source; +pub fn getSource(self: *const MessageEvent, frame: *Frame) ?Window.Access { + const source = self._source orelse return null; + return Window.Access.init(frame.window, source); } pub fn getPorts(self: *const MessageEvent) []const *MessagePort { diff --git a/src/browser/webapi/net/Request.zig b/src/browser/webapi/net/Request.zig index dccb1b15b..dbc1bce3e 100644 --- a/src/browser/webapi/net/Request.zig +++ b/src/browser/webapi/net/Request.zig @@ -81,7 +81,7 @@ const Cache = enum { pub fn init(input: Input, opts_: ?InitOpts, exec: *const Execution) !*Request { const arena = exec.arena; const url = switch (input) { - .url => |u| try URL.resolve(arena, exec.base(), u, .{ .always_dupe = true, .encoding = exec.charset.* }), + .url => |u| try URL.resolve(arena, exec.base(), u, .{ .encoding = exec.charset.* }), .request => |r| try arena.dupeZ(u8, r._url), }; diff --git a/src/browser/webapi/net/WebSocket.zig b/src/browser/webapi/net/WebSocket.zig index 9ce1bf1a9..a98efc591 100644 --- a/src/browser/webapi/net/WebSocket.zig +++ b/src/browser/webapi/net/WebSocket.zig @@ -115,7 +115,7 @@ pub fn init(url: []const u8, protocols: [][]const u8, exec: *const Execution) !* const arena = try exec.getArena(.medium, "WebSocket"); errdefer exec.releaseArena(arena); - const resolved_url = try URL.resolve(arena, exec.base(), url, .{ .always_dupe = true, .encoding = exec.charset.* }); + const resolved_url = try URL.resolve(arena, exec.base(), url, .{ .encoding = exec.charset.* }); const http_client = &exec.session.browser.http_client; const conn = http_client.network.newConnection() orelse { diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index 7135c627d..73460573d 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -52,6 +52,7 @@ _http_response: ?HttpClient.Response = null, // number of inflight requests, we can have multiple, e.g. xhr calling its own // send from the onload callback _active_requests: u8 = 0, +_send_flag: bool = false, _url: [:0]const u8 = "", _method: http.Method = .GET, @@ -185,6 +186,7 @@ pub fn open(self: *XMLHttpRequest, method_: []const u8, url: [:0]const u8) !void transfer.abort(error.Abort); self._http_response = null; } + self._send_flag = false; // Reset internal state. _override_mime intentionally survives open() // per https://xhr.spec.whatwg.org/#the-overridemimetype()-method. @@ -200,7 +202,7 @@ pub fn open(self: *XMLHttpRequest, method_: []const u8, url: [:0]const u8) !void const exec = self._exec; self._method = try parseMethod(method_); - self._url = try URL.resolve(self._arena, exec.base(), url, .{ .always_dupe = true, .encoding = exec.charset.* }); + self._url = try URL.resolve(self._arena, exec.base(), url, .{ .encoding = exec.charset.* }); try self.stateChanged(.opened, exec); } @@ -224,7 +226,7 @@ pub fn send(self: *XMLHttpRequest, body_: ?BodyInit, exec_: *const Execution) !v if (comptime IS_DEBUG) { log.debug(.http, "XMLHttpRequest.send", .{ .url = self._url }); } - if (self._ready_state != .opened) { + if (self._ready_state != .opened or self._send_flag) { return error.InvalidStateError; } @@ -259,6 +261,7 @@ pub fn send(self: *XMLHttpRequest, body_: ?BodyInit, exec_: *const Execution) !v self.acquireRef(); self._active_requests += 1; + self._send_flag = true; exec.makeRequest(.{ .ctx = self, @@ -282,6 +285,7 @@ pub fn send(self: *XMLHttpRequest, body_: ?BodyInit, exec_: *const Execution) !v .body_outlives_request = true, }) catch |err| { self.releaseSelfRef(); + self._send_flag = false; return err; }; } diff --git a/src/browser/webapi/storage/CookieStore.zig b/src/browser/webapi/storage/CookieStore.zig index c7c7117ae..9185e0707 100644 --- a/src/browser/webapi/storage/CookieStore.zig +++ b/src/browser/webapi/storage/CookieStore.zig @@ -319,7 +319,7 @@ fn resolveQueryUrl(exec: *const Execution, _override: ?[]const u8) ![:0]const u8 const current = exec.url.*; const override = _override orelse return current; - const resolved = try URL.resolve(exec.call_arena, exec.base(), override, .{ .always_dupe = true }); + const resolved = try URL.resolve(exec.call_arena, exec.base(), override, .{}); if (!exec.isSameOrigin(resolved)) return error.SecurityError; switch (exec.js.global) { diff --git a/src/cdp/Connection.zig b/src/cdp/Connection.zig index 69b984463..7163384ac 100644 --- a/src/cdp/Connection.zig +++ b/src/cdp/Connection.zig @@ -24,13 +24,11 @@ const CDP = @import("CDP.zig"); const App = @import("../App.zig"); const Inbox = @import("../Inbox.zig"); -const Config = @import("../Config.zig"); const WS = @import("../network/WS.zig"); const ArenaPool = @import("../ArenaPool.zig"); const log = lp.log; const posix = std.posix; -const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; pub const Connection = @This(); diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 155dc2d4d..355605585 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -308,7 +308,7 @@ fn navigate(cmd: *CDP.Command) !void { const session = bc.session; const frame = bc.mainFrame() orelse return error.FrameNotLoaded; - const encoded_url = try URL.ensureEncoded(frame.call_arena, params.url, "UTF-8"); + const encoded_url = try URL.resolveNavigation(frame.call_arena, params.url, .{}); // Fast path: a freshly-created target whose root frame hasn't navigated // yet has nothing to preserve across the HTTP round-trip. Skip the @@ -585,10 +585,10 @@ pub fn frameChildFrameCreated(bc: *CDP.BrowserContext, event: *const Notificatio const cdp = bc.cdp; const frame_id = &id.toFrameId(event.frame_id); - try cdp.sendEvent("Page.frameAttached", .{ .params = .{ + try cdp.sendEvent("Page.frameAttached", .{ .frameId = frame_id, .parentFrameId = &id.toFrameId(event.parent_id), - } }, .{ .session_id = session_id }); + }, .{ .session_id = session_id }); if (bc.page_life_cycle_events) { try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{ @@ -1055,6 +1055,25 @@ test "cdp.frame: getFrameTree" { } } +test "cdp.frame: frameAttached" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{ .session_id = "SID-X" }); + + bc.notification.dispatch(.frame_child_frame_created, &.{ + .frame_id = 2, + .parent_id = 1, + .loader_id = 7, + .timestamp = 0, + }); + + try ctx.expectSentEvent("Page.frameAttached", .{ + .frameId = "FID-0000000002", + .parentFrameId = "FID-0000000001", + }, .{ .session_id = "SID-X" }); +} + test "cdp.frame: captureScreenshot" { const LogFilter = @import("../../testing.zig").LogFilter; const filter: LogFilter = .init(&.{.not_implemented}); @@ -1377,6 +1396,9 @@ test "cdp.frame: navigate does not follow Location on a non-redirect 3xx" { } test "cdp.frame: navigate answers with errorText when the navigation fails" { + const filter: testing.LogFilter = .init(&.{.frame}); + defer filter.deinit(); + // A root navigation that fails before commit (here: connection refused — // nothing listens on port 1) must still answer the Page.navigate command. // Chrome resolves it with an errorText field ("present if and only if diff --git a/src/cdp/domains/target.zig b/src/cdp/domains/target.zig index d1543e055..c11ec2da0 100644 --- a/src/cdp/domains/target.zig +++ b/src/cdp/domains/target.zig @@ -222,7 +222,7 @@ fn createTarget(cmd: *CDP.Command) !void { } if (!std.mem.eql(u8, "about:blank", params.url)) { - const encoded_url = try URL.ensureEncoded(frame.call_arena, params.url, "UTF-8"); + const encoded_url = try URL.resolveNavigation(frame.call_arena, params.url, .{}); try frame.navigate( encoded_url, .{ .reason = .address_bar, .kind = .{ .push = null } }, diff --git a/src/html5ever/url.rs b/src/html5ever/url.rs index 55d14c2ad..2d5f60e07 100644 --- a/src/html5ever/url.rs +++ b/src/html5ever/url.rs @@ -19,6 +19,8 @@ // host string becomes its punycode form, or an error. use ::url::Url; +use encoding_rs::{EncoderResult, Encoding}; +use std::borrow::Cow; use std::os::raw::c_uchar; use std::slice; @@ -280,6 +282,11 @@ pub struct OwnedString { pub len: usize, } +const EMPTY_OWNED_STRING: OwnedString = OwnedString { + ptr: std::ptr::null_mut(), + len: 0, +}; + #[no_mangle] pub unsafe extern "C" fn free_owned_string(owned: OwnedString) { if owned.ptr.is_null() || owned.len == 0 { @@ -328,17 +335,40 @@ pub unsafe extern "C" fn url_get_port(url: *const Url) -> i32 { } } +fn clean_hostname_input(url: &Url, raw: &str) -> String { + let special = matches!( + url.scheme(), + "http" | "https" | "ws" | "wss" | "ftp" | "file" + ); + let mut out = String::with_capacity(raw.len()); + let mut in_brackets = false; + for c in raw.chars() { + match c { + '\t' | '\n' | '\r' => continue, + '[' => in_brackets = true, + ']' => in_brackets = false, + '/' | '?' | '#' => break, + '\\' if special => break, + ':' if !in_brackets => break, + _ => {} + } + out.push(c); + } + out +} + /// WHATWG `hostname` setter: sets the host without touching the port. #[no_mangle] pub unsafe extern "C" fn url_set_hostname(url: *mut Url, ptr: *const c_uchar, len: usize) -> i32 { let url = unsafe { &mut *url }; - let slice = match str_from(ptr, len) { + let raw = match str_from(ptr, len) { Some(s) => s, None => return -1, }; + let host = clean_hostname_input(url, raw); - match url.set_host(Some(slice)) { + match url.set_host(Some(host.as_str())) { Ok(()) => 0, Err(_) => -1, } @@ -399,6 +429,18 @@ pub unsafe extern "C" fn url_set_host(url: *mut Url, ptr: *const c_uchar, len: u None => return -1, }; + // The WHATWG host setter parses in "host state", which stops at the first + // '/', '?', '#' (and '\' for special schemes); the remainder is ignored + // rather than making the whole value invalid. + let special = matches!( + url.scheme(), + "http" | "https" | "ws" | "wss" | "ftp" | "file" + ); + let end = input + .find(|c| c == '/' || c == '?' || c == '#' || (special && c == '\\')) + .unwrap_or(input.len()); + let input = &input[..end]; + // Find the port separator ':', but only outside an IPv6 [...] literal. let colon = if input.starts_with('[') { input @@ -419,21 +461,28 @@ pub unsafe extern "C" fn url_set_host(url: *mut Url, ptr: *const c_uchar, len: u let (host, port_str) = (&input[..i], &input[i + 1..]); + // A trailing colon with no digits ("host:") supplies an empty port, which + // the WHATWG host setter ignores: set the host only, leaving any existing + // port untouched (matches Chrome and Firefox). + if port_str.is_empty() { + return if url.set_host(Some(host)).is_ok() { + 0 + } else { + -1 + }; + } + // Validate the port up-front so we never apply the host and then fail. - let new_port: Option