From ecec932a477e7c8e7683de3d203e7bd8ad365fbd Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 9 Mar 2026 17:13:12 +0800 Subject: [PATCH] Add setters to URL.username and URL.password Also, preserve port when setting host. --- src/browser/URL.zig | 103 ++++++++++++++++++++++++++++++------ src/browser/tests/url.html | 104 ++++++++++++++++++++++++++++++++++++- src/browser/webapi/URL.zig | 14 ++++- 3 files changed, 201 insertions(+), 20 deletions(-) diff --git a/src/browser/URL.zig b/src/browser/URL.zig index 78d14b23..19b87333 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -167,17 +167,17 @@ pub fn ensureEncoded(allocator: Allocator, url: [:0]const u8) ![:0]const u8 { const query_end = if (query_start) |_| (fragment_start orelse url.len) else path_end; const path_to_encode = url[path_start..path_end]; - const encoded_path = try percentEncodeSegment(allocator, path_to_encode, true); + const encoded_path = try percentEncodeSegment(allocator, path_to_encode, .path); const encoded_query = if (query_start) |qs| blk: { const query_to_encode = url[qs + 1 .. query_end]; - const encoded = try percentEncodeSegment(allocator, query_to_encode, false); + const encoded = try percentEncodeSegment(allocator, query_to_encode, .query); break :blk encoded; } else null; const encoded_fragment = if (fragment_start) |fs| blk: { const fragment_to_encode = url[fs + 1 ..]; - const encoded = try percentEncodeSegment(allocator, fragment_to_encode, false); + const encoded = try percentEncodeSegment(allocator, fragment_to_encode, .query); break :blk encoded; } else null; @@ -204,11 +204,13 @@ pub fn ensureEncoded(allocator: Allocator, url: [:0]const u8) ![:0]const u8 { return buf.items[0 .. buf.items.len - 1 :0]; } -fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime is_path: bool) ![]const u8 { +const EncodeSet = enum { path, query, userinfo }; + +fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime encode_set: EncodeSet) ![]const u8 { // Check if encoding is needed var needs_encoding = false; for (segment) |c| { - if (shouldPercentEncode(c, is_path)) { + if (shouldPercentEncode(c, encode_set)) { needs_encoding = true; break; } @@ -235,7 +237,7 @@ fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime is_p } } - if (shouldPercentEncode(c, is_path)) { + if (shouldPercentEncode(c, encode_set)) { try buf.writer(allocator).print("%{X:0>2}", .{c}); } else { try buf.append(allocator, c); @@ -245,16 +247,17 @@ fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime is_p return buf.items; } -fn shouldPercentEncode(c: u8, comptime is_path: bool) bool { +fn shouldPercentEncode(c: u8, comptime encode_set: EncodeSet) bool { return switch (c) { // Unreserved characters (RFC 3986) 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => false, - // sub-delims allowed in both path and query - '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=' => false, - // Separators allowed in both path and query - '/', ':', '@' => false, - // Query-specific: '?' is allowed in queries but not in paths - '?' => comptime is_path, + // sub-delims allowed in path/query but some must be encoded in userinfo + '!', '$', '&', '\'', '(', ')', '*', '+', ',' => false, + ';', '=' => encode_set == .userinfo, + // Separators: userinfo must encode these + '/', ':', '@' => encode_set == .userinfo, + // '?' is allowed in queries but not in paths or userinfo + '?' => encode_set != .query, // Everything else needs encoding (including space) else => true, }; @@ -514,7 +517,7 @@ pub fn setHost(current: [:0]const u8, value: []const u8, allocator: Allocator) ! const search = getSearch(current); const hash = getHash(current); - // Check if the host includes a port + // Check if the new value includes a port const colon_pos = std.mem.lastIndexOfScalar(u8, value, ':'); const clean_host = if (colon_pos) |pos| blk: { const port_str = value[pos + 1 ..]; @@ -526,7 +529,14 @@ pub fn setHost(current: [:0]const u8, value: []const u8, allocator: Allocator) ! break :blk value[0..pos]; } break :blk value; - } else value; + } else blk: { + // No port in new value - preserve existing port + const current_port = getPort(current); + if (current_port.len > 0) { + break :blk try std.fmt.allocPrint(allocator, "{s}:{s}", .{ value, current_port }); + } + break :blk value; + }; return buildUrl(allocator, protocol, clean_host, pathname, search, hash); } @@ -544,6 +554,9 @@ pub fn setHostname(current: [:0]const u8, value: []const u8, allocator: Allocato pub fn setPort(current: [:0]const u8, value: ?[]const u8, allocator: Allocator) ![:0]const u8 { const hostname = getHostname(current); const protocol = getProtocol(current); + const pathname = getPathname(current); + const search = getSearch(current); + const hash = getHash(current); // Handle null or default ports const new_host = if (value) |port_str| blk: { @@ -560,7 +573,7 @@ pub fn setPort(current: [:0]const u8, value: ?[]const u8, allocator: Allocator) break :blk try std.fmt.allocPrint(allocator, "{s}:{s}", .{ hostname, port_str }); } else hostname; - return setHost(current, new_host, allocator); + return buildUrl(allocator, protocol, new_host, pathname, search, hash); } pub fn setPathname(current: [:0]const u8, value: []const u8, allocator: Allocator) ![:0]const u8 { @@ -608,6 +621,64 @@ pub fn setHash(current: [:0]const u8, value: []const u8, allocator: Allocator) ! return buildUrl(allocator, protocol, host, pathname, search, hash); } +pub fn setUsername(current: [:0]const u8, value: []const u8, allocator: Allocator) ![:0]const u8 { + const protocol = getProtocol(current); + const host = getHost(current); + const pathname = getPathname(current); + const search = getSearch(current); + const hash = getHash(current); + const password = getPassword(current); + + const encoded_username = try percentEncodeSegment(allocator, value, .userinfo); + return buildUrlWithUserInfo(allocator, protocol, encoded_username, password, host, pathname, search, hash); +} + +pub fn setPassword(current: [:0]const u8, value: []const u8, allocator: Allocator) ![:0]const u8 { + const protocol = getProtocol(current); + const host = getHost(current); + const pathname = getPathname(current); + const search = getSearch(current); + const hash = getHash(current); + const username = getUsername(current); + + const encoded_password = try percentEncodeSegment(allocator, value, .userinfo); + return buildUrlWithUserInfo(allocator, protocol, username, encoded_password, host, pathname, search, hash); +} + +fn buildUrlWithUserInfo( + allocator: Allocator, + protocol: []const u8, + username: []const u8, + password: []const u8, + host: []const u8, + pathname: []const u8, + search: []const u8, + hash: []const u8, +) ![:0]const u8 { + if (username.len == 0 and password.len == 0) { + return buildUrl(allocator, protocol, host, pathname, search, hash); + } else if (password.len == 0) { + return std.fmt.allocPrintSentinel(allocator, "{s}//{s}@{s}{s}{s}{s}", .{ + protocol, + username, + host, + pathname, + search, + hash, + }, 0); + } else { + return std.fmt.allocPrintSentinel(allocator, "{s}//{s}:{s}@{s}{s}{s}{s}", .{ + protocol, + username, + password, + host, + pathname, + search, + hash, + }, 0); + } +} + pub fn concatQueryString(arena: Allocator, url: []const u8, query_string: []const u8) ![:0]const u8 { if (query_string.len == 0) { return arena.dupeZ(u8, url); diff --git a/src/browser/tests/url.html b/src/browser/tests/url.html index c5a71a85..f8074422 100644 --- a/src/browser/tests/url.html +++ b/src/browser/tests/url.html @@ -218,6 +218,106 @@ testing.expectEqual('', url.password); } + { + const url = new URL('https://example.com/path'); + url.username = 'newuser'; + testing.expectEqual('newuser', url.username); + testing.expectEqual('https://newuser@example.com/path', url.href); + } + + { + const url = new URL('https://olduser@example.com/path'); + url.username = 'newuser'; + testing.expectEqual('newuser', url.username); + testing.expectEqual('https://newuser@example.com/path', url.href); + } + + { + const url = new URL('https://olduser:pass@example.com/path'); + url.username = 'newuser'; + testing.expectEqual('newuser', url.username); + testing.expectEqual('pass', url.password); + testing.expectEqual('https://newuser:pass@example.com/path', url.href); + } + + { + const url = new URL('https://user@example.com/path'); + url.password = 'secret'; + testing.expectEqual('user', url.username); + testing.expectEqual('secret', url.password); + testing.expectEqual('https://user:secret@example.com/path', url.href); + } + + { + const url = new URL('https://user:oldpass@example.com/path'); + url.password = 'newpass'; + testing.expectEqual('user', url.username); + testing.expectEqual('newpass', url.password); + testing.expectEqual('https://user:newpass@example.com/path', url.href); + } + + { + const url = new URL('https://user:pass@example.com/path'); + url.username = ''; + url.password = ''; + testing.expectEqual('', url.username); + testing.expectEqual('', url.password); + testing.expectEqual('https://example.com/path', url.href); + } + + { + const url = new URL('https://example.com/path'); + url.username = 'user@domain'; + testing.expectEqual('user%40domain', url.username); + testing.expectEqual('https://user%40domain@example.com/path', url.href); + } + + { + const url = new URL('https://example.com/path'); + url.username = 'user:name'; + testing.expectEqual('user%3Aname', url.username); + } + + { + const url = new URL('https://example.com/path'); + url.password = 'pass@word'; + testing.expectEqual('pass%40word', url.password); + } + + { + const url = new URL('https://example.com/path'); + url.password = 'pass:word'; + testing.expectEqual('pass%3Aword', url.password); + } + + { + const url = new URL('https://example.com/path'); + url.username = 'user/name'; + testing.expectEqual('user%2Fname', url.username); + } + + { + const url = new URL('https://example.com/path'); + url.password = 'pass?word'; + testing.expectEqual('pass%3Fword', url.password); + } + + { + const url = new URL('https://user%40domain:pass%3Aword@example.com/path'); + testing.expectEqual('user%40domain', url.username); + testing.expectEqual('pass%3Aword', url.password); + } + + { + const url = new URL('https://example.com:8080/path?a=b#hash'); + url.username = 'user'; + url.password = 'pass'; + testing.expectEqual('https://user:pass@example.com:8080/path?a=b#hash', url.href); + testing.expectEqual('8080', url.port); + testing.expectEqual('?a=b', url.search); + testing.expectEqual('#hash', url.hash); + } + { const url = new URL('http://user:pass@example.com:8080/path?query=1#hash'); testing.expectEqual('http:', url.protocol); @@ -437,9 +537,9 @@ { const url = new URL('https://example.com:8080/path'); url.host = 'newhost.com'; - testing.expectEqual('https://newhost.com/path', url.href); + testing.expectEqual('https://newhost.com:8080/path', url.href); testing.expectEqual('newhost.com', url.hostname); - testing.expectEqual('', url.port); + testing.expectEqual('8080', url.port); } { diff --git a/src/browser/webapi/URL.zig b/src/browser/webapi/URL.zig index 0f2dc58b..3bc6f586 100644 --- a/src/browser/webapi/URL.zig +++ b/src/browser/webapi/URL.zig @@ -66,10 +66,20 @@ pub fn getUsername(self: *const URL) []const u8 { return U.getUsername(self._raw); } +pub fn setUsername(self: *URL, value: []const u8) !void { + const allocator = self._arena orelse return error.NoAllocator; + self._raw = try U.setUsername(self._raw, value, allocator); +} + pub fn getPassword(self: *const URL) []const u8 { return U.getPassword(self._raw); } +pub fn setPassword(self: *URL, value: []const u8) !void { + const allocator = self._arena orelse return error.NoAllocator; + self._raw = try U.setPassword(self._raw, value, allocator); +} + pub fn getPathname(self: *const URL) []const u8 { return U.getPathname(self._raw); } @@ -272,8 +282,8 @@ pub const JsApi = struct { pub const search = bridge.accessor(URL.getSearch, URL.setSearch, .{}); pub const hash = bridge.accessor(URL.getHash, URL.setHash, .{}); pub const pathname = bridge.accessor(URL.getPathname, URL.setPathname, .{}); - pub const username = bridge.accessor(URL.getUsername, null, .{}); - pub const password = bridge.accessor(URL.getPassword, null, .{}); + pub const username = bridge.accessor(URL.getUsername, URL.setUsername, .{}); + pub const password = bridge.accessor(URL.getPassword, URL.setPassword, .{}); pub const hostname = bridge.accessor(URL.getHostname, URL.setHostname, .{}); pub const host = bridge.accessor(URL.getHost, URL.setHost, .{}); pub const port = bridge.accessor(URL.getPort, URL.setPort, .{});