diff --git a/src/browser/key_value.zig b/src/browser/key_value.zig new file mode 100644 index 00000000..a4000220 --- /dev/null +++ b/src/browser/key_value.zig @@ -0,0 +1,277 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); + +const Allocator = std.mem.Allocator; + +// Used by FormDAta and URLSearchParams. +// +// We store the values in an ArrayList rather than a an +// StringArrayHashMap([]const u8) because of the way the iterators (i.e., keys(), +// values() and entries()) work. The FormData can contain duplicate keys, and +// each iteration yields 1 key=>value pair. So, given: +// +// let f = new FormData(); +// f.append('a', '1'); +// f.append('a', '2'); +// +// Then we'd expect f.keys(), f.values() and f.entries() to yield 2 results: +// ['a', '1'] +// ['a', '2'] +// +// This is much easier to do with an ArrayList than a HashMap, especially given +// that the FormData could be mutated while iterating. +// The downside is that most of the normal operations are O(N). +pub const List = struct { + entries: std.ArrayListUnmanaged(KeyValue) = .{}, + + pub fn init(entries: std.ArrayListUnmanaged(KeyValue)) List { + return .{ .entries = entries }; + } + + pub fn clone(self: *const List, arena: Allocator) !List { + const entries = self.entries.items; + + var c: std.ArrayListUnmanaged(KeyValue) = .{}; + try c.ensureTotalCapacity(arena, entries.len); + for (entries) |kv| { + c.appendAssumeCapacity(kv); + } + + return .{ .entries = c }; + } + + pub fn fromOwnedSlice(entries: []KeyValue) List { + return .{ + .entries = std.ArrayListUnmanaged(KeyValue).fromOwnedSlice(entries), + }; + } + + pub fn count(self: *const List) usize { + return self.entries.items.len; + } + + pub fn get(self: *const List, key: []const u8) ?[]const u8 { + const result = self.find(key) orelse return null; + return result.entry.value; + } + + pub fn getAll(self: *const List, arena: Allocator, key: []const u8) ![]const []const u8 { + var arr: std.ArrayListUnmanaged([]const u8) = .empty; + for (self.entries.items) |entry| { + if (std.mem.eql(u8, key, entry.key)) { + try arr.append(arena, entry.value); + } + } + return arr.items; + } + + pub fn has(self: *const List, key: []const u8) bool { + return self.find(key) != null; + } + + pub fn set(self: *List, arena: Allocator, key: []const u8, value: []const u8) !void { + self.delete(key); + return self.append(arena, key, value); + } + + pub fn append(self: *List, arena: Allocator, key: []const u8, value: []const u8) !void { + return self.appendOwned(arena, try arena.dupe(u8, key), try arena.dupe(u8, value)); + } + + pub fn appendOwned(self: *List, arena: Allocator, key: []const u8, value: []const u8) !void { + return self.entries.append(arena, .{ + .key = key, + .value = value, + }); + } + + pub fn delete(self: *List, key: []const u8) void { + var i: usize = 0; + while (i < self.entries.items.len) { + const entry = self.entries.items[i]; + if (std.mem.eql(u8, key, entry.key)) { + _ = self.entries.swapRemove(i); + } else { + i += 1; + } + } + } + + pub fn deleteKeyValue(self: *List, key: []const u8, value: []const u8) void { + var i: usize = 0; + while (i < self.entries.items.len) { + const entry = self.entries.items[i]; + if (std.mem.eql(u8, key, entry.key) and std.mem.eql(u8, value, entry.value)) { + _ = self.entries.swapRemove(i); + } else { + i += 1; + } + } + } + + pub fn keyIterator(self: *const List) KeyIterator { + return .{ .entries = &self.entries }; + } + + pub fn valueIterator(self: *const List) ValueIterator { + return .{ .entries = &self.entries }; + } + + pub fn entryIterator(self: *const List) EntryIterator { + return .{ .entries = &self.entries }; + } + + pub fn ensureTotalCapacity(self: *List, arena: Allocator, len: usize) !void { + return self.entries.ensureTotalCapacity(arena, len); + } + + const FindResult = struct { + index: usize, + entry: KeyValue, + }; + + fn find(self: *const List, key: []const u8) ?FindResult { + for (self.entries.items, 0..) |entry, i| { + if (std.mem.eql(u8, key, entry.key)) { + return .{ .index = i, .entry = entry }; + } + } + return null; + } +}; + +pub const KeyValue = struct { + key: []const u8, + value: []const u8, +}; + +pub const KeyIterator = struct { + index: usize = 0, + entries: *const std.ArrayListUnmanaged(KeyValue), + + pub fn _next(self: *KeyIterator) ?[]const u8 { + const entries = self.entries.items; + + const index = self.index; + if (index == entries.len) { + return null; + } + self.index += 1; + return entries[index].key; + } +}; + +pub const ValueIterator = struct { + index: usize = 0, + entries: *const std.ArrayListUnmanaged(KeyValue), + + pub fn _next(self: *ValueIterator) ?[]const u8 { + const entries = self.entries.items; + + const index = self.index; + if (index == entries.len) { + return null; + } + self.index += 1; + return entries[index].value; + } +}; + +pub const EntryIterator = struct { + index: usize = 0, + entries: *const std.ArrayListUnmanaged(KeyValue), + + pub fn _next(self: *EntryIterator) ?struct { []const u8, []const u8 } { + const entries = self.entries.items; + + const index = self.index; + if (index == entries.len) { + return null; + } + self.index += 1; + const entry = entries[index]; + return .{ entry.key, entry.value }; + } +}; + +const URLEncodeMode = enum { + form, + query, +}; + +pub fn urlEncode(list: List, mode: URLEncodeMode, writer: anytype) !void { + const entries = list.entries.items; + if (entries.len == 0) { + return; + } + + try urlEncodeEntry(entries[0], mode, writer); + for (entries[1..]) |entry| { + try writer.writeByte('&'); + try urlEncodeEntry(entry, mode, writer); + } +} + +fn urlEncodeEntry(entry: KeyValue, mode: URLEncodeMode, writer: anytype) !void { + try urlEncodeValue(entry.key, mode, writer); + + // for a form, for an empty value, we'll do "spice=" + // but for a query, we do "spice" + if (mode == .query and entry.value.len == 0) { + return; + } + + try writer.writeByte('='); + try urlEncodeValue(entry.value, mode, writer); +} + +fn urlEncodeValue(value: []const u8, mode: URLEncodeMode, writer: anytype) !void { + if (!urlEncodeShouldEscape(value, mode)) { + return writer.writeAll(value); + } + + for (value) |b| { + if (urlEncodeUnreserved(b, mode)) { + try writer.writeByte(b); + } else if (b == ' ' and mode == .form) { + // for form submission, space should be encoded as '+', not '%20' + try writer.writeByte('+'); + } else { + try writer.print("%{X:0>2}", .{b}); + } + } +} + +fn urlEncodeShouldEscape(value: []const u8, mode: URLEncodeMode) bool { + for (value) |b| { + if (!urlEncodeUnreserved(b, mode)) { + return true; + } + } + return false; +} + +fn urlEncodeUnreserved(b: u8, mode: URLEncodeMode) bool { + return switch (b) { + 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_' => true, + '~' => mode == .query, + else => false, + }; +} diff --git a/src/browser/url/query.zig b/src/browser/url/query.zig index b8afa834..a8621e5e 100644 --- a/src/browser/url/query.zig +++ b/src/browser/url/query.zig @@ -18,56 +18,46 @@ const std = @import("std"); -const Reader = @import("../../str/parser.zig").Reader; -const asUint = @import("../../str/parser.zig").asUint; +const Allocator = std.mem.Allocator; // Values is a map with string key of string values. pub const Values = struct { - arena: std.heap.ArenaAllocator, - map: std.StringArrayHashMapUnmanaged(List), + map: std.StringArrayHashMapUnmanaged(List) = .{}, const List = std.ArrayListUnmanaged([]const u8); - pub fn init(allocator: std.mem.Allocator) Values { - return .{ - .map = .{}, - .arena = std.heap.ArenaAllocator.init(allocator), - }; - } - - pub fn deinit(self: *Values) void { - self.arena.deinit(); - } - // add the key value couple to the values. // the key and the value are duplicated. - pub fn append(self: *Values, k: []const u8, v: []const u8) !void { - const allocator = self.arena.allocator(); + pub fn append(self: *Values, arena: Allocator, k: []const u8, v: []const u8) !void { + const owned_value = try arena.dupe(u8, v); + + var gop = try self.map.getOrPut(arena, k); + errdefer _ = self.map.orderedRemove(k); + + if (gop.found_existing) { + return gop.value_ptr.append(arena, owned_value); + } + + gop.key_ptr.* = try arena.dupe(u8, k); + + var list = List{}; + try list.append(arena, owned_value); + gop.value_ptr.* = list; + } + + pub fn set(self: *Values, arena: Allocator, k: []const u8, v: []const u8) !void { const owned_value = try allocator.dupe(u8, v); var gop = try self.map.getOrPut(allocator, k); + errdefer _ = self.map.remove(k); + if (gop.found_existing) { - return gop.value_ptr.append(allocator, owned_value); + gop.value_ptr.clearRetainingCapacity(); + } else { + gop._key_ptr.* = try arena.dupe(u8, k); + gop.value_ptr.* = .empty; } - - gop.key_ptr.* = try allocator.dupe(u8, k); - - var list = List{}; - try list.append(allocator, owned_value); - gop.value_ptr.* = list; - } - - // append by taking the ownership of the key and the value - fn appendOwned(self: *Values, k: []const u8, v: []const u8) !void { - const allocator = self.arena.allocator(); - var gop = try self.map.getOrPut(allocator, k); - if (gop.found_existing) { - return gop.value_ptr.append(allocator, v); - } - - var list = List{}; - try list.append(allocator, v); - gop.value_ptr.* = list; + try gop.value_ptr.append(arena, owned_value); } pub fn get(self: *const Values, k: []const u8) []const []const u8 { @@ -80,13 +70,16 @@ pub const Values = struct { pub fn first(self: *const Values, k: []const u8) []const u8 { if (self.map.getPtr(k)) |list| { - if (list.items.len == 0) return ""; + std.debug.assert(liste.items.len > 0); return list.items[0]; } - return ""; } + pub fn has(self: *const Values, k: []const u8) bool { + return self.map.contains(k); + } + pub fn delete(self: *Values, k: []const u8) void { _ = self.map.fetchSwapRemove(k); } @@ -97,6 +90,9 @@ pub const Values = struct { for (list.items, 0..) |vv, i| { if (std.mem.eql(u8, v, vv)) { _ = list.swapRemove(i); + if (i == 0) { + _ = self.map.orderedRemove(k); + } return; } } @@ -252,27 +248,27 @@ fn unescape(allocator: std.mem.Allocator, input: []const u8) ![]const u8 { const encoded = input[input_pos + 1 .. input_pos + 3]; const encoded_as_uint = @as(u16, @bitCast(encoded[0..2].*)); unescaped[unescaped_pos] = switch (encoded_as_uint) { - asUint("20") => ' ', - asUint("21") => '!', - asUint("22") => '"', - asUint("23") => '#', - asUint("24") => '$', - asUint("25") => '%', - asUint("26") => '&', - asUint("27") => '\'', - asUint("28") => '(', - asUint("29") => ')', - asUint("2A") => '*', - asUint("2B") => '+', - asUint("2C") => ',', - asUint("2F") => '/', - asUint("3A") => ':', - asUint("3B") => ';', - asUint("3D") => '=', - asUint("3F") => '?', - asUint("40") => '@', - asUint("5B") => '[', - asUint("5D") => ']', + asUint(u16, "20") => ' ', + asUint(u16, "21") => '!', + asUint(u16, "22") => '"', + asUint(u16, "23") => '#', + asUint(u16, "24") => '$', + asUint(u16, "25") => '%', + asUint(u16, "26") => '&', + asUint(u16, "27") => '\'', + asUint(u16, "28") => '(', + asUint(u16, "29") => ')', + asUint(u16, "2A") => '*', + asUint(u16, "2B") => '+', + asUint(u16, "2C") => ',', + asUint(u16, "2F") => '/', + asUint(u16, "3A") => ':', + asUint(u16, "3B") => ';', + asUint(u16, "3D") => '=', + asUint(u16, "3F") => '?', + asUint(u16, "40") => '@', + asUint(u16, "5B") => '[', + asUint(u16, "5D") => ']', else => HEX_DECODE[encoded[0]] << 4 | HEX_DECODE[encoded[1]], }; input_pos += 3; @@ -286,7 +282,11 @@ fn unescape(allocator: std.mem.Allocator, input: []const u8) ![]const u8 { return unescaped; } -const testing = std.testing; +pub fn asUint(comptime T: type, comptime string: []const u8) T { + return @bitCast(string[0..string.len].*); +} + +const testing = @import("../../testing.zig"); test "url.Query: unescape" { const allocator = testing.allocator; const cases = [_]struct { expected: []const u8, input: []const u8, free: bool }{ @@ -304,7 +304,7 @@ test "url.Query: unescape" { defer if (case.free) { allocator.free(value); }; - try testing.expectEqualStrings(case.expected, value); + try testing.expectEqual(case.expected, value); } try testing.expectError(error.EscapeError, unescape(undefined, "%")); @@ -346,21 +346,22 @@ test "url.Query: parseQuery" { } test "url.Query.Values: get/first/count" { - var values = Values.init(testing.allocator); - defer values.deinit(); + defer testing.reset(); + const arena = testing.arena_allocator; + var values = Values{}; { // empty try testing.expectEqual(0, values.count()); try testing.expectEqual(0, values.get("").len); - try testing.expectEqualStrings("", values.first("")); + try testing.expectEqual("", values.first("")); try testing.expectEqual(0, values.get("key").len); - try testing.expectEqualStrings("", values.first("key")); + try testing.expectEqual("", values.first("key")); } { // add 1 value => key - try values.appendOwned("key", "value"); + try values.append(arena, "key", "value"); try testing.expectEqual(1, values.count()); try testing.expectEqual(1, values.get("key").len); try testing.expectEqualSlices( @@ -368,12 +369,12 @@ test "url.Query.Values: get/first/count" { &.{"value"}, values.get("key"), ); - try testing.expectEqualStrings("value", values.first("key")); + try testing.expectEqual("value", values.first("key")); } { // add another value for the same key - try values.appendOwned("key", "another"); + try values.append(arena, "key", "another"); try testing.expectEqual(1, values.count()); try testing.expectEqual(2, values.get("key").len); try testing.expectEqualSlices( @@ -381,12 +382,12 @@ test "url.Query.Values: get/first/count" { &.{ "value", "another" }, values.get("key"), ); - try testing.expectEqualStrings("value", values.first("key")); + try testing.expectEqual("value", values.first("key")); } { // add a new key (and value) - try values.appendOwned("over", "9000!"); + try values.append(arena, "over", "9000!"); try testing.expectEqual(2, values.count()); try testing.expectEqual(2, values.get("key").len); try testing.expectEqual(1, values.get("over").len); @@ -395,7 +396,20 @@ test "url.Query.Values: get/first/count" { &.{"9000!"}, values.get("over"), ); - try testing.expectEqualStrings("9000!", values.first("over")); + try testing.expectEqual("9000!", values.first("over")); + } + + { + // set (override) + try values.append(arena, "key", "9000!"); + try testing.expectEqual(1, values.count()); + try testing.expectEqual(1, values.get("key").len); + try testing.expectEqualSlices( + []const u8, + &.{"9000!"}, + values.get("key"), + ); + try testing.expectEqual("9000!", values.first("key")); } } @@ -409,7 +423,7 @@ test "url.Query.Values: encode" { var buf: std.ArrayListUnmanaged(u8) = .{}; defer buf.deinit(testing.allocator); try values.encode(buf.writer(testing.allocator)); - try testing.expectEqualStrings( + try testing.expectEqual( "hello=world&i%20will%20not%20fear=%3E%3E&a=b&a=c", buf.items, ); @@ -425,7 +439,7 @@ fn testParseQuery(expected: anytype, query: []const u8) !void { const expect = @field(expected, f.name); try testing.expectEqual(expect.len, actual.len); for (expect, actual) |e, a| { - try testing.expectEqualStrings(e, a); + try testing.expectEqual(e, a); } count += 1; } diff --git a/src/browser/url/url.zig b/src/browser/url/url.zig index a33ac2a3..000967cf 100644 --- a/src/browser/url/url.zig +++ b/src/browser/url/url.zig @@ -17,13 +17,20 @@ // along with this program. If not, see . const std = @import("std"); -const Page = @import("../page.zig").Page; +const Allocator = std.mem.Allocator; -const query = @import("query.zig"); +const Page = @import("../page.zig").Page; +const FormData = @import("../xhr/form_data.zig").FormData; + +const kv = @import("../key_value.zig"); +const iterator = @import("../iterator/iterator.zig"); pub const Interfaces = .{ URL, URLSearchParams, + KeyIterable, + ValueIterable, + EntryIterable, }; // https://url.spec.whatwg.org/#url @@ -61,7 +68,7 @@ pub const URL = struct { return init(arena, uri); } - pub fn init(arena: std.mem.Allocator, uri: std.Uri) !URL { + pub fn init(arena: Allocator, uri: std.Uri) !URL { return .{ .uri = uri, .search_params = try URLSearchParams.init( @@ -93,15 +100,15 @@ pub const URL = struct { const cur = self.uri.query; defer self.uri.query = cur; var q = std.ArrayList(u8).init(arena); - try self.search_params.values.encode(q.writer()); + try self.search_params.encode(q.writer()); self.uri.query = .{ .percent_encoded = q.items }; return try self.toString(arena); } // format the url with all its components. - pub fn toString(self: *URL, arena: std.mem.Allocator) ![]const u8 { - var buf = std.ArrayList(u8).init(arena); + pub fn toString(self: *URL, arena: Allocator) ![]const u8 { + var buf: std.ArrayListUnmanaged(u8) = .empty; try self.uri.writeToStream(.{ .scheme = true, @@ -110,7 +117,8 @@ pub const URL = struct { .path = uriComponentNullStr(self.uri.path).len > 0, .query = uriComponentNullStr(self.uri.query).len > 0, .fragment = uriComponentNullStr(self.uri.fragment).len > 0, - }, buf.writer()); + }, buf.writer(arena)); + return buf.items; } @@ -165,7 +173,7 @@ pub const URL = struct { var buf: std.ArrayListUnmanaged(u8) = .{}; try buf.append(arena, '?'); - try self.search_params.values.encode(buf.writer(arena)); + try self.search_params.encode(buf.writer(arena)); return buf.items; } @@ -201,47 +209,227 @@ fn uriComponentStr(c: std.Uri.Component) []const u8 { } // https://url.spec.whatwg.org/#interface-urlsearchparams -// TODO array like pub const URLSearchParams = struct { - values: query.Values, + entries: kv.List, - pub fn constructor(qs: ?[]const u8, page: *Page) !URLSearchParams { - return init(page.arena, qs); - } - - pub fn init(arena: std.mem.Allocator, qs: ?[]const u8) !URLSearchParams { - return .{ - .values = try query.parseQuery(arena, qs orelse ""), + const URLSearchParamsOpts = union(enum) { + qs: []const u8, + form_data: *const FormData, + }; + pub fn constructor(opts_: ?URLSearchParamsOpts, page: *Page) !URLSearchParams { + const opts = opts_ orelse return .{ .entries = .{} }; + return switch (opts) { + .qs => |qs| init(page.arena, qs), + .form_data => |fd| .{ .entries = try fd.entries.clone(page.arena) }, }; } - pub fn get_size(self: *URLSearchParams) u32 { - return @intCast(self.values.count()); + pub fn init(arena: Allocator, qs_: ?[]const u8) !URLSearchParams { + return .{ + .entries = if (qs_) |qs| try parseQuery(arena, qs) else .{}, + }; } - pub fn _append(self: *URLSearchParams, name: []const u8, value: []const u8) !void { - try self.values.append(name, value); + pub fn get_size(self: *const URLSearchParams) u32 { + return @intCast(self.entries.count()); } - pub fn _delete(self: *URLSearchParams, name: []const u8, value: ?[]const u8) !void { - if (value) |v| return self.values.deleteValue(name, v); - - self.values.delete(name); + pub fn _append(self: *URLSearchParams, name: []const u8, value: []const u8, page: *Page) !void { + return self.entries.append(page.arena, name, value); } - pub fn _get(self: *URLSearchParams, name: []const u8) ?[]const u8 { - return self.values.first(name); + pub fn _set(self: *URLSearchParams, name: []const u8, value: []const u8, page: *Page) !void { + return self.entries.set(page.arena, name, value); } - // TODO return generates an error: caught unexpected error 'TypeLookup' - // pub fn _getAll(self: *URLSearchParams, name: []const u8) [][]const u8 { - // try self.values.get(name); - // } + pub fn _delete(self: *URLSearchParams, name: []const u8, value_: ?[]const u8) void { + if (value_) |value| { + return self.entries.deleteKeyValue(name, value); + } + return self.entries.delete(name); + } + + pub fn _get(self: *const URLSearchParams, name: []const u8) ?[]const u8 { + return self.entries.get(name); + } + + pub fn _getAll(self: *const URLSearchParams, name: []const u8, page: *Page) ![]const []const u8 { + return self.entries.getAll(page.call_arena, name); + } + + pub fn _has(self: *const URLSearchParams, name: []const u8) bool { + return self.entries.has(name); + } + + pub fn _keys(self: *const URLSearchParams) KeyIterable { + return .{ .inner = self.entries.keyIterator() }; + } + + pub fn _values(self: *const URLSearchParams) ValueIterable { + return .{ .inner = self.entries.valueIterator() }; + } + + pub fn _entries(self: *const URLSearchParams) EntryIterable { + return .{ .inner = self.entries.entryIterator() }; + } + + pub fn _symbol_iterator(self: *const URLSearchParams) EntryIterable { + return self._entries(); + } + + fn _toString(self: *const URLSearchParams, page: *Page) ![]const u8 { + var arr: std.ArrayListUnmanaged(u8) = .empty; + try kv.urlEncode(self.entries, .query, arr.writer(page.call_arena)); + return arr.items; + } // TODO pub fn _sort(_: *URLSearchParams) void {} + + fn encode(self: *const URLSearchParams, writer: anytype) !void { + return kv.urlEncode(self.entries, .query, writer); + } }; +// Parse the given query. +fn parseQuery(arena: Allocator, s: []const u8) !kv.List { + var list = kv.List{}; + + const ln = s.len; + if (ln == 0) { + return list; + } + + var query = if (s[0] == '?') s[1..] else s; + while (query.len > 0) { + const i = std.mem.indexOfScalarPos(u8, query, 0, '=') orelse query.len; + const name = query[0..i]; + + var value: ?[]const u8 = null; + if (i < query.len) { + query = query[i + 1 ..]; + const j = std.mem.indexOfScalarPos(u8, query, 0, '&') orelse query.len; + value = query[0..j]; + + query = if (j < query.len) query[j + 1 ..] else ""; + } else { + query = ""; + } + + try list.appendOwned( + arena, + try unescape(arena, name), + if (value) |v| try unescape(arena, v) else "", + ); + } + + return list; +} + +fn unescape(arena: Allocator, input: []const u8) ![]const u8 { + const HEX_CHAR = comptime blk: { + var all = std.mem.zeroes([256]bool); + for ('a'..('f' + 1)) |b| all[b] = true; + for ('A'..('F' + 1)) |b| all[b] = true; + for ('0'..('9' + 1)) |b| all[b] = true; + break :blk all; + }; + + const HEX_DECODE = comptime blk: { + var all = std.mem.zeroes([256]u8); + for ('a'..('z' + 1)) |b| all[b] = b - 'a' + 10; + for ('A'..('Z' + 1)) |b| all[b] = b - 'A' + 10; + for ('0'..('9' + 1)) |b| all[b] = b - '0'; + break :blk all; + }; + + var has_plus = false; + var unescaped_len = input.len; + + { + // Figure out if we have any spaces and what the final unescaped length + // will be (which will let us know if we have anything to unescape in + // the first place) + var i: usize = 0; + while (i < input.len) { + const c = input[i]; + if (c == '%') { + if (i + 2 >= input.len or !HEX_CHAR[input[i + 1]] or !HEX_CHAR[input[i + 2]]) { + return error.EscapeError; + } + i += 3; + unescaped_len -= 2; + } else if (c == '+') { + has_plus = true; + i += 1; + } else { + i += 1; + } + } + } + + // no encoding, and no plus. nothing to unescape + if (unescaped_len == input.len and has_plus == false) { + // we always dupe, because we know our caller wants it always duped. + return arena.dupe(u8, input); + } + + var unescaped = try arena.alloc(u8, unescaped_len); + errdefer arena.free(unescaped); + + var input_pos: usize = 0; + for (0..unescaped_len) |unescaped_pos| { + switch (input[input_pos]) { + '+' => { + unescaped[unescaped_pos] = ' '; + input_pos += 1; + }, + '%' => { + const encoded = input[input_pos + 1 .. input_pos + 3]; + const encoded_as_uint = @as(u16, @bitCast(encoded[0..2].*)); + unescaped[unescaped_pos] = switch (encoded_as_uint) { + asUint(u16, "20") => ' ', + asUint(u16, "21") => '!', + asUint(u16, "22") => '"', + asUint(u16, "23") => '#', + asUint(u16, "24") => '$', + asUint(u16, "25") => '%', + asUint(u16, "26") => '&', + asUint(u16, "27") => '\'', + asUint(u16, "28") => '(', + asUint(u16, "29") => ')', + asUint(u16, "2A") => '*', + asUint(u16, "2B") => '+', + asUint(u16, "2C") => ',', + asUint(u16, "2F") => '/', + asUint(u16, "3A") => ':', + asUint(u16, "3B") => ';', + asUint(u16, "3D") => '=', + asUint(u16, "3F") => '?', + asUint(u16, "40") => '@', + asUint(u16, "5B") => '[', + asUint(u16, "5D") => ']', + else => HEX_DECODE[encoded[0]] << 4 | HEX_DECODE[encoded[1]], + }; + input_pos += 3; + }, + else => |c| { + unescaped[unescaped_pos] = c; + input_pos += 1; + }, + } + } + return unescaped; +} + +fn asUint(comptime T: type, comptime string: []const u8) T { + return @bitCast(string[0..string.len].*); +} + +const KeyIterable = iterator.Iterable(kv.KeyIterator, "URLSearchParamsKeyIterator"); +const ValueIterable = iterator.Iterable(kv.ValueIterator, "URLSearchParamsValueIterator"); +const EntryIterable = iterator.Iterable(kv.EntryIterator, "URLSearchParamsEntryIterator"); + const testing = @import("../../testing.zig"); test "Browser.URL" { var runner = try testing.jsRunner(testing.tracking_allocator, .{}); @@ -269,17 +457,19 @@ test "Browser.URL" { .{ "url.searchParams.get('b')", "~" }, .{ "url.searchParams.append('c', 'foo')", "undefined" }, .{ "url.searchParams.get('c')", "foo" }, + .{ "url.searchParams.getAll('c').length", "1" }, + .{ "url.searchParams.getAll('c')[0]", "foo" }, .{ "url.searchParams.size", "3" }, // search is dynamic - .{ "url.search", "?a=%7E&b=%7E&c=foo" }, + .{ "url.search", "?a=~&b=~&c=foo" }, // href is dynamic - .{ "url.href", "https://foo.bar/path?a=%7E&b=%7E&c=foo#fragment" }, + .{ "url.href", "https://foo.bar/path?a=~&b=~&c=foo#fragment" }, .{ "url.searchParams.delete('c', 'foo')", "undefined" }, - .{ "url.searchParams.get('c')", "" }, + .{ "url.searchParams.get('c')", "null" }, .{ "url.searchParams.delete('a')", "undefined" }, - .{ "url.searchParams.get('a')", "" }, + .{ "url.searchParams.get('a')", "null" }, }, .{}); try runner.testCases(&.{ @@ -287,3 +477,84 @@ test "Browser.URL" { .{ "url.href", "https://lightpanda.io/over?9000" }, }, .{}); } + +test "Browser.URLSearchParams" { + var runner = try testing.jsRunner(testing.tracking_allocator, .{}); + defer runner.deinit(); + try runner.testCases(&.{ + .{ "let usp = new URLSearchParams()", null }, + .{ "usp.get('a')", "null" }, + .{ "usp.has('a')", "false" }, + .{ "usp.getAll('a')", "" }, + .{ "usp.delete('a')", "undefined" }, + + .{ "usp.set('a', 1)", "undefined" }, + .{ "usp.has('a')", "true" }, + .{ "usp.get('a')", "1" }, + .{ "usp.getAll('a')", "1" }, + + .{ "usp.append('a', 2)", "undefined" }, + .{ "usp.has('a')", "true" }, + .{ "usp.get('a')", "1" }, + .{ "usp.getAll('a')", "1,2" }, + + .{ "usp.append('b', '3')", "undefined" }, + .{ "usp.has('a')", "true" }, + .{ "usp.get('a')", "1" }, + .{ "usp.getAll('a')", "1,2" }, + .{ "usp.has('b')", "true" }, + .{ "usp.get('b')", "3" }, + .{ "usp.getAll('b')", "3" }, + + .{ "let acc = [];", null }, + .{ "for (const key of usp.keys()) { acc.push(key) }; acc;", "a,a,b" }, + + .{ "acc = [];", null }, + .{ "for (const value of usp.values()) { acc.push(value) }; acc;", "1,2,3" }, + + .{ "acc = [];", null }, + .{ "for (const entry of usp.entries()) { acc.push(entry) }; acc;", "a,1,a,2,b,3" }, + + .{ "acc = [];", null }, + .{ "for (const entry of usp) { acc.push(entry) }; acc;", "a,1,a,2,b,3" }, + + .{ "usp.delete('a')", "undefined" }, + .{ "usp.has('a')", "false" }, + .{ "usp.has('b')", "true" }, + + .{ "acc = [];", null }, + .{ "for (const key of usp.keys()) { acc.push(key) }; acc;", "b" }, + + .{ "acc = [];", null }, + .{ "for (const value of usp.values()) { acc.push(value) }; acc;", "3" }, + + .{ "acc = [];", null }, + .{ "for (const entry of usp.entries()) { acc.push(entry) }; acc;", "b,3" }, + + .{ "acc = [];", null }, + .{ "for (const entry of usp) { acc.push(entry) }; acc;", "b,3" }, + }, .{}); + + try runner.testCases(&.{ + .{ "usp = new URLSearchParams('?hello')", null }, + .{ "usp.get('hello')", "" }, + + .{ "usp = new URLSearchParams('?abc=')", null }, + .{ "usp.get('abc')", "" }, + + .{ "usp = new URLSearchParams('?abc=123&')", null }, + .{ "usp.get('abc')", "123" }, + .{ "usp.size", "1" }, + + .{ "var fd = new FormData()", null }, + .{ "fd.append('a', '1')", null }, + .{ "fd.append('a', '2')", null }, + .{ "fd.append('b', '3')", null }, + .{ "ups = new URLSearchParams(fd)", null }, + .{ "ups.size", "3" }, + .{ "ups.getAll('a')", "1,2" }, + .{ "ups.getAll('b')", "3" }, + .{ "fd.delete('a')", null }, // the two aren't linked, it created a copy + .{ "ups.size", "3" }, + }, .{}); +} diff --git a/src/browser/xhr/form_data.zig b/src/browser/xhr/form_data.zig index 25f31bb5..18e83066 100644 --- a/src/browser/xhr/form_data.zig +++ b/src/browser/xhr/form_data.zig @@ -22,8 +22,9 @@ const Allocator = std.mem.Allocator; const log = @import("../../log.zig"); const parser = @import("../netsurf.zig"); -const iterator = @import("../iterator/iterator.zig"); const Page = @import("../page.zig").Page; +const kv = @import("../key_value.zig"); +const iterator = @import("../iterator/iterator.zig"); pub const Interfaces = .{ FormData, @@ -32,29 +33,12 @@ pub const Interfaces = .{ EntryIterable, }; -// We store the values in an ArrayList rather than a an -// StringArrayHashMap([]const u8) because of the way the iterators (i.e., keys(), -// values() and entries()) work. The FormData can contain duplicate keys, and -// each iteration yields 1 key=>value pair. So, given: -// -// let f = new FormData(); -// f.append('a', '1'); -// f.append('a', '2'); -// -// Then we'd expect f.keys(), f.values() and f.entries() to yield 2 results: -// ['a', '1'] -// ['a', '2'] -// -// This is much easier to do with an ArrayList than a HashMap, especially given -// that the FormData could be mutated while iterating. -// The downside is that most of the normal operations are O(N). - // https://xhr.spec.whatwg.org/#interface-formdata pub const FormData = struct { - entries: Entry.List, + entries: kv.List, pub fn constructor(form_: ?*parser.Form, submitter_: ?*parser.ElementHTML, page: *Page) !FormData { - const form = form_ orelse return .{ .entries = .empty }; + const form = form_ orelse return .{ .entries = .{} }; return fromForm(form, submitter_, page); } @@ -64,208 +48,77 @@ pub const FormData = struct { } pub fn _get(self: *const FormData, key: []const u8) ?[]const u8 { - const result = self.find(key) orelse return null; - return result.entry.value; + return self.entries.get(key); } - pub fn _getAll(self: *const FormData, key: []const u8, page: *Page) ![][]const u8 { - const arena = page.call_arena; - var arr: std.ArrayListUnmanaged([]const u8) = .empty; - for (self.entries.items) |entry| { - if (std.mem.eql(u8, key, entry.key)) { - try arr.append(arena, entry.value); - } - } - return arr.items; + pub fn _getAll(self: *const FormData, key: []const u8, page: *Page) ![]const []const u8 { + return self.entries.getAll(page.call_arena, key); } pub fn _has(self: *const FormData, key: []const u8) bool { - return self.find(key) != null; + return self.entries.has(key); } // TODO: value should be a string or blog // TODO: another optional parameter for the filename pub fn _set(self: *FormData, key: []const u8, value: []const u8, page: *Page) !void { - self._delete(key); - return self._append(key, value, page); + return self.entries.set(page.arena, key, value); } // TODO: value should be a string or blog // TODO: another optional parameter for the filename pub fn _append(self: *FormData, key: []const u8, value: []const u8, page: *Page) !void { - const arena = page.arena; - return self.entries.append(arena, .{ .key = try arena.dupe(u8, key), .value = try arena.dupe(u8, value) }); + return self.entries.append(page.arena, key, value); } pub fn _delete(self: *FormData, key: []const u8) void { - var i: usize = 0; - while (i < self.entries.items.len) { - const entry = self.entries.items[i]; - if (std.mem.eql(u8, key, entry.key)) { - _ = self.entries.swapRemove(i); - } else { - i += 1; - } - } + return self.entries.delete(key); } pub fn _keys(self: *const FormData) KeyIterable { - return .{ .inner = .{ .entries = &self.entries } }; + return .{ .inner = self.entries.keyIterator() }; } pub fn _values(self: *const FormData) ValueIterable { - return .{ .inner = .{ .entries = &self.entries } }; + return .{ .inner = self.entries.valueIterator() }; } pub fn _entries(self: *const FormData) EntryIterable { - return .{ .inner = .{ .entries = &self.entries } }; + return .{ .inner = self.entries.entryIterator() }; } pub fn _symbol_iterator(self: *const FormData) EntryIterable { return self._entries(); } - const FindResult = struct { - index: usize, - entry: Entry, - }; - - fn find(self: *const FormData, key: []const u8) ?FindResult { - for (self.entries.items, 0..) |entry, i| { - if (std.mem.eql(u8, key, entry.key)) { - return .{ .index = i, .entry = entry }; - } - } - return null; - } - pub fn write(self: *const FormData, encoding_: ?[]const u8, writer: anytype) !void { const encoding = encoding_ orelse { - return urlEncode(self, writer); + return kv.urlEncode(self.entries, .form, writer); }; if (std.ascii.eqlIgnoreCase(encoding, "application/x-www-form-urlencoded")) { - return urlEncode(self, writer); + return kv.urlEncode(self.entries, .form, writer); } - log.warn(.web_api, "not implemented", .{ .feature = "form data encoding", .encoding = encoding }); + log.warn(.web_api, "not implemented", .{ + .feature = "form data encoding", + .encoding = encoding, + }); return error.EncodingNotSupported; } }; -fn urlEncode(data: *const FormData, writer: anytype) !void { - const entries = data.entries.items; - if (entries.len == 0) { - return; - } - - try urlEncodeEntry(entries[0], writer); - for (entries[1..]) |entry| { - try writer.writeByte('&'); - try urlEncodeEntry(entry, writer); - } -} - -fn urlEncodeEntry(entry: Entry, writer: anytype) !void { - try urlEncodeValue(entry.key, writer); - try writer.writeByte('='); - try urlEncodeValue(entry.value, writer); -} - -fn urlEncodeValue(value: []const u8, writer: anytype) !void { - if (!urlEncodeShouldEscape(value)) { - return writer.writeAll(value); - } - - for (value) |b| { - if (urlEncodeUnreserved(b)) { - try writer.writeByte(b); - } else if (b == ' ') { - // for form submission, space should be encoded as '+', not '%20' - try writer.writeByte('+'); - } else { - try writer.print("%{X:0>2}", .{b}); - } - } -} - -fn urlEncodeShouldEscape(value: []const u8) bool { - for (value) |b| { - if (!urlEncodeUnreserved(b)) { - return true; - } - } - return false; -} - -fn urlEncodeUnreserved(b: u8) bool { - return switch (b) { - 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true, - else => false, - }; -} - -const Entry = struct { - key: []const u8, - value: []const u8, - - pub const List = std.ArrayListUnmanaged(Entry); -}; - -const KeyIterable = iterator.Iterable(KeyIterator, "FormDataKeyIterator"); -const ValueIterable = iterator.Iterable(ValueIterator, "FormDataValueIterator"); -const EntryIterable = iterator.Iterable(EntryIterator, "FormDataEntryIterator"); - -const KeyIterator = struct { - index: usize = 0, - entries: *const Entry.List, - - pub fn _next(self: *KeyIterator) ?[]const u8 { - const index = self.index; - if (index == self.entries.items.len) { - return null; - } - self.index += 1; - return self.entries.items[index].key; - } -}; - -const ValueIterator = struct { - index: usize = 0, - entries: *const Entry.List, - - pub fn _next(self: *ValueIterator) ?[]const u8 { - const index = self.index; - if (index == self.entries.items.len) { - return null; - } - self.index += 1; - return self.entries.items[index].value; - } -}; - -const EntryIterator = struct { - index: usize = 0, - entries: *const Entry.List, - - pub fn _next(self: *EntryIterator) ?struct { []const u8, []const u8 } { - const index = self.index; - if (index == self.entries.items.len) { - return null; - } - self.index += 1; - const entry = self.entries.items[index]; - return .{ entry.key, entry.value }; - } -}; +const KeyIterable = iterator.Iterable(kv.KeyIterator, "FormDataKeyIterator"); +const ValueIterable = iterator.Iterable(kv.ValueIterator, "FormDataValueIterator"); +const EntryIterable = iterator.Iterable(kv.EntryIterator, "FormDataEntryIterator"); // TODO: handle disabled fieldsets -fn collectForm(form: *parser.Form, submitter_: ?*parser.ElementHTML, page: *Page) !Entry.List { +fn collectForm(form: *parser.Form, submitter_: ?*parser.ElementHTML, page: *Page) !kv.List { const arena = page.arena; const collection = try parser.formGetCollection(form); const len = try parser.htmlCollectionGetLength(collection); - var entries: Entry.List = .empty; + var entries: kv.List = .{}; try entries.ensureTotalCapacity(arena, len); var submitter_included = false; @@ -288,15 +141,10 @@ fn collectForm(form: *parser.Form, submitter_: ?*parser.ElementHTML, page: *Page if (std.ascii.eqlIgnoreCase(tpe, "image")) { if (submitter_name_) |submitter_name| { if (std.mem.eql(u8, submitter_name, name)) { - try entries.append(arena, .{ - .key = try std.fmt.allocPrint(arena, "{s}.x", .{name}), - .value = "0", - }); - try entries.append(arena, .{ - .key = try std.fmt.allocPrint(arena, "{s}.y", .{name}), - .value = "0", - }); - + const key_x = try std.fmt.allocPrint(arena, "{s}.x", .{name}); + const key_y = try std.fmt.allocPrint(arena, "{s}.y", .{name}); + try entries.appendOwned(arena, key_x, "0"); + try entries.appendOwned(arena, key_y, "0"); submitter_included = true; } } @@ -315,7 +163,7 @@ fn collectForm(form: *parser.Form, submitter_: ?*parser.ElementHTML, page: *Page submitter_included = true; } const value = (try parser.elementGetAttribute(element, "value")) orelse ""; - try entries.append(arena, .{ .key = name, .value = value }); + try entries.appendOwned(arena, name, value); }, .select => { const select: *parser.Select = @ptrCast(node); @@ -324,12 +172,12 @@ fn collectForm(form: *parser.Form, submitter_: ?*parser.ElementHTML, page: *Page .textarea => { const textarea: *parser.TextArea = @ptrCast(node); const value = try parser.textareaGetValue(textarea); - try entries.append(arena, .{ .key = name, .value = value }); + try entries.appendOwned(arena, name, value); }, .button => if (submitter_name_) |submitter_name| { if (std.mem.eql(u8, submitter_name, name)) { const value = (try parser.elementGetAttribute(element, "value")) orelse ""; - try entries.append(arena, .{ .key = name, .value = value }); + try entries.appendOwned(arena, name, value); submitter_included = true; } }, @@ -345,14 +193,14 @@ fn collectForm(form: *parser.Form, submitter_: ?*parser.ElementHTML, page: *Page // this can happen if the submitter is outside the form, but associated // with the form via a form=ID attribute const value = (try parser.elementGetAttribute(@ptrCast(submitter), "value")) orelse ""; - try entries.append(arena, .{ .key = submitter_name_.?, .value = value }); + try entries.appendOwned(arena, submitter_name_.?, value); } } return entries; } -fn collectSelectValues(arena: Allocator, select: *parser.Select, name: []const u8, entries: *Entry.List, page: *Page) !void { +fn collectSelectValues(arena: Allocator, select: *parser.Select, name: []const u8, entries: *kv.List, page: *Page) !void { const HTMLSelectElement = @import("../html/select.zig").HTMLSelectElement; // Go through the HTMLSelectElement because it has specific logic for handling @@ -372,7 +220,7 @@ fn collectSelectValues(arena: Allocator, select: *parser.Select, name: []const u return; } const value = try parser.optionGetValue(option); - return entries.append(arena, .{ .key = name, .value = value }); + return entries.appendOwned(arena, name, value); } const len = try parser.optionCollectionGetLength(options); @@ -386,7 +234,7 @@ fn collectSelectValues(arena: Allocator, select: *parser.Select, name: []const u if (try parser.optionGetSelected(option)) { const value = try parser.optionGetValue(option); - try entries.append(arena, .{ .key = name, .value = value }); + try entries.appendOwned(arena, name, value); } } } @@ -420,7 +268,7 @@ test "Browser.FormData" { \\ \\ \\ - \\ + \\ \\ \\ \\ @@ -518,7 +366,7 @@ test "Browser.FormData" { \\ acc.slice(0, -1) , \\txt-1=txt-1-v - \\txt-2=txt-2-v + \\txt-2=txt-~-v \\chk-3=chk-3-vb \\chk-3=chk-3-vc \\rdi-1=rdi-1-vc @@ -539,7 +387,7 @@ test "Browser.FormData: urlEncode" { defer arr.deinit(testing.allocator); { - var fd = FormData{ .entries = .empty }; + var fd = FormData{ .entries = .{} }; try testing.expectError(error.EncodingNotSupported, fd.write("unknown", arr.writer(testing.allocator))); try fd.write(null, arr.writer(testing.allocator)); @@ -550,12 +398,12 @@ test "Browser.FormData: urlEncode" { } { - var fd = FormData{ .entries = Entry.List.fromOwnedSlice(@constCast(&[_]Entry{ + var fd = FormData{ .entries = kv.List.fromOwnedSlice(@constCast(&[_]kv.KeyValue{ .{ .key = "a", .value = "1" }, .{ .key = "it's over", .value = "9000 !!!" }, - .{ .key = "emot", .value = "ok: ☺" }, + .{ .key = "em~ot", .value = "ok: ☺" }, })) }; - const expected = "a=1&it%27s+over=9000+%21%21%21&emot=ok%3A+%E2%98%BA"; + const expected = "a=1&it%27s+over=9000+%21%21%21&em%7Eot=ok%3A+%E2%98%BA"; try fd.write(null, arr.writer(testing.allocator)); try testing.expectEqual(expected, arr.items);