From 9a0cefad26a980ffe01a4ff5e2e72e26704e3e6e Mon Sep 17 00:00:00 2001 From: dinisimys2018 Date: Mon, 30 Mar 2026 18:42:23 +0300 Subject: [PATCH 1/4] fix(browser-url): url resolve scheme in path --- src/browser/URL.zig | 223 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 196 insertions(+), 27 deletions(-) diff --git a/src/browser/URL.zig b/src/browser/URL.zig index b095a17d..9dcf44ac 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -25,28 +25,54 @@ const ResolveOpts = struct { }; // 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, path: anytype, comptime opts: ResolveOpts) ![:0]const u8 { - const PT = @TypeOf(path); - if (base.len == 0 or isCompleteHTTPUrl(path)) { - if (comptime opts.always_dupe or !isNullTerminated(PT)) { - const duped = try allocator.dupeZ(u8, path); - return processResolved(allocator, duped, opts); - } - if (comptime opts.encode) { - return processResolved(allocator, path, opts); - } - return path; +pub fn resolve(allocator: Allocator, base: [:0]const u8, source_path: anytype, comptime opts: ResolveOpts) ![:0]const u8 { + const PT = @TypeOf(source_path); + + if (source_path.len == 0) { + return processResolved(allocator, base, opts); + } + var path: [:0]const u8 = if (comptime !isNullTerminated(PT) or opts.always_dupe) try allocator.dupeZ(u8, source_path) else source_path; + + if (base.len == 0) { + return processResolved(allocator, path, opts); } - if (path.len == 0) { - if (comptime opts.always_dupe) { - const duped = try allocator.dupeZ(u8, base); - return processResolved(allocator, duped, 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: { + //from "ws" to "https" + if (scheme_path_end >= 2 and scheme_path_end <= 5) { + const scheme_path = path[0..scheme_path_end]; + const has_double_sleshes: bool = 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_sleshes) { + //Skip ":" and exit as relative state + path = path[scheme_path_end + 1 ..]; + break :scheme_check; + } else { + var rest_start: usize = scheme_path_end + 1; + //File scheme allow empty host + const separator: []const u8 = if (std.ascii.eqlIgnoreCase(scheme_path, "file") and !has_double_sleshes) ":///" else "://"; + //Skip any sleshes after "scheme:" + while (rest_start < path.len and (path[rest_start] == '/' or path[rest_start] == '\\')) { + rest_start += 1; + } + path = try std.mem.joinZ(allocator, "", &.{ scheme_path, separator, path[rest_start..] }); + return processResolved(allocator, path, opts); + } + } + } + } + //path is complete http url + return processResolved(allocator, path, opts); + } } - if (comptime opts.encode) { - return processResolved(allocator, base, opts); - } - return base; } if (path[0] == '?') { @@ -63,14 +89,7 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime if (std.mem.startsWith(u8, path, "//")) { // network-path reference const index = std.mem.indexOfScalar(u8, base, ':') orelse { - if (comptime isNullTerminated(PT)) { - if (comptime opts.encode) { - return processResolved(allocator, path, opts); - } - return path; - } - const duped = try allocator.dupeZ(u8, path); - return processResolved(allocator, duped, opts); + return processResolved(allocator, path, opts); }; const protocol = base[0 .. index + 1]; const result = try std.mem.joinZ(allocator, "", &.{ protocol, path }); @@ -96,6 +115,7 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime // 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 var out = try std.mem.join(allocator, "", &.{ normalized_base, "/", path, " " }); + const end = out.len - 2; const path_marker = path_start + 1; @@ -1570,3 +1590,152 @@ test "URL: getOrigin" { } } } + +test "URL: resolve path scheme" { + const Case = struct { + base: [:0]const u8, + path: [:0]const u8, + expected: [:0]const u8, + }; + + const cases = [_]Case{ + //same schemes and path as relative path (one slash) + .{ + .base = "https://www.example.com/example", + .path = "https:/about", + .expected = "https://www.example.com/about", + }, + //same schemes and path as relative path (without slash) + .{ + .base = "https://www.example.com/example", + .path = "https:about", + .expected = "https://www.example.com/about", + }, + //same schemes and path as absolute path (two slashes) + .{ + .base = "https://www.example.com/example", + .path = "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", + }, + //different schemes and path as absolute (with one slash) + .{ + .base = "https://www.example.com/example", + .path = "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", + }, + //same schemes and path as absolute (with more slashes) + .{ + .base = "https://site/", + .path = "https://path", + .expected = "https://path", + }, + //path scheme is not special and path as absolute (without additional slashes) + .{ + .base = "http://localhost/", + .path = "data:test", + .expected = "data:test", + }, + //different schemes and path as absolute (pathscheme=ws) + .{ + .base = "https://www.example.com/example", + .path = "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", + }, + //different schemes and path as absolute (path scheme=ftp) + .{ + .base = "https://www.example.com/example", + .path = "ftp://about", + .expected = "ftp://about", + }, + //different schemes and path as absolute (path scheme=file) + .{ + .base = "https://www.example.com/example", + .path = "file://path/to/file", + .expected = "file://path/to/file", + }, + //different schemes and path as absolute (path scheme=file, host is empty) + .{ + .base = "https://www.example.com/example", + .path = "file:/path/to/file", + .expected = "file:///path/to/file", + }, + .{ + .base = "https://www.example.com/example", + .path = "file:path/to/file", + .expected = "file:///path/to/file", + }, + .{ + .base = "https://www.example.com/example", + .path = "https:/file:/relative/path/", + .expected = "https://www.example.com/file:/relative/path/", + }, + .{ + .base = "https://www.example.com/example", + .path = "https:/http://relative/path/", + .expected = "https://www.example.com/http://relative/path/", + }, + .{ + .base = "http://www.example.com/example", + .path = "http:http:/relative/path/", + .expected = "http://www.example.com/http:/relative/path/", + }, + .{ + .base = "http://www.example.com/example", + .path = "http:relative:path", + .expected = "http://www.example.com/relative:path", + }, + .{ + .base = "https://www.example.com/example", + .path = "https:/http://relative/path/", + .expected = "https://www.example.com/http://relative/path/", + }, + .{ + .base = "http://www.example.com/example", + .path = "http:http:/relative/path/", + .expected = "http://www.example.com/http:/relative/path/", + }, + .{ + .base = "http://www.example.com/example", + .path = "http:https://relative:path", + .expected = "http://www.example.com/https://relative:path", + }, + .{ + .base = "http://www.example.com/example", + .path = "blob:other", + .expected = "blob:other", + }, + .{ + .base = "http://www.example.com/example", + .path = "custom+foo:other", + .expected = "custom+foo:other", + }, + .{ + .base = "http://www.example.com/example", + .path = "blob:", + .expected = "blob:", + }, + }; + + for (cases) |case| { + const result = try resolve(testing.arena_allocator, case.base, case.path, .{}); + try testing.expectString(case.expected, result); + } +} From 0a222ff39754346afcbcbd12d1cf5582f600434c Mon Sep 17 00:00:00 2001 From: dinisimys2018 Date: Tue, 31 Mar 2026 16:54:06 +0300 Subject: [PATCH 2/4] fix(browser-url): add more combinations base+path handle --- src/browser/URL.zig | 83 ++++++++++++++++++++++++++++--------- src/browser/webapi/Node.zig | 1 + 2 files changed, 64 insertions(+), 20 deletions(-) diff --git a/src/browser/URL.zig b/src/browser/URL.zig index 9dcf44ac..06700f36 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -28,9 +28,6 @@ const ResolveOpts = struct { pub fn resolve(allocator: Allocator, base: [:0]const u8, source_path: anytype, comptime opts: ResolveOpts) ![:0]const u8 { const PT = @TypeOf(source_path); - if (source_path.len == 0) { - return processResolved(allocator, base, opts); - } var path: [:0]const u8 = if (comptime !isNullTerminated(PT) or opts.always_dupe) try allocator.dupeZ(u8, source_path) else source_path; if (base.len == 0) { @@ -41,40 +38,59 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, source_path: anytype, c 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 scheme_path = path[0..scheme_path_end]; - const has_double_sleshes: bool = path[scheme_path_end + 1] == '/' and path[scheme_path_end + 2] == '/'; + const has_double_slashas: 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_sleshes) { + if (base_scheme_end > 0 and std.mem.eql(u8, base[0..base_scheme_end], scheme_path) and !has_double_slashas) { //Skip ":" and exit as relative state path = path[scheme_path_end + 1 ..]; break :scheme_check; } else { var rest_start: usize = scheme_path_end + 1; - //File scheme allow empty host - const separator: []const u8 = if (std.ascii.eqlIgnoreCase(scheme_path, "file") and !has_double_sleshes) ":///" else "://"; - //Skip any sleshes after "scheme:" + //Skip any slashas after "scheme:" while (rest_start < path.len and (path[rest_start] == '/' or path[rest_start] == '\\')) { rest_start += 1; } + //special scheme need to any symbols after "://" + if (rest_start >= path.len) { + return error.InvalidURL; + } + //File scheme allow empty host + const separator: []const u8 = if (std.ascii.eqlIgnoreCase(scheme_path, "file") and !has_double_slashas) ":///" else "://"; + path = try std.mem.joinZ(allocator, "", &.{ scheme_path, separator, path[rest_start..] }); return processResolved(allocator, path, opts); } } } } + 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 }); @@ -1596,6 +1612,7 @@ test "URL: resolve path scheme" { base: [:0]const u8, path: [:0]const u8, expected: [:0]const u8, + expected_error: bool = false, }; const cases = [_]Case{ @@ -1677,65 +1694,91 @@ test "URL: resolve path scheme" { .path = "file:/path/to/file", .expected = "file:///path/to/file", }, + //different schemes without :// and normalize "file" scheme, absolute path .{ .base = "https://www.example.com/example", .path = "file:path/to/file", .expected = "file:///path/to/file", }, + //same schemes without :// in path and rest starts with scheme:/, relative path .{ .base = "https://www.example.com/example", .path = "https:/file:/relative/path/", .expected = "https://www.example.com/file:/relative/path/", }, + //same schemes without :// in path and rest starts with scheme://, relative path .{ .base = "https://www.example.com/example", .path = "https:/http://relative/path/", .expected = "https://www.example.com/http://relative/path/", }, - .{ - .base = "http://www.example.com/example", - .path = "http:http:/relative/path/", - .expected = "http://www.example.com/http:/relative/path/", - }, + //same schemes without :// in path , relative state .{ .base = "http://www.example.com/example", .path = "http:relative:path", .expected = "http://www.example.com/relative:path", }, - .{ - .base = "https://www.example.com/example", - .path = "https:/http://relative/path/", - .expected = "https://www.example.com/http://relative/path/", - }, + //repeat different schemes in path .{ .base = "http://www.example.com/example", .path = "http:http:/relative/path/", .expected = "http://www.example.com/http:/relative/path/", }, + //repeat different schemes in path .{ .base = "http://www.example.com/example", .path = "http:https://relative:path", .expected = "http://www.example.com/https://relative:path", }, + //NOT required :// for blob scheme .{ .base = "http://www.example.com/example", .path = "blob:other", .expected = "blob:other", }, + //NOT required :// for NON-special schemes and can contains "+" or "-" or "." in scheme .{ .base = "http://www.example.com/example", .path = "custom+foo:other", .expected = "custom+foo:other", }, + //NOT required :// for NON-special schemes .{ .base = "http://www.example.com/example", .path = "blob:", .expected = "blob:", }, + //NOT required :// for special scheme equal base scheme + .{ + .base = "http://www.example.com/example", + .path = "http:", + .expected = "http://www.example.com/example", + }, + //required :// for special scheme, so throw error.InvalidURL + .{ + .base = "http://www.example.com/example", + .path = "https:", + .expected = "", + .expected_error = true, + }, + //incorrect symbols in path scheme + .{ + .base = "https://site", + .path = "http?://host/some", + .expected = "https://site/http?://host/some", + }, }; for (cases) |case| { - const result = try resolve(testing.arena_allocator, case.base, case.path, .{}); + const result = resolve(testing.arena_allocator, case.base, case.path, .{}) catch |err| { + if (err == error.InvalidURL) { + try testing.expect(case.expected_error); + continue; + } + + return err; + }; + try testing.expectString(case.expected, result); } } diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 0e7c2ffe..5b493ed5 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -750,6 +750,7 @@ const CloneError = error{ TypeError, CompilationError, JsException, + InvalidURL, }; pub fn cloneNode(self: *Node, deep_: ?bool, page: *Page) CloneError!*Node { const deep = deep_ orelse false; From 2d87f5bf47ef56c1ec4f2bc4d2ea3ee2f50e78a3 Mon Sep 17 00:00:00 2001 From: dinisimys2018 Date: Tue, 31 Mar 2026 18:42:03 +0300 Subject: [PATCH 3/4] fix(browser-url): handle specific file scheme and change error InvalidURL to TypeError --- src/browser/URL.zig | 35 +++++++++++++++++++---------------- src/browser/webapi/Node.zig | 1 - 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/browser/URL.zig b/src/browser/URL.zig index 06700f36..579cd7f8 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -58,12 +58,12 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, source_path: anytype, c while (rest_start < path.len and (path[rest_start] == '/' or path[rest_start] == '\\')) { rest_start += 1; } - //special scheme need to any symbols after "://" - if (rest_start >= path.len) { - return error.InvalidURL; + // A special scheme (exclude "file") must contain at least anu 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 (std.ascii.eqlIgnoreCase(scheme_path, "file") and !has_double_slashas) ":///" else "://"; + const separator: []const u8 = if (!has_double_slashas 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); @@ -1694,6 +1694,12 @@ test "URL: resolve path scheme" { .path = "file:/path/to/file", .expected = "file:///path/to/file", }, + //different schemes and path as absolute (path scheme=file, host is empty) + .{ + .base = "https://www.example.com/example", + .path = "file:/", + .expected = "file:///", + }, //different schemes without :// and normalize "file" scheme, absolute path .{ .base = "https://www.example.com/example", @@ -1712,13 +1718,13 @@ test "URL: resolve path scheme" { .path = "https:/http://relative/path/", .expected = "https://www.example.com/http://relative/path/", }, - //same schemes without :// in path , relative state + //same schemes without :// in path , relative state .{ .base = "http://www.example.com/example", .path = "http:relative:path", .expected = "http://www.example.com/relative:path", }, - //repeat different schemes in path + //repeat different schemes in path .{ .base = "http://www.example.com/example", .path = "http:http:/relative/path/", @@ -1770,15 +1776,12 @@ test "URL: resolve path scheme" { }; for (cases) |case| { - const result = resolve(testing.arena_allocator, case.base, case.path, .{}) catch |err| { - if (err == error.InvalidURL) { - try testing.expect(case.expected_error); - continue; - } - - return err; - }; - - 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); + } } } diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 5b493ed5..0e7c2ffe 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -750,7 +750,6 @@ const CloneError = error{ TypeError, CompilationError, JsException, - InvalidURL, }; pub fn cloneNode(self: *Node, deep_: ?bool, page: *Page) CloneError!*Node { const deep = deep_ orelse false; From 7fcaa500d8495301ecdc3020eac180ea871e28b4 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 1 Apr 2026 19:20:55 +0800 Subject: [PATCH 4/4] Fix typo in variable name protect against overflow if path stats with ':' Minor tweaks to https://github.com/lightpanda-io/browser/pull/2046 --- src/browser/URL.zig | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/browser/URL.zig b/src/browser/URL.zig index 579cd7f8..dd49bfb9 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -41,14 +41,14 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, source_path: anytype, c 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_slashas: bool = scheme_path_end + 3 <= path.len and path[scheme_path_end + 1] == '/' and path[scheme_path_end + 2] == '/'; + 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_slashas) { + 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; @@ -58,12 +58,12 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, source_path: anytype, c 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 anu chars after "://" + // 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_slashas and std.ascii.eqlIgnoreCase(scheme_path, "file")) ":///" else "://"; + 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); @@ -71,10 +71,12 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, source_path: anytype, c } } } - for (scheme_path[1..]) |c| { - if (!std.ascii.isAlphanumeric(c) and c != '+' and c != '-' and c != '.') { - //Exit as relative state - break :scheme_check; + 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