Merge branch 'main' into agent-async-goto-parallel-navigation

This commit is contained in:
Adrià Arrufat
2026-06-30 09:29:15 +02:00
61 changed files with 1098 additions and 839 deletions

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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 && \

110
build.zig
View File

@@ -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",
},
});

View File

@@ -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",

View File

@@ -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");

View File

@@ -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);

View File

@@ -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

View File

@@ -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;
};

View File

@@ -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();

View File

@@ -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");

View File

@@ -17,316 +17,52 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const idna = @import("../sys/idna.zig");
const U = @import("../sys/url.zig");
const Allocator = std.mem.Allocator;
pub const ResolveOpts = struct {
pub const ResolveOptions = struct {
/// null = don't encode, "UTF-8" = standard percent encoding,
/// other charset = encode query string using that charset with NCR fallback
/// other charset = encode query string using that charset with NCR fallback.
encoding: ?[]const u8 = null,
always_dupe: bool = false,
};
// path is anytype, so that it can be used with both []const u8 and [:0]const u8
pub fn resolve(allocator: Allocator, base: [:0]const u8, source_path: anytype, opts: ResolveOpts) ![:0]const u8 {
const PT = @TypeOf(source_path);
pub fn resolve(
allocator: Allocator,
base: [:0]const u8,
source_path: anytype,
options: ResolveOptions,
) ![:0]const u8 {
const path = source_path;
const needs_dupe = comptime !isNullTerminated(PT);
var path: [:0]const u8 = if (needs_dupe or opts.always_dupe) try allocator.dupeZ(u8, source_path) else source_path;
var err: i32 = 0;
const href = if (options.encoding) |encoding|
U.url_resolve_with_encoding(base.ptr, base.len, path.ptr, path.len, encoding.ptr, encoding.len, &err)
else
U.url_resolve_without_encoding(base.ptr, base.len, path.ptr, path.len, &err);
if (std.mem.indexOfAny(u8, path, "\t\r\n")) |first| {
path = blk: {
var buf: std.ArrayList(u8) = try .initCapacity(allocator, path.len);
buf.appendSliceAssumeCapacity(path[0..first]);
for (path[first + 1 ..]) |c| {
if (c != '\t' and c != '\r' and c != '\n') {
buf.appendAssumeCapacity(c);
}
}
buf.appendAssumeCapacity(0);
break :blk buf.items[0 .. buf.items.len - 1 :0];
};
if (err != 0) {
return error.TypeError;
}
defer href.deinit();
if (base.len == 0) {
return processResolved(allocator, path, opts);
}
// Minimum is "x:" and skip relative path (very common case)
if (path.len >= 2 and path[0] != '/') {
if (std.mem.indexOfScalar(u8, path[0..], ':')) |scheme_path_end| {
scheme_check: {
const scheme_path = path[0..scheme_path_end];
//from "ws" to "https"
if (scheme_path_end >= 2 and scheme_path_end <= 5) {
const has_double_slashes: bool = scheme_path_end + 3 <= path.len and path[scheme_path_end + 1] == '/' and path[scheme_path_end + 2] == '/';
const special_schemes = [_][]const u8{ "https", "http", "ws", "wss", "file", "ftp" };
for (special_schemes) |special_scheme| {
if (std.ascii.eqlIgnoreCase(scheme_path, special_scheme)) {
const base_scheme_end = std.mem.indexOf(u8, base, "://") orelse 0;
if (base_scheme_end > 0 and std.mem.eql(u8, base[0..base_scheme_end], scheme_path) and !has_double_slashes) {
//Skip ":" and exit as relative state
path = path[scheme_path_end + 1 ..];
break :scheme_check;
} else {
var rest_start: usize = scheme_path_end + 1;
//Skip any slashas after "scheme:"
while (rest_start < path.len and (path[rest_start] == '/' or path[rest_start] == '\\')) {
rest_start += 1;
}
// A special scheme (exclude "file") must contain at least any chars after "://"
if (rest_start == path.len and !std.ascii.eqlIgnoreCase(scheme_path, "file")) {
return error.TypeError;
}
//File scheme allow empty host
const separator: []const u8 = if (!has_double_slashes and std.ascii.eqlIgnoreCase(scheme_path, "file")) ":///" else "://";
path = try std.mem.joinZ(allocator, "", &.{ scheme_path, separator, path[rest_start..] });
return processResolved(allocator, path, opts);
}
}
}
}
if (scheme_path.len > 0) {
for (scheme_path[1..]) |c| {
if (!std.ascii.isAlphanumeric(c) and c != '+' and c != '-' and c != '.') {
//Exit as relative state
break :scheme_check;
}
}
}
//path is complete http url
return processResolved(allocator, path, opts);
}
}
}
if (path.len == 0) {
if (opts.always_dupe) {
const dupe = try allocator.dupeZ(u8, base);
return processResolved(allocator, dupe, opts);
}
return processResolved(allocator, base, opts);
}
if (path[0] == '?') {
const base_path_end = std.mem.indexOfAny(u8, base, "?#") orelse base.len;
const result = try std.mem.joinZ(allocator, "", &.{ base[0..base_path_end], path });
return processResolved(allocator, result, opts);
}
if (path[0] == '#') {
const base_fragment_start = std.mem.indexOfScalar(u8, base, '#') orelse base.len;
const result = try std.mem.joinZ(allocator, "", &.{ base[0..base_fragment_start], path });
return processResolved(allocator, result, opts);
}
if (std.mem.startsWith(u8, path, "//")) {
// network-path reference
const index = std.mem.indexOfScalar(u8, base, ':') orelse {
return processResolved(allocator, path, opts);
};
const protocol = base[0 .. index + 1];
const result = try std.mem.joinZ(allocator, "", &.{ protocol, path });
return processResolved(allocator, result, opts);
}
const scheme_end = std.mem.indexOf(u8, base, "://");
const authority_start = if (scheme_end) |end| end + 3 else 0;
const path_start = std.mem.indexOfAnyPos(u8, base, authority_start, "/?#") orelse base.len;
const path_end = std.mem.indexOfAnyPos(u8, base, path_start, "?#") orelse base.len;
var out: []u8 = undefined;
if (path[0] == '/') {
// Absolute path — keep base authority, replace path. Two trailing
// spaces give us safe lookahead for the dot-segment loop below.
out = try std.mem.join(allocator, "", &.{ base[0..path_start], path, " " });
} else {
var normalized_base: []const u8 = base[0..path_start];
if (path_start < path_end) {
if (std.mem.lastIndexOfScalar(u8, base[path_start + 1 .. path_end], '/')) |pos| {
normalized_base = base[0 .. path_start + 1 + pos];
}
}
// trailing space so that we always have space to append the null terminator
// and so that we can compare the next two characters without needing to length check
out = try std.mem.join(allocator, "", &.{ normalized_base, "/", path, " " });
}
const end = out.len - 2;
const path_marker = path_start + 1;
// Strip out ./ and ../. This is done in-place, because doing so can
// only ever make `out` smaller. After this, `out` cannot be freed by
// an allocator, which is ok, because we expect allocator to be an arena.
var in_i: usize = 0;
var out_i: usize = 0;
while (in_i < end) {
if (out[in_i] == '.' and (out_i == 0 or out[out_i - 1] == '/')) {
if (out[in_i + 1] == '/') { // always safe, because we added a whitespace
// /./
in_i += 2;
continue;
}
if (out[in_i + 1] == '.' and (out[in_i + 2] == '/' or in_i + 2 == end)) {
// /../ or trailing /.. — both step up one segment. The
// trailing slash stays implicit (out_i ends up right after
// the previous '/'), matching `new URL("..", base)`.
if (out_i > path_marker) {
// go back before the /
out_i -= 2;
while (out_i > 1 and out[out_i - 1] != '/') {
out_i -= 1;
}
} else {
// if out_i == path_marker, than we've reached the start of
// the path. We can't ../ any more. E.g.:
// http://www.example.com/../hello.
// You might think that's an error, but, at least with
// new URL('../hello', 'http://www.example.com/')
// it just ignores the extra ../
}
in_i += 3;
continue;
}
if (in_i == end - 1) {
// ignore trailing dot
break;
}
}
const c = out[in_i];
out[out_i] = c;
in_i += 1;
out_i += 1;
}
// we always have an extra space
out[out_i] = 0;
return processResolved(allocator, out[0..out_i :0], opts);
return allocator.dupeZ(u8, href.slice());
}
fn processResolved(allocator: Allocator, url: [:0]const u8, opts: ResolveOpts) ![:0]const u8 {
const encoding = opts.encoding orelse return ensureHostAscii(allocator, url);
return ensureEncoded(allocator, url, encoding);
/// Resolves a user-provided "address bar" URL the way curl does. Bare host like
/// `lightpanda.io` has no scheme, so it can't be parsed as an absolute URL.
pub fn resolveNavigation(allocator: Allocator, url: []const u8, options: ResolveOptions) ![:0]const u8 {
return resolve(allocator, "", url, options) catch |err| switch (err) {
error.TypeError => {
const with_scheme = try std.fmt.allocPrintSentinel(allocator, "http://{s}", .{url}, 0);
return resolve(allocator, "", with_scheme, options);
},
else => return err,
};
}
/// IDNA pass: converts a non-ASCII host (`räksmörgås.se`) to its punycode form
/// (`xn--rksmrgs-5wao1o.se`), validates any ASCII punycode (`xn--…`) labels,
/// and leaves everything else alone. Returns `error.Idna` for an invalid
/// domain (e.g. malformed punycode), which surfaces as a URL parse failure.
fn ensureHostAscii(allocator: Allocator, url: [:0]const u8) ![:0]const u8 {
const hostname = getHostname(url);
if (hostname.len == 0 or (!idna.needsAscii(hostname) and !hasAceLabel(hostname))) {
return url;
}
const ascii = try idna.toAscii(allocator, hostname);
// hostname is a slice of url, so its start offset is just pointer arithmetic.
const start = @intFromPtr(hostname.ptr) - @intFromPtr(url.ptr);
const end = start + hostname.len;
var buf = try std.ArrayList(u8).initCapacity(allocator, url.len - hostname.len + ascii.len + 1);
buf.appendSliceAssumeCapacity(url[0..start]);
buf.appendSliceAssumeCapacity(ascii);
buf.appendSliceAssumeCapacity(url[end..]);
buf.appendAssumeCapacity(0);
return buf.items[0 .. buf.items.len - 1 :0];
}
/// True if any dot-separated label of `host` begins with the IDNA ACE prefix
/// "xn--" (case-insensitive). Such labels are punycode: even though they're
/// pure ASCII, UTS#46 must decode and validate them, so they can't take the
/// `needsAscii` fast path.
fn hasAceLabel(host: []const u8) bool {
var pos: usize = 0;
while (std.mem.indexOfScalarPos(u8, host, pos, '-')) |i| {
pos = i + 1;
if (i < 2 or i + 1 >= host.len or host[i + 1] != '-') {
continue;
}
if (!std.ascii.eqlIgnoreCase(host[i - 2 .. i], "xn")) {
continue;
}
const label_start = i - 2;
if (label_start == 0 or host[label_start - 1] == '.') {
return true;
}
}
return false;
}
pub fn ensureEncoded(allocator: Allocator, url_in: [:0]const u8, encoding: []const u8) ![:0]const u8 {
// Resolve any IDN host first; everything below operates on the ASCII form.
const url = try ensureHostAscii(allocator, url_in);
const scheme_end = std.mem.indexOf(u8, url, "://");
const authority_start = if (scheme_end) |end| end + 3 else 0;
const path_start = std.mem.indexOfScalarPos(u8, url, authority_start, '/') orelse return url;
const query_start = std.mem.indexOfScalarPos(u8, url, path_start, '?');
const fragment_start = std.mem.indexOfScalarPos(u8, url, query_start orelse path_start, '#');
const path_end = query_start orelse fragment_start orelse url.len;
const query_end = if (query_start) |_| (fragment_start orelse url.len) else path_end;
const path_to_encode = url[path_start..path_end];
// Path is always UTF-8 percent encoded per URL spec
const encoded_path = try percentEncodeSegment(allocator, path_to_encode, .path);
// Query string uses document encoding
const encoded_query = if (query_start) |qs| blk: {
const query_to_encode = url[qs + 1 .. query_end];
break :blk try encodeQueryString(allocator, query_to_encode, encoding);
} else null;
const encoded_fragment = if (fragment_start) |fs| blk: {
const fragment_to_encode = url[fs + 1 ..];
break :blk try percentEncodeSegment(allocator, fragment_to_encode, .query);
} else null;
if (encoded_path.ptr == path_to_encode.ptr and
(encoded_query == null or encoded_query.?.ptr == url[query_start.? + 1 .. query_end].ptr) and
(encoded_fragment == null or encoded_fragment.?.ptr == url[fragment_start.? + 1 ..].ptr))
{
// nothing has changed
return url;
}
var buf = try std.ArrayList(u8).initCapacity(allocator, url.len + 20);
try buf.appendSlice(allocator, url[0..path_start]);
try buf.appendSlice(allocator, encoded_path);
if (encoded_query) |eq| {
try buf.append(allocator, '?');
try buf.appendSlice(allocator, eq);
}
if (encoded_fragment) |ef| {
try buf.append(allocator, '#');
try buf.appendSlice(allocator, ef);
}
try buf.append(allocator, 0);
return buf.items[0 .. buf.items.len - 1 :0];
}
/// Selects which RFC 3986 / WHATWG URL Standard percent-encode set to apply.
///
/// The `path`, `query`, `query_legacy`, `userinfo`, and `fragment` variants
/// match the corresponding URL spec sets — they assume the input is already
/// structured (e.g. `key=val&key=val` for `query`) and only encode characters
/// disallowed in that location.
///
/// `component` is stricter: it encodes everything outside the RFC 3986
/// unreserved set, including sub-delims (`& = + ! * ' ( ) , $ ;`). Use this
/// when embedding an arbitrary string as a single URI component such as a
/// query-parameter value, where reserved characters in the input would
/// otherwise change the URL's structure.
pub const EncodeSet = enum { path, query, query_legacy, userinfo, fragment, component };
const EncodeSet = enum { path, query, query_legacy, userinfo, fragment, component };
pub fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime encode_set: EncodeSet) ![]const u8 {
// Check if encoding is needed
@@ -374,52 +110,6 @@ pub fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime
return buf.items;
}
const h5e = @import("parser/html5ever.zig");
/// Encode a query string using the specified encoding.
/// For UTF-8, this is standard percent encoding.
/// For legacy encodings, unmappable characters are replaced with NCRs (&#codepoint;).
fn encodeQueryString(allocator: Allocator, query: []const u8, encoding: []const u8) ![]const u8 {
// For UTF-8, use standard percent encoding
if (std.mem.eql(u8, encoding, "UTF-8")) {
return percentEncodeSegment(allocator, query, .query);
}
// For legacy encodings, first encode to the target charset with NCR fallback
const enc_info = h5e.encoding_for_label(encoding.ptr, encoding.len);
if (!enc_info.isValid()) {
// Unknown encoding, fall back to UTF-8
return percentEncodeSegment(allocator, query, .query);
}
// Calculate max buffer size for encoded output
const max_encoded_len = h5e.encoding_max_encode_buffer_length(enc_info.handle.?, query.len);
if (max_encoded_len == 0) {
return percentEncodeSegment(allocator, query, .query);
}
const encode_buf = try allocator.alloc(u8, max_encoded_len);
defer allocator.free(encode_buf);
// Encode UTF-8 to legacy encoding with NCR fallback
const result = h5e.encoding_encode_with_ncr(
enc_info.handle.?,
query.ptr,
query.len,
encode_buf.ptr,
encode_buf.len,
);
if (!result.isSuccess()) {
// Encoding failed, fall back to UTF-8
return percentEncodeSegment(allocator, query, .query);
}
// Now percent-encode the result using query_legacy to preserve NCRs
const encoded_bytes = encode_buf[0..result.bytes_written];
return percentEncodeSegment(allocator, encoded_bytes, .query_legacy);
}
fn shouldPercentEncode(c: u8, comptime encode_set: EncodeSet) bool {
return switch (c) {
// Unreserved characters (RFC 3986)
@@ -441,10 +131,6 @@ fn shouldPercentEncode(c: u8, comptime encode_set: EncodeSet) bool {
};
}
fn isNullTerminated(comptime value: type) bool {
return @typeInfo(value).pointer.sentinel_ptr != null;
}
pub fn isCompleteHTTPUrl(url: []const u8) bool {
if (url.len < 3) { // Minimum is "x://"
return false;
@@ -1024,6 +710,7 @@ test "URL: resolve" {
base: [:0]const u8,
path: [:0]const u8,
expected: [:0]const u8,
expected_error: bool = false,
};
const cases = [_]Case{
@@ -1177,30 +864,37 @@ test "URL: resolve" {
.path = "./.././.././hello",
.expected = "https://example/hello",
},
// A base must itself be a valid absolute URL; schemeless bases are
// rejected, matching `new URL(path, base)`.
.{
.base = "some/page",
.path = "hello",
.expected = "some/hello",
.expected = "",
.expected_error = true,
},
.{
.base = "some/page/",
.path = "hello",
.expected = "some/page/hello",
.expected = "",
.expected_error = true,
},
.{
.base = "some/page/other",
.path = ".././hello",
.expected = "some/hello",
.expected = "",
.expected_error = true,
},
.{
.base = "https://www.example.com/hello/world",
.path = "//example/about",
.expected = "https://example/about",
},
// "http:" alone is not a valid base (special scheme without a host).
.{
.base = "http:",
.path = "//example.com/over/9000",
.expected = "http://example.com/over/9000",
.expected = "",
.expected_error = true,
},
.{
.base = "https://example.com/",
@@ -1245,8 +939,13 @@ test "URL: resolve" {
};
for (cases) |case| {
const result = try resolve(testing.arena_allocator, case.base, case.path, .{});
try testing.expectString(case.expected, result);
if (case.expected_error) {
const result = resolve(testing.arena_allocator, case.base, case.path, .{});
try testing.expectError(error.TypeError, result);
} else {
const result = try resolve(testing.arena_allocator, case.base, case.path, .{});
try testing.expectString(case.expected, result);
}
}
}
@@ -1286,188 +985,13 @@ test "URL: resolve strips tab and newline from input" {
test "URL: resolve validates ASCII punycode (xn--) labels" {
defer testing.reset();
// Valid punycode is left untouched (the needsAscii fast path would skip it,
// so this exercises the xn-- gate going through toAscii and back).
const ok = try resolve(testing.arena_allocator, "", "https://xn--rksmrgs-5wao1o.se/x", .{});
// Valid punycode is left untouched.
const ok = try resolve(testing.arena_allocator, "https://example.com/", "https://xn--rksmrgs-5wao1o.se/x", .{});
try testing.expectString("https://xn--rksmrgs-5wao1o.se/x", ok);
// Malformed punycode must be rejected rather than passed through verbatim.
// (URL.init remaps this error.Idna to TypeError for `new URL`.)
try testing.expectError(error.Idna, resolve(testing.arena_allocator, "", "https://xn--0.pt/x", .{}));
try testing.expectError(error.Idna, resolve(testing.arena_allocator, "", "https://xn--a.pt/x", .{}));
}
test "URL: hasAceLabel" {
// ACE prefix at a label start (case-insensitive).
try testing.expectEqual(true, hasAceLabel("xn--a"));
try testing.expectEqual(true, hasAceLabel("xn--rksmrgs-5wao1o.se"));
try testing.expectEqual(true, hasAceLabel("a.xn--b.com"));
try testing.expectEqual(true, hasAceLabel("XN--ab.com"));
try testing.expectEqual(true, hasAceLabel("foo.example.xn--p1ai"));
// Has '-', but no ACE label.
try testing.expectEqual(false, hasAceLabel("example.com"));
try testing.expectEqual(false, hasAceLabel("my-site.com"));
try testing.expectEqual(false, hasAceLabel("axn--b.com")); // xn-- not at a label start
try testing.expectEqual(false, hasAceLabel("x-n--a.com")); // not "xn" before the '-'
try testing.expectEqual(false, hasAceLabel("-.com"));
try testing.expectEqual(false, hasAceLabel(""));
}
test "URL: ensureEncoded" {
defer testing.reset();
const Case = struct {
url: [:0]const u8,
expected: [:0]const u8,
};
const cases = [_]Case{
.{
.url = "https://example.com/over 9000!",
.expected = "https://example.com/over%209000!",
},
.{
.url = "http://example.com/hello world.html",
.expected = "http://example.com/hello%20world.html",
},
.{
.url = "https://example.com/file[1].html",
.expected = "https://example.com/file%5B1%5D.html",
},
.{
.url = "https://example.com/file{name}.html",
.expected = "https://example.com/file%7Bname%7D.html",
},
.{
.url = "https://example.com/page?query=hello world",
.expected = "https://example.com/page?query=hello%20world",
},
.{
.url = "https://example.com/page?a=1&b=value with spaces",
.expected = "https://example.com/page?a=1&b=value%20with%20spaces",
},
.{
.url = "https://example.com/page#section one",
.expected = "https://example.com/page#section%20one",
},
.{
.url = "https://example.com/my path?query=my value#my anchor",
.expected = "https://example.com/my%20path?query=my%20value#my%20anchor",
},
.{
.url = "https://example.com/already%20encoded",
.expected = "https://example.com/already%20encoded",
},
.{
.url = "https://example.com/file%5B1%5D.html",
.expected = "https://example.com/file%5B1%5D.html",
},
.{
.url = "https://example.com/caf%C3%A9",
.expected = "https://example.com/caf%C3%A9",
},
.{
.url = "https://example.com/page?query=already%20encoded",
.expected = "https://example.com/page?query=already%20encoded",
},
.{
.url = "https://example.com/page?a=1&b=value%20here",
.expected = "https://example.com/page?a=1&b=value%20here",
},
.{
.url = "https://example.com/page#section%20one",
.expected = "https://example.com/page#section%20one",
},
.{
.url = "https://example.com/part%20encoded and not",
.expected = "https://example.com/part%20encoded%20and%20not",
},
.{
.url = "https://example.com/page?a=encoded%20value&b=not encoded",
.expected = "https://example.com/page?a=encoded%20value&b=not%20encoded",
},
.{
.url = "https://example.com/my%20path?query=not encoded#encoded%20anchor",
.expected = "https://example.com/my%20path?query=not%20encoded#encoded%20anchor",
},
.{
.url = "https://example.com/fully%20encoded?query=also%20encoded#and%20this",
.expected = "https://example.com/fully%20encoded?query=also%20encoded#and%20this",
},
.{
.url = "https://example.com/path-with_under~tilde",
.expected = "https://example.com/path-with_under~tilde",
},
.{
.url = "https://example.com/sub-delims!$&'()*+,;=",
.expected = "https://example.com/sub-delims!$&'()*+,;=",
},
.{
.url = "https://example.com",
.expected = "https://example.com",
},
.{
.url = "https://example.com?query=value",
.expected = "https://example.com?query=value",
},
.{
.url = "https://example.com/clean/path",
.expected = "https://example.com/clean/path",
},
.{
.url = "https://example.com/path?clean=query#clean-fragment",
.expected = "https://example.com/path?clean=query#clean-fragment",
},
.{
.url = "https://example.com/100% complete",
.expected = "https://example.com/100%25%20complete",
},
.{
.url = "https://example.com/path?value=100% done",
.expected = "https://example.com/path?value=100%25%20done",
},
.{
.url = "about:blank",
.expected = "about:blank",
},
};
for (cases) |case| {
const result = try ensureEncoded(testing.arena_allocator, case.url, "UTF-8");
try testing.expectString(case.expected, result);
}
}
test "URL: percentEncodeSegment component passes unreserved chars through" {
defer testing.reset();
const r = try percentEncodeSegment(testing.arena_allocator, "abcXYZ012-._~", .component);
try testing.expectString("abcXYZ012-._~", r);
}
test "URL: percentEncodeSegment component encodes spaces, sub-delims and reserved chars" {
defer testing.reset();
const r = try percentEncodeSegment(testing.arena_allocator, "hello world&q=1", .component);
try testing.expectString("hello%20world%26q%3D1", r);
const r2 = try percentEncodeSegment(testing.arena_allocator, "a+b!c*d", .component);
try testing.expectString("a%2Bb%21c%2Ad", r2);
}
test "URL: percentEncodeSegment component encodes UTF-8 bytes" {
defer testing.reset();
const r = try percentEncodeSegment(testing.arena_allocator, "café", .component);
try testing.expectString("caf%C3%A9", r);
}
test "URL: percentEncodeSegment component re-encodes literal '%' in raw input" {
defer testing.reset();
const r = try percentEncodeSegment(testing.arena_allocator, "100%", .component);
try testing.expectString("100%25", r);
// Even when followed by hex digits, treat as opaque user data.
const r2 = try percentEncodeSegment(testing.arena_allocator, "100%2A", .component);
try testing.expectString("100%252A", r2);
try testing.expectError(error.TypeError, resolve(testing.arena_allocator, "https://example.com/", "https://xn--0.pt/x", .{}));
try testing.expectError(error.TypeError, resolve(testing.arena_allocator, "https://example.com/", "https://xn--a.pt/x", .{}));
}
test "URL: resolve with encoding" {
@@ -1497,11 +1021,11 @@ test "URL: resolve with encoding" {
.path = "path with multiple spaces",
.expected = "https://example.com/path%20with%20%20multiple%20%20%20spaces",
},
// Special characters that need encoding
// Brackets are not in the WHATWG path percent-encode set
.{
.base = "https://example.com/",
.path = "file[1].html",
.expected = "https://example.com/file%5B1%5D.html",
.expected = "https://example.com/file[1].html",
},
.{
.base = "https://example.com/",
@@ -1518,20 +1042,24 @@ test "URL: resolve with encoding" {
.path = "file\"quote\".html",
.expected = "https://example.com/file%22quote%22.html",
},
// Pipe is not in the WHATWG path percent-encode set
.{
.base = "https://example.com/",
.path = "file|pipe.html",
.expected = "https://example.com/file%7Cpipe.html",
.expected = "https://example.com/file|pipe.html",
},
// Backslash is a path separator in special URLs
.{
.base = "https://example.com/",
.path = "file\\backslash.html",
.expected = "https://example.com/file%5Cbackslash.html",
.expected = "https://example.com/file/backslash.html",
},
// Note: the current URL spec percent-encodes '^' in paths, but
// rust-url does not (yet); harmless divergence locked in here.
.{
.base = "https://example.com/",
.path = "file^caret.html",
.expected = "https://example.com/file%5Ecaret.html",
.expected = "https://example.com/file^caret.html",
},
.{
.base = "https://example.com/",
@@ -1986,31 +1514,31 @@ test "URL: resolve path scheme" {
.{
.base = "https://www.example.com/example",
.path = "https://about",
.expected = "https://about",
.expected = "https://about/",
},
//different schemes and path as absolute (without slash)
.{
.base = "https://www.example.com/example",
.path = "http:about",
.expected = "http://about",
.expected = "http://about/",
},
//different schemes and path as absolute (with one slash)
.{
.base = "https://www.example.com/example",
.path = "http:/about",
.expected = "http://about",
.expected = "http://about/",
},
//different schemes and path as absolute (with two slashes)
.{
.base = "https://www.example.com/example",
.path = "http://about",
.expected = "http://about",
.expected = "http://about/",
},
//same schemes and path as absolute (with more slashes)
.{
.base = "https://site/",
.path = "https://path",
.expected = "https://path",
.expected = "https://path/",
},
//path scheme is not special and path as absolute (without additional slashes)
.{
@@ -2022,19 +1550,19 @@ test "URL: resolve path scheme" {
.{
.base = "https://www.example.com/example",
.path = "ws://about",
.expected = "ws://about",
.expected = "ws://about/",
},
//different schemes and path as absolute (path scheme=wss)
.{
.base = "https://www.example.com/example",
.path = "wss://about",
.expected = "wss://about",
.expected = "wss://about/",
},
//different schemes and path as absolute (path scheme=ftp)
.{
.base = "https://www.example.com/example",
.path = "ftp://about",
.expected = "ftp://about",
.expected = "ftp://about/",
},
//different schemes and path as absolute (path scheme=file)
.{
@@ -2139,3 +1667,28 @@ test "URL: resolve path scheme" {
}
}
}
test "URL: resolveNavigation defaults a schemeless host to http (curl-like)" {
defer testing.reset();
const Case = struct {
url: [:0]const u8,
expected: [:0]const u8,
};
const cases = [_]Case{
// Schemeless input (the regression): assume http://, like curl.
.{ .url = "lightpanda.io", .expected = "http://lightpanda.io/" },
.{ .url = "example.com/path?q=1", .expected = "http://example.com/path?q=1" },
// An explicit scheme is preserved, not double-prefixed.
.{ .url = "https://example.com/x", .expected = "https://example.com/x" },
.{ .url = "http://example.com/", .expected = "http://example.com/" },
// Non-http absolute URLs still parse as-is.
.{ .url = "about:blank", .expected = "about:blank" },
};
for (cases) |case| {
const result = try resolveNavigation(testing.arena_allocator, case.url, .{});
try testing.expectString(case.expected, result);
}
}

View File

@@ -173,7 +173,7 @@ fn _constructor(self: *Caller, func: anytype, info: FunctionCallbackInfo, compti
info.getReturnValue().set(this.handle);
}
pub fn getIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
pub fn getIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 {
const local = &self.local;
var hs: js.HandleScope = undefined;
@@ -183,12 +183,11 @@ pub fn getIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, handle
const info = PropertyCallbackInfo{ .handle = handle };
return _getIndex(T, local, func, idx, info, opts) catch |err| {
handleError(T, @TypeOf(func), local, err, info, opts);
// not intercepted
return 0;
return js.Intercepted.no;
};
}
fn _getIndex(comptime T: type, local: *const Local, func: anytype, idx: u32, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
fn _getIndex(comptime T: type, local: *const Local, func: anytype, idx: u32, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@@ -200,7 +199,7 @@ fn _getIndex(comptime T: type, local: *const Local, func: anytype, idx: u32, inf
return handleIndexedReturn(T, F, true, local, ret, info, opts);
}
pub fn getNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
pub fn getNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 {
const local = &self.local;
var hs: js.HandleScope = undefined;
@@ -210,12 +209,11 @@ pub fn getNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *cons
const info = PropertyCallbackInfo{ .handle = handle };
return _getNamedIndex(T, local, func, name, info, opts) catch |err| {
handleError(T, @TypeOf(func), local, err, info, opts);
// not intercepted
return 0;
return js.Intercepted.no;
};
}
fn _getNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *const v8.Name, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
fn _getNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *const v8.Name, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@@ -227,7 +225,7 @@ fn _getNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *c
return handleIndexedReturn(T, F, true, local, ret, info, opts);
}
pub fn setNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, js_value: *const v8.Value, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
pub fn setNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, js_value: *const v8.Value, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 {
const local = &self.local;
var hs: js.HandleScope = undefined;
@@ -237,12 +235,11 @@ pub fn setNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *cons
const info = PropertyCallbackInfo{ .handle = handle };
return _setNamedIndex(T, local, func, name, .{ .local = &self.local, .handle = js_value }, info, opts) catch |err| {
handleError(T, @TypeOf(func), local, err, info, opts);
// not intercepted
return 0;
return js.Intercepted.no;
};
}
fn _setNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *const v8.Name, js_value: js.Value, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
fn _setNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *const v8.Name, js_value: js.Value, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@@ -255,7 +252,7 @@ fn _setNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *c
return handleIndexedReturn(T, F, false, local, ret, info, opts);
}
pub fn deleteNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
pub fn deleteNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 {
const local = &self.local;
var hs: js.HandleScope = undefined;
@@ -265,11 +262,11 @@ pub fn deleteNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *c
const info = PropertyCallbackInfo{ .handle = handle };
return _deleteNamedIndex(T, local, func, name, info, opts) catch |err| {
handleError(T, @TypeOf(func), local, err, info, opts);
return 0;
return js.Intercepted.no;
};
}
fn _deleteNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *const v8.Name, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
fn _deleteNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *const v8.Name, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@@ -281,7 +278,7 @@ fn _deleteNamedIndex(comptime T: type, local: *const Local, func: anytype, name:
return handleIndexedReturn(T, F, false, local, ret, info, opts);
}
pub fn getEnumerator(self: *Caller, comptime T: type, func: anytype, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
pub fn getEnumerator(self: *Caller, comptime T: type, func: anytype, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 {
const local = &self.local;
var hs: js.HandleScope = undefined;
@@ -291,12 +288,11 @@ pub fn getEnumerator(self: *Caller, comptime T: type, func: anytype, handle: *co
const info = PropertyCallbackInfo{ .handle = handle };
return _getEnumerator(T, local, func, info, opts) catch |err| {
handleError(T, @TypeOf(func), local, err, info, opts);
// not intercepted
return 0;
return js.Intercepted.no;
};
}
fn _getEnumerator(comptime T: type, local: *const Local, func: anytype, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
fn _getEnumerator(comptime T: type, local: *const Local, func: anytype, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@@ -307,7 +303,7 @@ fn _getEnumerator(comptime T: type, local: *const Local, func: anytype, info: Pr
return handleIndexedReturn(T, F, true, local, ret, info, opts);
}
fn handleIndexedReturn(comptime T: type, comptime F: type, comptime with_value: bool, local: *const Local, ret: anytype, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
fn handleIndexedReturn(comptime T: type, comptime F: type, comptime with_value: bool, local: *const Local, ret: anytype, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 {
// need to unwrap this error immediately for when opts.null_as_undefined == true
// and we need to compare it to null;
const non_error_ret = switch (@typeInfo(@TypeOf(ret))) {
@@ -318,13 +314,11 @@ fn handleIndexedReturn(comptime T: type, comptime F: type, comptime with_value:
// if error.NotHandled is part of the error set.
if (isInErrorSet(error.NotHandled, eu.error_set)) {
if (err == error.NotHandled) {
// not intercepted
return 0;
return js.Intercepted.no;
}
}
handleError(T, F, local, err, info, opts);
// not intercepted
return 0;
return js.Intercepted.no;
};
},
else => ret,
@@ -333,8 +327,7 @@ fn handleIndexedReturn(comptime T: type, comptime F: type, comptime with_value:
if (comptime with_value) {
info.getReturnValue().set(try local.zigValueToJs(non_error_ret, opts));
}
// intercepted
return 1;
return js.Intercepted.yes;
}
fn isInErrorSet(err: anyerror, comptime T: type) bool {
@@ -372,7 +365,6 @@ fn handleError(comptime T: type, comptime F: type, local: *const Local, err: any
error.TryCatchRethrow => return,
error.InvalidArgument => isolate.createTypeError("invalid argument"),
error.TypeError => isolate.createTypeError(""),
error.Idna => isolate.createTypeError("invalid domain"),
error.RangeError => isolate.createRangeError(""),
error.OutOfMemory => isolate.createError("out of memory"),
error.IllegalConstructor => isolate.createError("Illegal Constructor"),

View File

@@ -581,8 +581,7 @@ fn promiseRejectCallback(message_handle: v8.PromiseRejectMessage) callconv(.c) v
return;
}
const promise_handle = v8.v8__PromiseRejectMessage__GetPromise(&message_handle).?;
const v8_isolate = v8.v8__Object__GetIsolate(@ptrCast(promise_handle)).?;
const v8_isolate = v8.v8__Isolate__GetCurrent().?;
const isolate = js.Isolate{ .handle = v8_isolate };
const ctx, const v8_context = Context.fromIsolate(isolate) orelse return;

View File

@@ -46,7 +46,6 @@ pub fn getException(self: Module) js.Value {
pub fn getModuleRequests(self: Module) Requests {
return .{
.context_handle = self.local.handle,
.handle = v8.v8__Module__GetModuleRequests(self.handle).?,
};
}
@@ -117,14 +116,13 @@ pub const Global = struct {
const Requests = struct {
handle: *const v8.FixedArray,
context_handle: *const v8.Context,
pub fn len(self: Requests) usize {
return @intCast(v8.v8__FixedArray__Length(self.handle));
}
pub fn get(self: Requests, idx: usize) Request {
return .{ .handle = v8.v8__FixedArray__Get(self.handle, self.context_handle, @intCast(idx)).? };
return .{ .handle = v8.v8__FixedArray__Get(self.handle, @intCast(idx)).? };
}
};

View File

@@ -798,6 +798,7 @@ fn attachClass(comptime JsApi: type, comptime flatten: bool, isolate: *v8.Isolat
.deleter = null,
.definer = null,
.descriptor = null,
.index_of = null,
.data = null,
.flags = 0,
};

View File

@@ -259,8 +259,8 @@ pub const Accessor = struct {
};
pub const Indexed = struct {
getter: *const fn (idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8,
enumerator: ?*const fn (handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8,
getter: *const fn (idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32,
enumerator: ?*const fn (handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32,
const Opts = struct {
as_typed_array: bool = false,
@@ -271,11 +271,11 @@ pub const Indexed = struct {
var indexed = Indexed{
.enumerator = null,
.getter = struct {
fn wrap(idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 {
fn wrap(idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 {
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
var caller: Caller = undefined;
if (!caller.init(v8_isolate)) {
return 0;
return js.Intercepted.no;
}
defer caller.deinit();
@@ -289,11 +289,11 @@ pub const Indexed = struct {
if (@typeInfo(@TypeOf(enumerator)) != .null) {
indexed.enumerator = struct {
fn wrap(handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 {
fn wrap(handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 {
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
var caller: Caller = undefined;
if (!caller.init(v8_isolate)) {
return 0;
return js.Intercepted.no;
}
defer caller.deinit();
return caller.getEnumerator(T, enumerator, handle.?, .{});
@@ -306,9 +306,9 @@ pub const Indexed = struct {
};
pub const NamedIndexed = struct {
getter: *const fn (c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8,
setter: ?*const fn (c_name: ?*const v8.Name, c_value: ?*const v8.Value, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 = null,
deleter: ?*const fn (c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 = null,
getter: *const fn (c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32,
setter: ?*const fn (c_name: ?*const v8.Name, c_value: ?*const v8.Value, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null,
deleter: ?*const fn (c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null,
const Opts = struct {
as_typed_array: bool = false,
@@ -321,11 +321,11 @@ pub const NamedIndexed = struct {
fn init(comptime T: type, comptime getter: anytype, setter: anytype, deleter: anytype, comptime opts: Opts) NamedIndexed {
const getter_fn = struct {
fn wrap(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 {
fn wrap(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 {
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
var caller: Caller = undefined;
if (!caller.init(v8_isolate)) {
return 0;
return js.Intercepted.no;
}
defer caller.deinit();
@@ -337,11 +337,11 @@ pub const NamedIndexed = struct {
}.wrap;
const setter_fn = if (@typeInfo(@TypeOf(setter)) == .null) null else struct {
fn wrap(c_name: ?*const v8.Name, c_value: ?*const v8.Value, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 {
fn wrap(c_name: ?*const v8.Name, c_value: ?*const v8.Value, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 {
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
var caller: Caller = undefined;
if (!caller.init(v8_isolate)) {
return 0;
return js.Intercepted.no;
}
defer caller.deinit();
@@ -365,11 +365,11 @@ pub const NamedIndexed = struct {
}.wrap;
const deleter_fn = if (@typeInfo(@TypeOf(deleter)) == .null) null else struct {
fn wrap(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 {
fn wrap(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 {
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
var caller: Caller = undefined;
if (!caller.init(v8_isolate)) {
return 0;
return js.Intercepted.no;
}
defer caller.deinit();
@@ -480,14 +480,14 @@ pub const Property = struct {
}
};
pub fn unknownWindowPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 {
pub fn unknownWindowPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 {
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
// During snapshot creation, there's no Context in embedder data yet.
// I hate this check, but there doesn't seem to be a way to add this method
// to the global, without triggering it during snapshot creation.
const v8_context = v8.v8__Isolate__GetCurrentContext(v8_isolate) orelse return 0;
const ctx: *Context = @ptrCast(@alignCast(v8.v8__Context__GetAlignedPointerFromEmbedderData(v8_context, 1) orelse return 0));
const v8_context = v8.v8__Isolate__GetCurrentContext(v8_isolate) orelse return js.Intercepted.no;
const ctx: *Context = @ptrCast(@alignCast(v8.v8__Context__GetAlignedPointerFromEmbedderData(v8_context, 1) orelse return js.Intercepted.no));
var caller: Caller = undefined;
caller.initWithContext(ctx, v8_context);
@@ -500,7 +500,7 @@ pub fn unknownWindowPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8
defer hs.deinit();
const property: []const u8 = js.String.toSlice(.{ .local = local, .handle = @ptrCast(c_name.?) }) catch {
return 0;
return js.Intercepted.no;
};
// Only Page contexts have document.getElementById lookup
@@ -508,10 +508,10 @@ pub fn unknownWindowPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8
.frame => |frame| {
const document = frame.document;
if (document.getElementById(property, frame)) |el| {
const js_val = local.zigValueToJs(el, .{}) catch return 0;
const js_val = local.zigValueToJs(el, .{}) catch return js.Intercepted.no;
var pc = Caller.PropertyCallbackInfo{ .handle = handle.? };
pc.getReturnValue().set(js_val);
return 1;
return js.Intercepted.yes;
}
},
.worker => {}, // no global lookup in a worker
@@ -521,7 +521,7 @@ pub fn unknownWindowPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8
if (std.mem.startsWith(u8, property, "__")) {
// some frameworks will extend built-in types using a __ prefix
// these should always be safe to ignore.
return 0;
return js.Intercepted.no;
}
const ignored = std.StaticStringMap(void).initComptime(.{
@@ -553,28 +553,27 @@ pub fn unknownWindowPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8
});
if (!ignored.has(property)) {
var buf: [2048]u8 = undefined;
const key = std.fmt.bufPrint(&buf, "Window:{s}", .{property}) catch return 0;
logUnknownProperty(local, key) catch return 0;
const key = std.fmt.bufPrint(&buf, "Window:{s}", .{property}) catch return js.Intercepted.no;
logUnknownProperty(local, key) catch return js.Intercepted.no;
}
}
// not intercepted
return 0;
return js.Intercepted.no;
}
// Only used for debugging
pub fn unknownObjectPropertyCallback(comptime JsApi: type) *const fn (?*const v8.Name, ?*const v8.PropertyCallbackInfo) callconv(.c) u8 {
pub fn unknownObjectPropertyCallback(comptime JsApi: type) *const fn (?*const v8.Name, ?*const v8.PropertyCallbackInfo) callconv(.c) u32 {
if (comptime !IS_DEBUG) {
@compileError("unknownObjectPropertyCallback should only be used in debug builds");
}
return struct {
fn wrap(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 {
fn wrap(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 {
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
var caller: Caller = undefined;
if (!caller.init(v8_isolate)) {
return 0;
return js.Intercepted.no;
}
defer caller.deinit();
@@ -585,45 +584,44 @@ pub fn unknownObjectPropertyCallback(comptime JsApi: type) *const fn (?*const v8
defer hs.deinit();
const property: []const u8 = js.String.toSlice(.{ .local = local, .handle = @ptrCast(c_name.?) }) catch {
return 0;
return js.Intercepted.no;
};
if (std.mem.startsWith(u8, property, "__")) {
// some frameworks will extend built-in types using a __ prefix
// these should always be safe to ignore.
return 0;
return js.Intercepted.no;
}
if (std.mem.startsWith(u8, property, "jQuery")) {
return 0;
return js.Intercepted.no;
}
if (JsApi == @import("../webapi/cdata/Text.zig").JsApi or JsApi == @import("../webapi/cdata/Comment.zig").JsApi) {
if (std.mem.eql(u8, property, "tagName")) {
// knockout does this, a lot.
return 0;
return js.Intercepted.no;
}
}
if (JsApi == @import("../webapi/element/Html.zig").JsApi or JsApi == @import("../webapi/Element.zig").JsApi or JsApi == @import("../webapi/element/html/Custom.zig").JsApi) {
// react ?
if (std.mem.eql(u8, property, "props")) return 0;
if (std.mem.eql(u8, property, "hydrated")) return 0;
if (std.mem.eql(u8, property, "isHydrated")) return 0;
if (std.mem.eql(u8, property, "props")) return js.Intercepted.no;
if (std.mem.eql(u8, property, "hydrated")) return js.Intercepted.no;
if (std.mem.eql(u8, property, "isHydrated")) return js.Intercepted.no;
}
if (JsApi == @import("../webapi/Console.zig").JsApi) {
if (std.mem.eql(u8, property, "firebug")) return 0;
if (std.mem.eql(u8, property, "firebug")) return js.Intercepted.no;
}
const ignored = std.StaticStringMap(void).initComptime(.{});
if (!ignored.has(property)) {
var buf: [2048]u8 = undefined;
const key = std.fmt.bufPrint(&buf, "{s}:{s}", .{ if (@hasDecl(JsApi.Meta, "name")) JsApi.Meta.name else @typeName(JsApi), property }) catch return 0;
logUnknownProperty(local, key) catch return 0;
const key = std.fmt.bufPrint(&buf, "{s}:{s}", .{ if (@hasDecl(JsApi.Meta, "name")) JsApi.Meta.name else @typeName(JsApi), property }) catch return js.Intercepted.no;
logUnknownProperty(local, key) catch return js.Intercepted.no;
}
// not intercepted
return 0;
return js.Intercepted.no;
}
}.wrap;
}

View File

@@ -21,6 +21,11 @@ const lp = @import("lightpanda");
pub const v8 = @import("v8").c;
pub const Intercepted = struct {
pub const yes: u32 = 0;
pub const no: u32 = 1;
};
const string = @import("../../string.zig");
pub const Env = @import("Env.zig");

View File

@@ -709,7 +709,7 @@ test "browser.markdown: block link" {
\\### Title
\\
\\Description
\\[https://example.com](https://example.com)
\\[https://example.com/](https://example.com/)
\\
);
}
@@ -725,7 +725,7 @@ test "browser.markdown: block link with aria-label" {
\\### Title
\\
\\Description
\\[Docs](https://example.com)
\\[Docs](https://example.com/)
\\
);
}
@@ -741,7 +741,7 @@ test "browser.markdown: block link with title" {
\\### Title
\\
\\Description
\\[Docs](https://example.com)
\\[Docs](https://example.com/)
\\
);
}
@@ -751,7 +751,7 @@ test "browser.markdown: inline link" {
\\<p>Visit <a href="https://example.com">Example</a>.</p>
,
\\
\\Visit [Example](https://example.com).
\\Visit [Example](https://example.com/).
\\
);
}

View File

@@ -253,3 +253,37 @@
testing.expectEqual(testing.BASE_URL + 'element/html/over%209000!', a.href);
}
</script>
<script id=reflecting_getter_about_blank_base>
// Regression: with an `about:blank` document base, the WHATWG URL parser
// can't join a relative reference to a cannot-be-a-base URL. A reflecting
// URL getter must still NOT throw — per spec it returns the raw content
// attribute unchanged. (A thrown TypeError here used to break the WPT
// harness's get_stack(), which reads script.src, and hung the run.)
{
let base = document.createElement('base');
document.head.appendChild(base);
base.href = 'about:blank';
let a = document.createElement('a');
a.setAttribute('href', 'foo/bar');
testing.expectEqual('foo/bar', a.href);
let s = document.createElement('script');
s.src = 'resources/x.js';
testing.expectEqual('resources/x.js', s.src);
let img = document.createElement('img');
img.setAttribute('src', 'pic.png');
testing.expectEqual('pic.png', img.src);
// An absolute reference still resolves normally against an about:blank base.
let abs = document.createElement('a');
abs.setAttribute('href', 'https://example.com/x');
testing.expectEqual('https://example.com/x', abs.href);
// Restore the document base so later test blocks are unaffected.
base.href = '';
document.head.removeChild(base);
}
</script>

View File

@@ -0,0 +1,49 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<!--
MessageEvent.source is the WindowProxy of the frame the message came from, so it
is reference-equal to that frame's iframe.contentWindow — for a cross-origin
sender as well as a same-origin one. The two children below differ only by origin:
localhost is cross-origin to the 127.0.0.1 test page, 127.0.0.1 is same-origin;
both must satisfy event.source === iframe.contentWindow.
-->
<script id="message_source_identity" type=module>
{
const state = await testing.async();
const ALT_BASE = testing.BASE_URL.replace('127.0.0.1', 'localhost');
const ALT_ORIGIN = testing.ORIGIN.replace('127.0.0.1', 'localhost');
function loadAndAwait(src, expectedOrigin) {
return new Promise((resolve) => {
const iframe = document.createElement('iframe');
const onmsg = (e) => {
if (e.origin !== expectedOrigin) return;
window.removeEventListener('message', onmsg);
resolve({ iframe, event: e });
};
window.addEventListener('message', onmsg);
iframe.src = src;
document.documentElement.appendChild(iframe);
});
}
// localhost is a different origin than the 127.0.0.1 test page: cross-origin sender.
const cross = await loadAndAwait(ALT_BASE + 'frames/support/post_to_parent.html', ALT_ORIGIN);
// 127.0.0.1 is the test page's own origin: same-origin control.
const same = await loadAndAwait(testing.BASE_URL + 'frames/support/post_to_parent.html', testing.ORIGIN);
state.resolve();
await state.done(() => {
testing.expectEqual('hello', cross.event.data);
testing.expectEqual(ALT_ORIGIN, cross.event.origin);
testing.expectFalse(cross.event.source === null);
testing.expectTrue(cross.event.source === cross.iframe.contentWindow);
testing.expectEqual('hello', same.event.data);
testing.expectEqual(testing.ORIGIN, same.event.origin);
testing.expectTrue(same.event.source === same.iframe.contentWindow);
});
}
</script>

View File

@@ -0,0 +1,4 @@
<!DOCTYPE html>
<script>
parent.postMessage('hello', '*');
</script>

View File

@@ -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);

View File

@@ -481,3 +481,30 @@
testing.expectEqual(false, registered);
}
</script>
<script id=xhr_send_flag type=module>
{
// Per spec, calling send() while a request is already in flight throws
// InvalidStateError (the "send() flag"). Without it, two concurrent
// transfers race on the XHR's single response slot and one is left dangling
// at teardown (the "release overflow" UAF).
const state = await testing.async();
const req = new XMLHttpRequest();
req.onload = () => state.resolve();
req.open('GET', 'http://127.0.0.1:9582/xhr');
req.send();
testing.expectError('InvalidStateError', () => req.send());
await state.done(() => {
// the first request was unaffected by the rejected second send()
testing.expectEqual(200, req.status);
});
// a fresh open() clears the flag, so the object is reusable
const state2 = await testing.async();
req.onload = () => state2.resolve();
req.open('GET', 'http://127.0.0.1:9582/xhr');
req.send();
await state2.done(() => testing.expectEqual(200, req.status));
}
</script>

View File

@@ -333,31 +333,88 @@
<script id=parse_static>
testing.expectEqual('function', typeof URL.parse);
// Parseable input returns a URL instance (mirrors the constructor).
// Parseable absolute URLs (no base) -> a URL instance. parse mirrors the
// constructor: it returns an object exactly when `new URL(...)` would not
// throw, and null otherwise.
{
const url = URL.parse('https://example.com/path?a=b#h');
const url = URL.parse('https://example.com:8080/path?a=b#hash');
testing.expectEqual(true, url instanceof URL);
testing.expectEqual('https://example.com/path?a=b#h', url.href);
testing.expectEqual('https://example.com:8080/path?a=b#hash', url.href);
}
// Relative input resolves against a valid base.
// The returned object is a fully-functional URL: its components are
// populated and its accessors behave like a constructed instance.
{
const url = URL.parse('/x', 'http://example.com/a/b');
testing.expectEqual('http://example.com/x', url.href);
const url = URL.parse('https://user:pass@example.com:8080/p/q?x=1#frag');
testing.expectEqual('https:', url.protocol);
testing.expectEqual('user', url.username);
testing.expectEqual('pass', url.password);
testing.expectEqual('example.com', url.hostname);
testing.expectEqual('8080', url.port);
testing.expectEqual('example.com:8080', url.host);
testing.expectEqual('/p/q', url.pathname);
testing.expectEqual('?x=1', url.search);
testing.expectEqual('#frag', url.hash);
testing.expectEqual('https://example.com:8080', url.origin);
testing.expectEqual('1', url.searchParams.get('x'));
}
// Unlike the constructor, a parse failure returns null instead of throwing.
// The returned object is mutable like any other URL instance.
{
const url = URL.parse('https://example.com/path');
url.pathname = '/changed';
testing.expectEqual('https://example.com/changed', url.href);
}
// Non-special but parseable schemes -> a URL instance.
{
testing.expectEqual(true, URL.parse('about:blank') instanceof URL);
testing.expectEqual(true, URL.parse('mailto:foo@bar.com') instanceof URL);
testing.expectEqual(true, URL.parse('data:text/plain,hello') instanceof URL);
testing.expectEqual(true, URL.parse('file:///etc/hosts') instanceof URL);
}
// Relative or malformed input (no base) -> null instead of throwing.
{
testing.expectEqual(null, URL.parse('foo.js'));
testing.expectEqual(null, URL.parse(''));
testing.expectEqual(null, URL.parse('http://example.com', 'invalid base'));
testing.expectEqual(null, URL.parse('foo.js'));
testing.expectEqual(null, URL.parse('/path/to/file'));
testing.expectEqual(null, URL.parse('?query=value'));
testing.expectEqual(null, URL.parse('#fragment'));
testing.expectEqual(null, URL.parse('//only-host'));
testing.expectEqual(null, URL.parse('http://'));
}
// Each call returns a distinct object.
// Relative input resolved against a valid base -> a URL instance with the
// input resolved against the base.
{
const a = URL.parse('https://example.com/');
const b = URL.parse('https://example.com/');
testing.expectEqual(false, a === b);
testing.expectEqual('http://example.com/path/subpath', URL.parse('subpath', 'http://example.com/path/').href);
testing.expectEqual('http://example.com/newpath', URL.parse('/newpath', 'http://example.com/path').href);
testing.expectEqual('http://example.com/path?a=b', URL.parse('?a=b', 'http://example.com/path').href);
testing.expectEqual('http://example.com/path#hash', URL.parse('#hash', 'http://example.com/path').href);
testing.expectEqual('http://example.com/a/foo', URL.parse('../foo', 'http://example.com/a/b/c').href);
testing.expectEqual('http://other.com/p', URL.parse('//other.com/p', 'http://example.com/').href);
}
// Absolute input with a valid base -> the input wins, the base is ignored.
{
testing.expectEqual('http://example.com/absolute', URL.parse('http://example.com/absolute', 'http://base.com').href);
}
// The base is parsed unconditionally: an unparseable base -> null, even when
// the input itself is absolute (mirrors the constructor throwing).
{
testing.expectEqual(null, URL.parse('http://example.com/absolute', 'invalid base'));
testing.expectEqual(null, URL.parse('foo.js', 'not a url'));
testing.expectEqual(null, URL.parse('subpath', 'relative/path'));
testing.expectEqual(null, URL.parse('http://example.com', ''));
}
// An undefined base is treated as "no base given".
{
testing.expectEqual(true, URL.parse('https://example.com', undefined) instanceof URL);
testing.expectEqual(null, URL.parse('foo.js', undefined));
testing.expectEqual(null, URL.parse('subpath', undefined));
}
</script>
@@ -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);
}
</script>
<script id=wpt_searchparams_appends_equals>
// url-constructor: a query key with no value serializes back with a
// trailing '=' via URLSearchParams.
{
const url = new URL('http://foo.bar/baz?qux');
testing.expectEqual('qux=', url.searchParams.toString());
}
</script>
<script id=wpt_searchparams_encodes_question_mark>
// url-constructor: '?' inside a key must be percent-encoded as %3F when
// re-serialized through searchParams.
{
const url = new URL('??a=b&c=d', 'http://example.org/foo/bar');
testing.expectEqual('%3Fa=b&c=d', url.searchParams.toString());
}
</script>
<script id=wpt_setter_host_with_slash>
// url-setters: setting host to a value containing a '/' takes only the part
// before the delimiter as the host (the rest is ignored).
{
const url = new URL('http://example.net/path');
url.host = 'example.com/stuff';
testing.expectEqual('example.com', url.host);
testing.expectEqual('http://example.com/path', url.href);
}
</script>
<script id=wpt_setter_hostname_strips_tab>
// url-setters: a tab in a hostname is stripped (here clearing the host),
// the setter must not throw.
{
const url = new URL('sc://x/');
url.hostname = '\t';
testing.expectEqual('', url.hostname);
testing.expectEqual('sc:///', url.href);
}
</script>

View File

@@ -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, .{});

View File

@@ -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, .{});
};

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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");

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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),
};

View File

@@ -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 {

View File

@@ -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;
};
}

View File

@@ -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) {

View File

@@ -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();

View File

@@ -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

View File

@@ -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 } },

View File

@@ -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<u16> = if port_str.is_empty() {
None // "host:" clears the port
} else {
match port_str.parse::<u16>() {
Ok(p) => Some(p),
Err(_) => return -1,
}
let new_port: u16 = match port_str.parse::<u16>() {
Ok(p) => p,
Err(_) => return -1,
};
if url.set_host(Some(host)).is_err() {
return -1;
}
// set_port only errors on cannot-be-a-base, already ruled out by set_host.
let _ = url.set_port(new_port);
let _ = url.set_port(Some(new_port));
0
}
@@ -568,3 +617,168 @@ pub unsafe extern "C" fn url_get_href(
*out_ptr = href.as_ptr();
*out_len = href.len();
}
fn encode_query_ncr(encoding: &'static Encoding, s: &str) -> Cow<'static, [u8]> {
// fast path: fully mappable
let (out, _, had_errors) = encoding.encode(s);
if !had_errors {
return Cow::Owned(out.into_owned());
}
let mut encoder = encoding.new_encoder();
let mut result = Vec::with_capacity(s.len() * 2);
let mut input = s;
loop {
let needed = encoder
.max_buffer_length_from_utf8_without_replacement(input.len())
.unwrap();
let start = result.len();
result.resize(start + needed, 0);
let (r, read, written) =
encoder.encode_from_utf8_without_replacement(input, &mut result[start..], true);
result.truncate(start + written);
input = &input[read..];
match r {
EncoderResult::InputEmpty => break,
EncoderResult::Unmappable(c) => {
result.extend_from_slice(format!("%26%23{}%3B", c as u32).as_bytes());
}
// Output was sized with max_buffer_length, so it cannot run out.
EncoderResult::OutputFull => unreachable!(),
}
}
Cow::Owned(result)
}
#[no_mangle]
pub unsafe extern "C" fn url_resolve_with_encoding(
base_ptr: *const c_uchar,
base_len: usize,
input_ptr: *const c_uchar,
input_len: usize,
enc_ptr: *const c_uchar,
enc_len: usize,
err: *mut i32,
) -> OwnedString {
let base_slice = match str_from(base_ptr, base_len) {
Some(s) => s,
None => {
*err = -1;
return EMPTY_OWNED_STRING;
}
};
// An empty base means the input must be an absolute URL.
let base = if base_slice.is_empty() {
None
} else {
match Url::parse(base_slice) {
Ok(u) => Some(u),
Err(_) => {
*err = -1;
return EMPTY_OWNED_STRING;
}
}
};
let slice = match str_from(input_ptr, input_len) {
Some(s) => s,
None => {
*err = -1;
return EMPTY_OWNED_STRING;
}
};
let encoding_slice = match str_from(enc_ptr, enc_len) {
Some(s) => s,
None => {
*err = -1;
return EMPTY_OWNED_STRING;
}
};
// Per the URL spec, queries use the document encoding's *output encoding*.
let encoding = Encoding::for_label(encoding_slice.as_bytes())
.map(|encoding| encoding.output_encoding())
.filter(|&encoding| encoding != encoding_rs::UTF_8);
let result = match encoding {
Some(encoding) => Url::options()
.base_url(base.as_ref())
.encoding_override(Some(&move |s| encode_query_ncr(encoding, s)))
.parse(slice),
// Fallback to default.
None => match &base {
Some(base) => base.join(slice),
None => Url::parse(slice),
},
};
match result {
Ok(url) => {
*err = 0;
let s = String::from(url); // Moves the serialization, no copy.
let len = s.len();
let ptr = Box::into_raw(s.into_bytes().into_boxed_slice()) as *mut c_uchar;
OwnedString { ptr, len }
}
Err(_) => {
*err = -1;
EMPTY_OWNED_STRING
}
}
}
/// Similar to url_parse_with_base; returns a href instead.
#[no_mangle]
pub unsafe extern "C" fn url_resolve_without_encoding(
base_ptr: *const c_uchar,
base_len: usize,
input_ptr: *const c_uchar,
input_len: usize,
err: *mut i32,
) -> OwnedString {
let base_slice = match str_from(base_ptr, base_len) {
Some(s) => s,
None => {
*err = -1;
return EMPTY_OWNED_STRING;
}
};
// An empty base means the input must be an absolute URL.
let base = if base_slice.is_empty() {
None
} else {
match Url::parse(base_slice) {
Ok(u) => Some(u),
Err(_) => {
*err = -1;
return EMPTY_OWNED_STRING;
}
}
};
let input = match str_from(input_ptr, input_len) {
Some(s) => s,
None => {
*err = -1;
return EMPTY_OWNED_STRING;
}
};
let result = match &base {
Some(base) => base.join(input),
None => Url::parse(input),
};
match result {
Ok(url) => {
*err = 0;
let s = String::from(url); // Moves the serialization, no copy.
let len = s.len();
let ptr = Box::into_raw(s.into_bytes().into_boxed_slice()) as *mut c_uchar;
OwnedString { ptr, len }
}
Err(_) => {
*err = -1;
EMPTY_OWNED_STRING
}
}
}

View File

@@ -55,8 +55,6 @@ pub const cookies = @import("cookies.zig");
pub const build_config = @import("build_config");
pub const crash_handler = @import("crash_handler.zig");
const IS_DEBUG = @import("builtin").mode == .Debug;
pub const FetchOpts = struct {
wait_ms: u32 = 5000,
wait_until: ?Config.WaitUntil = null,
@@ -106,7 +104,7 @@ pub fn fetch(app: *App, browser: *Browser, urls: []const [:0]const u8, opts: Fet
const page = try session.createPage();
const frame = page.frame().?;
// not guaranteed to be valid after navigate
const encoded_url = try URL.ensureEncoded(frame.call_arena, url, "UTF-8");
const encoded_url = try URL.resolveNavigation(frame.call_arena, url, .{});
_ = try frame.navigate(encoded_url, .{
.reason = .address_bar,
.kind = .{ .push = null },

View File

@@ -20,7 +20,6 @@ const std = @import("std");
const lp = @import("lightpanda");
const Layer = @import("../../browser/HttpClient.zig").Layer;
const Request = @import("../../browser/HttpClient.zig").Request;
const Transfer = @import("../../browser/HttpClient.zig").Transfer;
const Response = @import("../../browser/HttpClient.zig").Response;

View File

@@ -20,10 +20,8 @@ const std = @import("std");
const lp = @import("lightpanda");
const log = lp.log;
const Client = @import("../../browser/HttpClient.zig").Client;
const Network = @import("../Network.zig");
const Transfer = @import("../../browser/HttpClient.zig").Transfer;
const Request = @import("../../browser/HttpClient.zig").Request;
const Response = @import("../../browser/HttpClient.zig").Response;
const Layer = @import("../../browser/HttpClient.zig").Layer;
const StableResponse = @import("../../browser/HttpClient.zig").StableResponse;

View File

@@ -16,6 +16,7 @@
// 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 builtin = @import("builtin");
const lp = @import("lightpanda");
const log = lp.log;
@@ -23,6 +24,7 @@ const log = lp.log;
const IS_DEBUG = builtin.mode == .Debug;
const http = @import("../http.zig");
const HttpClient = @import("../../browser/HttpClient.zig");
const Request = @import("../../browser/HttpClient.zig").Request;
const Transfer = @import("../../browser/HttpClient.zig").Transfer;
const Response = @import("../../browser/HttpClient.zig").Response;
@@ -232,6 +234,26 @@ pub fn fulfillRequest(
// Leave the parked state (accounting `intercepted` exactly once) and move to
// .completing BEFORE running the user callbacks.
transfer.unpark();
if (HttpClient.isRedirectStatus(status)) {
if (findLocation(headers)) |location| {
fulfilledRedirect(transfer, status, headers, location) catch |err| {
if (transfer.state == .created) {
transfer.abort(err);
}
return err;
};
self.next.request(transfer) catch |err| {
if (transfer.state == .created) {
transfer.abort(err);
}
return err;
};
return;
}
}
// Not a redirect: move to .completing BEFORE running the user callbacks.
transfer.state = .completing;
defer transfer.deinit();
@@ -281,3 +303,25 @@ fn fulfillInner(
done.* = true;
try req.done_callback(req.ctx);
}
fn fulfilledRedirect(transfer: *Transfer, status: u16, headers: []const http.Header, location: []const u8) !void {
// retrieve cookies from the fulfilled response's headers.
if (transfer.req.cookie_jar) |jar| {
for (headers) |hdr| {
if (std.ascii.eqlIgnoreCase(hdr.name, "set-cookie")) {
try jar.populateFromResponse(transfer.req.url, hdr.value);
}
}
}
try transfer.applyRedirectTarget(transfer.req.url, location, status);
}
fn findLocation(headers: []const http.Header) ?[]const u8 {
for (headers) |hdr| {
if (std.ascii.eqlIgnoreCase(hdr.name, "location")) {
return hdr.value;
}
}
return null;
}

View File

@@ -16,9 +16,6 @@
//! Bindings for Servo's rust-url (https://github.com/servo/rust-url).
//! Check @src/html5ever/url.rs for Rust-side of the bindings.
const std = @import("std");
const mem = std.mem;
pub const Url = anyopaque;
pub const OwnedString = extern struct {
@@ -83,6 +80,12 @@ pub extern "c" fn url_get_fragment(url: *const Url, out_ptr: *[*]const u8, out_l
pub extern "c" fn url_set_query(url: *Url, ptr: [*]const u8, len: usize) i32;
pub extern "c" fn url_set_query_to_null(url: *Url) void;
pub extern "c" fn url_get_query(url: *const Url, out_ptr: *[*]const u8, out_len: *usize) i32;
/// `err` is `0` if there's no error.
/// Returned `OwnedString` doesn't have a sentinel; callers must be aware of that.
pub extern "c" fn url_resolve_with_encoding(base_ptr: [*]const u8, base_len: usize, input_ptr: [*]const u8, input_len: usize, enc_ptr: [*]const u8, enc_len: usize, err: *i32) OwnedString;
/// `err` is `0` if there's no error.
/// Returned `OwnedString` doesn't have a sentinel; callers must be aware of that.
pub extern "c" fn url_resolve_without_encoding(base_ptr: [*]const u8, base_len: usize, input_ptr: [*]const u8, input_len: usize, err: *i32) OwnedString;
extern "c" fn url_get_port(url: *const Url) i32;
pub inline fn urlGetPort(url: *const Url) ?u16 {