From 58c18114a590af1526a34cb6766f4eca3d20e786 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Mon, 23 Mar 2026 16:52:39 +0100 Subject: [PATCH] fix: percent-encode pathname in URL.setPathname per URL spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit URL.setPathname() inserted the value verbatim without percent-encoding, so `url.pathname = "c d"` produced `http://a/c d` instead of `http://a/c%20d`. This caused sites using URL polyfills (e.g. Angular's polyfills bundle) to detect broken native URL support and fall back to a polyfill that relies on HTMLInputElement.checkValidity(), which is not implemented — crashing the entire app bootstrap. --- src/browser/URL.zig | 28 +++++++++++++++++++++++++--- src/browser/tests/url.html | 29 +++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/browser/URL.zig b/src/browser/URL.zig index 935d1791..6dce6f1d 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -595,11 +595,14 @@ pub fn setPathname(current: [:0]const u8, value: []const u8, allocator: Allocato const search = getSearch(current); const hash = getHash(current); + // Percent-encode the pathname per the URL spec (spaces → %20, etc.) + const encoded = try percentEncodeSegment(allocator, value, .path); + // Add / prefix if not present and value is not empty - const pathname = if (value.len > 0 and value[0] != '/') - try std.fmt.allocPrint(allocator, "/{s}", .{value}) + const pathname = if (encoded.len > 0 and encoded[0] != '/') + try std.fmt.allocPrint(allocator, "/{s}", .{encoded}) else - value; + encoded; return buildUrl(allocator, protocol, host, pathname, search, hash); } @@ -1422,3 +1425,22 @@ test "URL: getHost" { try testing.expectEqualSlices(u8, "example.com:8080", getHost("https://user:pass@example.com:8080/page")); try testing.expectEqualSlices(u8, "", getHost("not-a-url")); } + +test "URL: setPathname percent-encodes" { + // Use arena allocator to match production usage (setPathname makes intermediate allocations) + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + // Spaces must be encoded as %20 + const result1 = try setPathname("http://a/", "c d", allocator); + try testing.expectEqualSlices(u8, "http://a/c%20d", result1); + + // Already-encoded sequences must not be double-encoded + const result2 = try setPathname("https://example.com/path", "/already%20encoded", allocator); + try testing.expectEqualSlices(u8, "https://example.com/already%20encoded", result2); + + // Query and hash must be preserved + const result3 = try setPathname("https://example.com/path?a=b#hash", "/new path", allocator); + try testing.expectEqualSlices(u8, "https://example.com/new%20path?a=b#hash", result3); +} diff --git a/src/browser/tests/url.html b/src/browser/tests/url.html index c1bfa09b..5e126db5 100644 --- a/src/browser/tests/url.html +++ b/src/browser/tests/url.html @@ -591,6 +591,35 @@ testing.expectEqual('/new/path', url.pathname); } +// Pathname setter must percent-encode spaces and special characters +{ + const url = new URL('http://a/'); + url.pathname = 'c d'; + testing.expectEqual('http://a/c%20d', url.href); +} + +{ + const url = new URL('https://example.com/path'); + url.pathname = '/path with spaces/file name'; + testing.expectEqual('https://example.com/path%20with%20spaces/file%20name', url.href); + testing.expectEqual('/path%20with%20spaces/file%20name', url.pathname); +} + +// Already-encoded sequences should not be double-encoded +{ + const url = new URL('https://example.com/path'); + url.pathname = '/already%20encoded'; + testing.expectEqual('https://example.com/already%20encoded', url.href); +} + +// This is the exact check the URL polyfill uses to decide if native URL is sufficient +{ + const url = new URL('b', 'http://a'); + url.pathname = 'c d'; + testing.expectEqual('http://a/c%20d', url.href); + testing.expectEqual(true, !!url.searchParams); +} + { const url = new URL('https://example.com/path'); url.search = '?a=b';