Merge branch 'main' into agent

This commit is contained in:
Adrià Arrufat
2026-06-01 08:04:44 +02:00
12 changed files with 327 additions and 18 deletions

View File

@@ -95,6 +95,22 @@ fn handleClient(client: posix.socket_t) void {
const key_end = std.mem.indexOfScalarPos(u8, request, key_line_start, '\r') orelse return;
const key = request[key_line_start..key_end];
// Capture the request's Cookie header value (if any) so the test
// fixture can ask for it back via the `get-cookie` command. Copy out
// of `request` because the WS frame loop below reuses `buf` for
// incoming frames, invalidating the slice. Buffer is sized to match
// `buf` since any cookie that fit in the request fits here.
var cookie_buf: [4096]u8 = undefined;
var cookie_len: usize = 0;
const cookie_header = "Cookie: ";
if (std.mem.indexOf(u8, request, cookie_header)) |cookie_start| {
const value_start = cookie_start + cookie_header.len;
const value_end = std.mem.indexOfScalarPos(u8, request, value_start, '\r') orelse value_start;
const value = request[value_start..value_end];
cookie_len = @min(value.len, cookie_buf.len);
@memcpy(cookie_buf[0..cookie_len], value[0..cookie_len]);
}
// Compute accept key
var hasher = std.crypto.hash.Sha1.init(.{});
hasher.update(key);
@@ -128,7 +144,7 @@ fn handleClient(client: posix.socket_t) void {
// Handle commands or echo
if (frame.opcode == 1) { // Text
handleTextMessage(client, frame.payload) catch break;
handleTextMessage(client, frame.payload, cookie_buf[0..cookie_len]) catch break;
} else if (frame.opcode == 2) { // Binary
handleBinaryMessage(client, frame.payload) catch break;
}
@@ -233,12 +249,20 @@ const RecvBuffer = struct {
}
};
fn handleTextMessage(client: posix.socket_t, payload: []const u8) !void {
fn handleTextMessage(client: posix.socket_t, payload: []const u8, cookie_header: []const u8) !void {
// Command: force-close - close socket immediately without close frame
if (std.mem.eql(u8, payload, "force-close")) {
return error.ForceClose;
}
// Command: get-cookie - send the Cookie header value the upgrade
// request carried (empty string if none). Used by the cookie-on-
// upgrade regression test.
if (std.mem.eql(u8, payload, "get-cookie")) {
try sendFrame(client, 1, "cookie:", cookie_header);
return;
}
// Command: send-large:N - send a message of N bytes
if (std.mem.startsWith(u8, payload, "send-large:")) {
const size_str = payload["send-large:".len..];

View File

@@ -1247,10 +1247,12 @@ pub const Request = struct {
.origin_url = self.cookie_origin,
.is_navigation = self.resource_type == .document,
});
const written = aw.written();
if (written.len == 0) return null;
if (aw.written().len == 0) {
return null;
}
try aw.writer.writeByte(0);
return written.ptr[0..written.len :0];
const written = aw.written();
return written.ptr[0 .. written.len - 1 :0];
}
pub fn deinit(self: *const Request) void {

View File

@@ -121,7 +121,8 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, source_path: anytype, o
const scheme_end = std.mem.indexOf(u8, base, "://");
const authority_start = if (scheme_end) |end| end + 3 else 0;
const path_start = std.mem.indexOfScalarPos(u8, base, authority_start, '/') orelse base.len;
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;
if (path[0] == '/') {
const result = try std.mem.joinZ(allocator, "", &.{ base[0..path_start], path });
@@ -129,8 +130,8 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, source_path: anytype, o
}
var normalized_base: []const u8 = base[0..path_start];
if (path_start < base.len) {
if (std.mem.lastIndexOfScalar(u8, base[path_start + 1 ..], '/')) |pos| {
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];
}
}
@@ -482,8 +483,8 @@ pub fn getProtocol(raw: [:0]const u8) []const u8 {
return raw[0 .. pos + 1];
}
pub fn isHTTPS(raw: [:0]const u8) bool {
return std.mem.startsWith(u8, raw, "https:");
pub fn isSecure(raw: [:0]const u8) bool {
return std.mem.startsWith(u8, raw, "https:") or std.mem.startsWith(u8, raw, "wss:");
}
pub fn getHostname(raw: [:0]const u8) []const u8 {
@@ -991,6 +992,66 @@ test "URL: resolve" {
.path = "something.js",
.expected = "https://example/xyz/abc/something.js",
},
.{
.base = "http://127.0.0.1:8123/#/login",
.path = "api/users/login",
.expected = "http://127.0.0.1:8123/api/users/login",
},
.{
.base = "https://example/app/page?next=/foo/bar",
.path = "api/users/login",
.expected = "https://example/app/api/users/login",
},
.{
.base = "https://example/app/page#/foo/bar",
.path = "api/users/login",
.expected = "https://example/app/api/users/login",
},
.{
.base = "https://example?next=/foo/bar",
.path = "api/users/login",
.expected = "https://example/api/users/login",
},
.{
.base = "https://example#/foo/bar",
.path = "api/users/login",
.expected = "https://example/api/users/login",
},
.{
.base = "https://example?next=/foo/bar",
.path = "/api/users/login",
.expected = "https://example/api/users/login",
},
.{
.base = "https://example/app/page?next=/foo/bar",
.path = "../api/users/login",
.expected = "https://example/api/users/login",
},
.{
.base = "https://example/app/page#/foo/bar",
.path = "../api/users/login",
.expected = "https://example/api/users/login",
},
.{
.base = "https://example/app/dir/?next=/foo/bar",
.path = "../api/users/login",
.expected = "https://example/app/api/users/login",
},
.{
.base = "https://example/app/dir/#/foo/bar",
.path = "../api/users/login",
.expected = "https://example/app/api/users/login",
},
.{
.base = "https://example/app/page?next=/foo/bar",
.path = "?q=/api/users/login",
.expected = "https://example/app/page?q=/api/users/login",
},
.{
.base = "https://example/app/page#/foo/bar",
.path = "#/api/users/login",
.expected = "https://example/app/page#/api/users/login",
},
.{
.base = "https://example/xyz/abc/123",
.path = "/something.js",

View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=fetch_relative_url_ignores_hash_route type=module>
{
const state = await testing.async();
const originalUrl = location.href;
history.replaceState(null, '', '/#/login');
const response = await fetch('xhr', { method: 'POST', body: 'foo' });
const text = await response.text();
history.replaceState(null, '', originalUrl);
state.resolve({
ok: response.ok,
status: response.status,
url: response.url,
length: text.length,
});
await state.done((data) => {
testing.expectEqual(true, data.ok);
testing.expectEqual(200, data.status);
testing.expectEqual('http://127.0.0.1:9582/xhr', data.url);
testing.expectEqual(100, data.length);
});
}
</script>

View File

@@ -579,3 +579,169 @@
});
}
</script>
<script id=cookies_on_upgrade type=module>
{
// Cookies set on the page should ride along with the WebSocket
// upgrade request as a Cookie header — same as any other same-origin
// HTTP request. Without this, cookie-authenticated WS endpoints
// (Phoenix LiveView, anything session-gated) reject the upgrade.
//
// RFC 6455 §4.1: the upgrade is an HTTP/1.1 request and follows
// normal HTTP cookie semantics.
// RFC 6265 §5.4: cookies attach to "any HTTP request" matching
// the cookie's domain/path; ports are not part of cookie scope, so
// a cookie set on 127.0.0.1:9582 attaches to a 127.0.0.1:9584 WS.
// Set with path=/ so the cookie applies to the WS server's "/" path
// (without an explicit Path attribute, default-path uses the page's
// directory which doesn't match the WS endpoint).
document.cookie = 'ws_token=session_xyz; path=/';
// Sanity-check the cookie did land in the jar before opening the WS.
testing.expectEqual(true, document.cookie.includes('ws_token=session_xyz'));
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.addEventListener('open', () => {
ws.send('get-cookie');
});
ws.addEventListener('message', (e) => {
received.push(e.data);
ws.close();
});
ws.addEventListener('close', () => {
state.resolve();
});
await state.done(() => {
testing.expectEqual(['cookie:ws_token=session_xyz'], received);
});
// Clean up so other tests on this fixture don't see this cookie.
document.cookie = 'ws_token=; path=/; Max-Age=0';
}
</script>
<script id=cookies_on_upgrade_path_scoped_out type=module>
{
// Path scoping: a cookie scoped to /restricted MUST NOT attach to
// a WS upgrade for "/". Guards against the patch over-sending cookies.
// Each test starts by clearing all cookies on path=/ since this
// fixture's <script> blocks share a single page/cookie jar.
for (const pair of document.cookie.split(';')) {
const name = pair.split('=')[0].trim();
if (name) document.cookie = `${name}=; path=/; Max-Age=0`;
}
document.cookie = 'restricted=nope; path=/restricted';
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.addEventListener('open', () => { ws.send('get-cookie'); });
ws.addEventListener('message', (e) => { received.push(e.data); ws.close(); });
ws.addEventListener('close', () => { state.resolve(); });
await state.done(() => {
// Server should report an empty Cookie header.
testing.expectEqual(['cookie:'], received);
});
document.cookie = 'restricted=; path=/restricted; Max-Age=0';
}
</script>
<script id=cookies_on_upgrade_samesite_strict type=module>
{
// SameSite=Strict on a same-site upgrade (origin and target share
// host 127.0.0.1) MUST attach. The patch passes is_navigation=false;
// the cookie jar's SameSite logic only blocks Strict cookies when
// the request is cross-site, regardless of navigation status. This
// test pins the same-site path so a regression that breaks same-site
// detection on WS upgrades fails loudly.
for (const pair of document.cookie.split(';')) {
const name = pair.split('=')[0].trim();
if (name) document.cookie = `${name}=; path=/; Max-Age=0`;
}
document.cookie = 'strict_token=s; path=/; SameSite=Strict';
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.addEventListener('open', () => { ws.send('get-cookie'); });
ws.addEventListener('message', (e) => { received.push(e.data); ws.close(); });
ws.addEventListener('close', () => { state.resolve(); });
await state.done(() => {
testing.expectEqual(['cookie:strict_token=s'], received);
});
document.cookie = 'strict_token=; path=/; Max-Age=0';
}
</script>
<script id=cookies_on_upgrade_empty_jar type=module>
{
// With no matching cookies, no Cookie header should be added.
// The patch has a `if (cookie_buf.written().len > 0)` guard;
// this test exercises the false branch so a regression that
// sends an empty `Cookie: ` header is caught.
for (const pair of document.cookie.split(';')) {
const name = pair.split('=')[0].trim();
if (name) document.cookie = `${name}=; path=/; Max-Age=0`;
}
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.addEventListener('open', () => { ws.send('get-cookie'); });
ws.addEventListener('message', (e) => { received.push(e.data); ws.close(); });
ws.addEventListener('close', () => { state.resolve(); });
await state.done(() => {
// Server's get-cookie returns "cookie:" with whatever Cookie header
// value it captured — empty string means no header was sent.
testing.expectEqual(['cookie:'], received);
});
}
</script>
<script id=cookies_on_upgrade_multiple type=module>
{
// Multiple matching cookies must be joined with "; " per RFC 6265 §5.4.
for (const pair of document.cookie.split(';')) {
const name = pair.split('=')[0].trim();
if (name) document.cookie = `${name}=; path=/; Max-Age=0`;
}
document.cookie = 'a=1; path=/';
document.cookie = 'b=2; path=/';
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.addEventListener('open', () => { ws.send('get-cookie'); });
ws.addEventListener('message', (e) => { received.push(e.data); ws.close(); });
ws.addEventListener('close', () => { state.resolve(); });
await state.done(() => {
// Order in the Cookie header isn't strictly specified by RFC 6265
// but the jar iterates insertion order, so a=1 lands first.
testing.expectEqual(['cookie:a=1; b=2'], received);
});
document.cookie = 'a=; path=/; Max-Age=0';
document.cookie = 'b=; path=/; Max-Age=0';
}
</script>

View File

@@ -55,8 +55,11 @@ pub fn define(self: *CustomElementRegistry, name: []const u8, constructor: js.Fu
break :blk tag;
} else null;
const gop = try self._definitions.getOrPut(frame.arena, name);
if (gop.found_existing) {
// Do not use getOrPut here to hold the GetOrPut entry until we need to write
// the definition. constructor.getPropertyValue() can fire a get accessor
// which could callback here and alter self._definitions, invalidating any
// GOP pointer we have.
if (self._definitions.contains(name)) {
// Yes, this is the correct error to return when trying to redefine a name
return error.NotSupported;
}
@@ -95,6 +98,10 @@ pub fn define(self: *CustomElementRegistry, name: []const u8, constructor: js.Fu
}
}
const gop = try self._definitions.getOrPut(frame.arena, owned_name);
if (gop.found_existing) {
return error.NotSupported;
}
gop.key_ptr.* = owned_name;
gop.value_ptr.* = definition;

View File

@@ -270,4 +270,5 @@ fn httpShutdownCallback(ctx: *anyopaque) void {
const testing = @import("../../../testing.zig");
test "WebApi: fetch" {
try testing.htmlRunner("net/fetch.html", .{});
try testing.htmlRunner("net/fetch_hash_route.html", .{});
}

View File

@@ -133,9 +133,29 @@ pub fn init(url: []const u8, protocols: [][]const u8, exec: *const Execution) !*
var headers = try http_client.newHeaders();
errdefer headers.deinit();
var has_extra_headers = false;
if (protocols.len > 0) {
const header = try std.fmt.allocPrintSentinel(arena, "Sec-WebSocket-Protocol: {s}", .{try std.mem.join(arena, ", ", protocols)}, 0);
try headers.add(header);
has_extra_headers = true;
}
{
var buf: std.Io.Writer.Allocating = .init(arena);
try exec.session.cookie_jar.forRequest(resolved_url, &buf.writer, .{
.is_http = true,
.is_navigation = false,
.origin_url = exec.url.*,
});
if (buf.written().len > 0) {
try buf.writer.writeByte(0);
const written = buf.written();
try conn.setCookies(written.ptr[0 .. written.len - 1 :0]);
}
}
if (has_extra_headers) {
try conn.setHeaders(&headers);
}

View File

@@ -574,7 +574,7 @@ pub const Jar = struct {
const target = PreparedUri{
.host = URL.getHostname(target_url),
.path = URL.getPathname(target_url),
.secure = URL.isHTTPS(target_url),
.secure = URL.isSecure(target_url),
};
const same_site = try areSameSite(opts.origin_url, target.host);

View File

@@ -76,7 +76,7 @@ fn onCookieChanged(ctx: *anyopaque, data: *const Notification.CookieChanged) !vo
const target = Cookie.PreparedUri{
.host = URL.getHostname(doc_url),
.path = URL.getPathname(doc_url),
.secure = URL.isHTTPS(doc_url),
.secure = URL.isSecure(doc_url),
};
if (target.host.len == 0) return;
@@ -353,7 +353,7 @@ fn matchCookies(
const target = Cookie.PreparedUri{
.host = URL.getHostname(url_resolved),
.path = URL.getPathname(url_resolved),
.secure = URL.isHTTPS(url_resolved),
.secure = URL.isSecure(url_resolved),
};
if (target.host.len == 0) return error.SecurityError;
@@ -408,7 +408,7 @@ fn storeCookie(exec: *const Execution, init: CookieInit) !void {
if (std.mem.indexOfAny(u8, d, ";\r\n\x00") != null) return error.InvalidCookieDomain;
}
const is_https = URL.isHTTPS(url);
const is_https = URL.isSecure(url);
// Per spec, SameSite=None requires Secure. CookieStore additionally
// marks any cookie written from an HTTPS document as Secure.
const secure = is_https or init.sameSite == .none;

View File

@@ -233,7 +233,7 @@ fn getCookies(cmd: *CDP.Command) !void {
urls.appendAssumeCapacity(.{
.host = try Cookie.parseDomain(cmd.arena, url, null),
.path = try Cookie.parsePath(cmd.arena, url, null),
.secure = URL.isHTTPS(url),
.secure = URL.isSecure(url),
});
}

View File

@@ -153,7 +153,7 @@ pub fn setCdpCookie(cookie_jar: *CookieJar, param: CdpCookie) !void {
const domain = try Cookie.parseDomain(a, param.url, param.domain);
const path = if (param.path == null) "/" else try Cookie.parsePath(a, null, param.path);
const secure = if (param.secure) |s| s else if (param.url) |url| URL.isHTTPS(url) else false;
const secure = if (param.secure) |s| s else if (param.url) |url| URL.isSecure(url) else false;
break :blk Cookie{
.arena = arena,