From 07f7366d73eb1056df1c4c2f9d10dc6970d92be5 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Thu, 9 Jul 2026 17:49:57 +0300 Subject: [PATCH 01/23] `FormData`: add `parseUrlEncoded` Introduces another url decoder also, we may like to unite these at some point. --- src/browser/webapi/net/FormData.zig | 81 +++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/src/browser/webapi/net/FormData.zig b/src/browser/webapi/net/FormData.zig index 6335ba068..9623f2719 100644 --- a/src/browser/webapi/net/FormData.zig +++ b/src/browser/webapi/net/FormData.zig @@ -388,6 +388,87 @@ fn writeMultipartName(writer: *std.Io.Writer, name: []const u8) !void { } } +// Inverse of urlEncode: application/x-www-form-urlencoded parsing per +// URL §5.1 — '+' decodes to a space, invalid percent sequences pass through +// verbatim, and a pair without '=' becomes an entry with an empty value. +pub fn parseUrlEncoded(self: *FormData, bytes: []const u8) !void { + var it = std.mem.splitScalar(u8, bytes, '&'); + while (it.next()) |pair| { + if (pair.len == 0) { + continue; + } + if (std.mem.indexOfScalar(u8, pair, '=')) |idx| { + try self.append( + try formUrlDecode(self._arena, pair[0..idx]), + try formUrlDecode(self._arena, pair[idx + 1 ..]), + ); + } else { + const key = try formUrlDecode(self._arena, pair); + // Insert with empty value. + try self.append(key, ""); + } + } +} + +/// Index of the first byte needing URL-decoding ('%' or '+'), or null if +/// the slice needs no decoding at all. +fn indexOfSpecial(slice: []const u8) ?usize { + const vector_len = std.simd.suggestVectorLength(u8) orelse { + // Non-SIMD path. + return std.mem.indexOfAnyPos(u8, slice, 0, "%+"); + }; + const Vector = @Vector(vector_len, u8); + + var end: usize = 0; + while (end + vector_len <= slice.len) : (end += vector_len) { + const percent: Vector = @splat('%'); + const plus: Vector = @splat('+'); + const chunk: Vector = slice[end..][0..vector_len].*; + + const mask = @intFromBool(chunk == percent) | @intFromBool(chunk == plus); + const mask_int = @as(std.meta.Int(.unsigned, vector_len), @bitCast(mask)); + + if (mask_int != 0) { + return end + @ctz(mask_int); + } + } + + return std.mem.indexOfAnyPos(u8, slice, end, "%+"); +} + +/// URL-decodes passed `raw` slice; returned value may or may not be heap allocated. +fn formUrlDecode(arena: Allocator, raw: []const u8) ![]const u8 { + const start = indexOfSpecial(raw) orelse return raw; + + var out: std.ArrayList(u8) = try .initCapacity(arena, raw.len); + out.appendSliceAssumeCapacity(raw[0..start]); + + var i: usize = start; + while (i < raw.len) : (i += 1) { + const c = raw[i]; + switch (c) { + '+' => out.appendAssumeCapacity(' '), + '%' => { + const decoded: ?u8 = blk: { + if (i + 2 >= raw.len) break :blk null; + const hi = std.fmt.charToDigit(raw[i + 1], 16) catch break :blk null; + const lo = std.fmt.charToDigit(raw[i + 2], 16) catch break :blk null; + break :blk hi * 16 + lo; + }; + if (decoded) |b| { + out.appendAssumeCapacity(b); + i += 2; + } else { + out.appendAssumeCapacity('%'); + } + }, + else => out.appendAssumeCapacity(c), + } + } + + return out.items; +} + // Used by URLSearchParams to ingest a FormData; file entries collapse via Value.asString. pub fn toKeyValueList(self: *const FormData, arena: Allocator) !KeyValueList { var list: KeyValueList = .empty; From e3fbd745a7330b9bcaf84b62c851b9d2381c6757 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 15 Jul 2026 14:19:29 +0300 Subject: [PATCH 02/23] introduce `simd.zig` Idea is to have reusable parsing utilities here; not sure on the name, we can surely go for something else. --- src/simd.zig | 508 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 508 insertions(+) create mode 100644 src/simd.zig diff --git a/src/simd.zig b/src/simd.zig new file mode 100644 index 000000000..356796018 --- /dev/null +++ b/src/simd.zig @@ -0,0 +1,508 @@ +// Copyright (C) 2023-2026 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 . + +//! We have multiple places that are in need of same parsing requirements +//! with same efficiency. This file tries to unite them under here. + +const std = @import("std"); +const builtin = @import("builtin"); + +/// Block size of the CPU. +const block_size = @sizeOf(usize); + +const header_key_map = [256]u1{ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0-15 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16-31 + 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 32-47 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, // 48-63 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 64-79 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 80-95 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 96-111 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, // 112-127 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 128-143 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 144-159 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 160-175 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 176-191 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 192-207 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 208-223 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 224-239 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 240-255 +}; + +const header_value_map = [256]u1{ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0-15 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16-31 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 32-47 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 48-63 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 64-79 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 80-95 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 96-111 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, // 112-127 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 128-143 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 144-159 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 160-175 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 176-191 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 192-207 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 208-223 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 224-239 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 240-255 +}; + +/// Returns an integer filled with a given byte. +inline fn broadcast(comptime T: type, byte: u8) T { + comptime { + const bits = @ctz(@as(T, 0)); + const b = @as(T, byte); + return switch (bits) { + 8 => b * 0x01, + 16 => b * 0x01_01, + 32 => b * 0x01_01_01_01, + 64 => b * 0x01_01_01_01_01_01_01_01, + else => @compileError("unexpected broadcast size"), + }; + } +} + +/// Returns how much to move forward for the end of header key; the byte at +/// the returned index is either an invalid one or the `:` delimiter. If the +/// returned index is equal to `bytes.len`, the buffer has been consumed fully. +/// Force this to inline, there's merely a single call-site for it. +inline fn matchHeaderKey(bytes: []const u8) usize { + // How much we've moved forward. + var i: usize = 0; + + const use_vectors = if (std.simd.suggestVectorLength(u8)) |recommended| + recommended >= 16 + else + false; + + if (comptime use_vectors) { + // Pick a good default for relatively small strings. + const vec_size = 16; + const Vec = @Vector(vec_size, u8); + const Int = std.meta.Int(.unsigned, vec_size); + + while (bytes.len - i >= vec_size) { + const spaces: Vec = @splat(' '); + const colons: Vec = @splat(':'); + const deletes: Vec = @splat(0x7f); + const chunk: Vec = bytes[i..][0..vec_size].*; + + const bits = @intFromBool(chunk > spaces) & ~(@intFromBool(chunk == colons) | @intFromBool(chunk == deletes)); + // How much to move forward. + const advance_by = @ctz(~@as(Int, @bitCast(bits))); + i += advance_by; + // Found one of the characters we'd like to have. + if (advance_by != vec_size) { + return i; + } + } + } + + // NOTE: SWAR is not preferred here, this might change in the future + // but honestly header keys are not so large. + + // Fallback for len < sse_vec_size. + while (i < bytes.len) : (i += 1) { + if (header_key_map[bytes[i]] == 0) { + return i; + } + } + + return i; +} + +/// Returns how much to move forward for the end of header value; the byte at +/// the returned index is either an invalid one or a line ending. If the +/// returned index is equal to `bytes.len`, the buffer has been consumed fully. +/// Force this to inline, there's merely a single call-site for it. +inline fn matchHeaderValue(bytes: []const u8) usize { + // How much we've moved forward. + var i: usize = 0; + + const maybe_vec_size: ?usize = comptime blk: { + if (std.simd.suggestVectorLength(u8)) |recommended| { + break :blk if (recommended >= 64) 32 else recommended; + } + break :blk null; + }; + + // Unlike header keys, prefer larger vectors initially when validating + // header values if possible. + if (comptime maybe_vec_size) |vec_size| { + const Vec = @Vector(vec_size, u8); + const Int = std.meta.Int(.unsigned, vec_size); + + while (bytes.len - i >= vec_size) { + // Fill a vector with DEL (127). + const deletes: Vec = @splat(0x7f); + // Fill a vector with US (31). + const full_31: Vec = @splat(0x1f); + // Load the next chunk from the buffer. + const chunk: Vec = bytes[i..][0..vec_size].*; + + const bits = @intFromBool(chunk > full_31) & ~@intFromBool(chunk == deletes); + const advance_by = @ctz(~@as(Int, @bitCast(bits))); + i += advance_by; + + if (advance_by != vec_size) { + return i; + } + } + } + + // SWAR path. + while (bytes.len - i >= block_size) { + const spaces = comptime broadcast(usize, ' '); + const ones = comptime broadcast(usize, 0x01); + const dels = comptime broadcast(usize, 0x7f); + const full_128 = comptime broadcast(usize, 128); + const chunk: usize = @bitCast(bytes[i..][0..block_size].*); + + // When a byte is less than a space (32), subtraction will wrap around + // and set the high bit; the AND NOT makes sure only bytes below 128 + // can report so. + const lt = (chunk -% spaces) & ~chunk; + const xor_dels = chunk ^ dels; + const eq_del = (xor_dels -% ones) & ~xor_dels; + const advance_by = @ctz((lt | eq_del) & full_128) >> 3; + i += advance_by; + + if (advance_by != block_size) { + return i; + } + } + + // Fallback for len < block_size. + while (i < bytes.len) : (i += 1) { + if (header_value_map[bytes[i]] == 0) { + return i; + } + } + + return i; +} + +/// Represents a single HTTP header. +pub const HttpHeader = struct { + key: []const u8, + value: []const u8, +}; + +pub const ParseHttpHeaderError = error{ Incomplete, Invalid }; + +/// Parses a single HTTP header out of given buffer and returns how many bytes +/// are consumed, including the line ending. The buffer MAY or MAY NOT have a +/// line ending; in case it doesn't exist, `error.Incomplete` is returned. +/// Which allows streaming (or push-style) usage of this function. +pub fn parseHttpHeader(bytes: []const u8, header: *HttpHeader) ParseHttpHeaderError!usize { + var cursor = bytes; + + const key_end = matchHeaderKey(cursor); + // Buffer has been consumed fully without a delimiter; the caller can read + // more data and try to parse again. + if (key_end == cursor.len) { + return error.Incomplete; + } + + // Make sure we're at colon. + switch (cursor[key_end]) { + ':' => { + @branchHint(.likely); + // 0 length headers are invalid. + if (key_end == 0) { + return error.Invalid; + } + }, + // Invalid character, so a malformed header. Can't go further. + else => return error.Invalid, + } + + // Found header key. + const key = cursor[0..key_end]; + // Skip the key and the colon. + cursor = cursor[key_end + 1 ..]; + + // Skip leading whitespaces. + while (cursor.len > 0 and cursor[0] == ' ') : (cursor = cursor[1..]) {} + + // We're at where header value starts; find where it ends. + const value_end = matchHeaderValue(cursor); + // Buffer has been consumed fully without a line ending; the caller can + // read more data and try to parse again. + if (value_end == cursor.len) { + return error.Incomplete; + } + + // Found header value. + const value = cursor[0..value_end]; + + // Both `\n` and `\r\n` indicate the end of the value part. + switch (cursor[value_end]) { + '\n' => cursor = cursor[value_end + 1 ..], + '\r' => { + // We need a `\n` character too. + if (value_end + 1 == cursor.len) { + return error.Incomplete; + } + if (cursor[value_end + 1] != '\n') { + @branchHint(.unlikely); + return error.Invalid; + } + + cursor = cursor[value_end + 2 ..]; + }, + // Any other character is invalid. + else => return error.Invalid, + } + + // Header is set. + header.* = .{ .key = key, .value = value }; + + // Return the total consumed length to caller. + return bytes.len - cursor.len; +} + +/// Parses HTTP headers out of given buffer until the terminating empty line +/// (exclusive `\n` or `\r\n`), which is required; if the buffer ends before +/// it, `error.Incomplete` is returned. Returns how many bytes are consumed, +/// including the terminating line ending; `count` receives how many headers +/// are parsed. If the provided `headers` length is not sufficient, +/// `error.Invalid` is returned. +pub fn parseHttpHeaders(bytes: []const u8, headers: []HttpHeader, count: *usize) ParseHttpHeaderError!usize { + var cursor = bytes; + + var i: usize = 0; + while (true) { + // The terminating empty line is required; the caller can read more + // data and try to parse again. + if (cursor.len == 0) { + return error.Incomplete; + } + + // Check if headers part has finished. + switch (cursor[0]) { + '\n' => { + // End of headers. + cursor = cursor[1..]; + break; + }, + '\r' => { + // We need a `\n` character too. + if (cursor.len < 2) { + return error.Incomplete; + } + if (cursor[1] != '\n') { + return error.Invalid; + } + + // End of headers. + cursor = cursor[2..]; + break; + }, + else => {}, + } + + // Not enough space in `headers`. + // NOTE: Currently interpreted as `error.Invalid`, this might change in the future. + if (i == headers.len) { + return error.Invalid; + } + + const consumed = try parseHttpHeader(cursor, &headers[i]); + cursor = cursor[consumed..]; + i += 1; + } + + // Set the count of parsed headers. + count.* = i; + + // Return the total consumed length to caller. + return bytes.len - cursor.len; +} + +pub const Disposition = struct { + name: ?[]const u8 = null, + filename: ?[]const u8 = null, +}; + +// Parses a part's Content-Disposition value: `form-data` followed by +// `; key="value"` params. Values are quoted-strings whose CR/LF/" were +// percent-escaped by the encoder (writeMultipartName), so the next raw '"' +// always closes the value; a raw ';' inside quotes is legal and preserved. +pub fn parseDisposition(value: []const u8) !Disposition { + var rest = std.mem.trim(u8, value, " \t"); + if (!std.ascii.startsWithIgnoreCase(rest, "form-data")) { + return error.InvalidFormData; + } + rest = rest["form-data".len..]; + + var disposition = Disposition{}; + while (true) { + rest = std.mem.trimLeft(u8, rest, " \t"); + if (rest.len == 0) { + return disposition; + } + if (rest[0] != ';') { + return error.InvalidFormData; + } + rest = std.mem.trimLeft(u8, rest[1..], " \t"); + + const eq = std.mem.indexOfScalar(u8, rest, '=') orelse return error.InvalidFormData; + const key = rest[0..eq]; + rest = rest[eq + 1 ..]; + if (rest.len == 0 or rest[0] != '"') { + return error.InvalidFormData; + } + const end = std.mem.indexOfScalarPos(u8, rest, 1, '"') orelse return error.InvalidFormData; + const param_value = rest[1..end]; + rest = rest[end + 1 ..]; + + if (std.ascii.eqlIgnoreCase(key, "name")) { + disposition.name = param_value; + } else if (std.ascii.eqlIgnoreCase(key, "filename")) { + disposition.filename = param_value; + } + } +} + +const testing = @import("testing.zig"); +test "simd: parse HTTP header" { + const bytes = "Content-Disposition: attachment; filename*=UTF-8''file%20name.jpg\r\nrest"; + var header: HttpHeader = undefined; + const consumed = try parseHttpHeader(bytes, &header); + + try testing.expectEqual(bytes.len - "rest".len, consumed); + try testing.expectString("Content-Disposition", header.key); + try testing.expectString("attachment; filename*=UTF-8''file%20name.jpg", header.value); + + // Lone `\n` line endings are accepted too. + try testing.expectEqual(10, try parseHttpHeader("Host: abc\nHost: def\n", &header)); + try testing.expectString("Host", header.key); + try testing.expectString("abc", header.value); + + // Leading whitespaces of the value are skipped, trailing ones are kept. + _ = try parseHttpHeader("Key: value \r\n", &header); + try testing.expectString("value ", header.value); + + // 0 length values are fine. + _ = try parseHttpHeader("Key:\r\n", &header); + try testing.expectString("Key", header.key); + try testing.expectString("", header.value); +} + +test "simd: parse HTTP header incomplete" { + var header: HttpHeader = undefined; + + // Buffer may end anywhere before the line ending is complete. + const bytes = "Content-Type: text/plain\r\n"; + for (0..bytes.len) |len| { + try testing.expectError(error.Incomplete, parseHttpHeader(bytes[0..len], &header)); + } + // The lone `\n` variant completes a byte earlier. + const lf_bytes = "Content-Type: text/plain\n"; + try testing.expectEqual(lf_bytes.len, try parseHttpHeader(lf_bytes, &header)); +} + +test "simd: parse HTTP header invalid" { + var header: HttpHeader = undefined; + + // 0 length keys. + try testing.expectError(error.Invalid, parseHttpHeader(": value\r\n", &header)); + // Invalid characters in the key. + try testing.expectError(error.Invalid, parseHttpHeader("Key\x01: value\r\n", &header)); + try testing.expectError(error.Invalid, parseHttpHeader("Key name: value\r\n", &header)); + // Invalid characters in the value. + try testing.expectError(error.Invalid, parseHttpHeader("Key: val\x01ue\r\n", &header)); + try testing.expectError(error.Invalid, parseHttpHeader("Key: val\x7fue\r\n", &header)); + // `\r` must be followed by `\n`. + try testing.expectError(error.Invalid, parseHttpHeader("Key: value\rX\n", &header)); +} + +test "simd: parse HTTP headers" { + var headers: [4]HttpHeader = undefined; + var count: usize = 0; + + const bytes = "Content-Disposition: form-data; name=\"file\"\r\nContent-Type: application/octet-stream\r\n\r\nbinary\x00data"; + const consumed = try parseHttpHeaders(bytes, &headers, &count); + + try testing.expectEqual(bytes.len - "binary\x00data".len, consumed); + try testing.expectEqual(2, count); + try testing.expectString("Content-Disposition", headers[0].key); + try testing.expectString("form-data; name=\"file\"", headers[0].value); + try testing.expectString("Content-Type", headers[1].key); + try testing.expectString("application/octet-stream", headers[1].value); + + // Lone `\n` line endings are accepted too. + try testing.expectEqual(18, try parseHttpHeaders("Host: abc\nX: def\n\nrest", &headers, &count)); + try testing.expectEqual(2, count); + + // 0 length header sections are fine. + try testing.expectEqual(2, try parseHttpHeaders("\r\nrest", &headers, &count)); + try testing.expectEqual(0, count); + + // The terminating empty line is required. + try testing.expectError(error.Incomplete, parseHttpHeaders("Host: abc\r\n", &headers, &count)); + try testing.expectError(error.Incomplete, parseHttpHeaders("Host: abc\r\n\r", &headers, &count)); + + // Not enough space in `headers`. + var few: [1]HttpHeader = undefined; + try testing.expectError(error.Invalid, parseHttpHeaders("A: 1\r\nB: 2\r\n\r\n", &few, &count)); + // ...but an exact fit is fine. + try testing.expectEqual(8, try parseHttpHeaders("A: 1\r\n\r\n", &few, &count)); + try testing.expectEqual(1, count); +} + +test "simd: match functions against scalar reference" { + // Exhaustive: place every possible byte at every position of an otherwise + // valid buffer, across lengths covering the vector, SWAR and scalar paths. + var buf: [48]u8 = undefined; + for (1..buf.len + 1) |len| { + for (0..len) |pos| { + for (0..256) |c| { + @memset(buf[0..len], 'a'); + buf[pos] = @intCast(c); + + var key_expected: usize = 0; + while (key_expected < len and header_key_map[buf[key_expected]] != 0) key_expected += 1; + var value_expected: usize = 0; + while (value_expected < len and header_value_map[buf[value_expected]] != 0) value_expected += 1; + + try testing.expectEqual(key_expected, matchHeaderKey(buf[0..len])); + try testing.expectEqual(value_expected, matchHeaderValue(buf[0..len])); + } + } + } + + // Randomized: fully random buffers to exercise multiple invalid bytes per + // chunk at once. + var prng = std.Random.DefaultPrng.init(0x5eed); + const random = prng.random(); + for (0..20_000) |_| { + const len = random.intRangeAtMost(usize, 0, buf.len); + random.bytes(buf[0..len]); + + var key_expected: usize = 0; + while (key_expected < len and header_key_map[buf[key_expected]] != 0) key_expected += 1; + var value_expected: usize = 0; + while (value_expected < len and header_value_map[buf[value_expected]] != 0) value_expected += 1; + + try testing.expectEqual(key_expected, matchHeaderKey(buf[0..len])); + try testing.expectEqual(value_expected, matchHeaderValue(buf[0..len])); + } +} From 0c93b50748c3504cbf0c66d4d0d3dd43b4a1f450 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 15 Jul 2026 14:21:46 +0300 Subject: [PATCH 03/23] `FormData`: introduce new ways to create `FormData` objects Adds `initFromUrlEncoded` and `initFromMultipart`; which are required for `Request#formData`. --- src/browser/webapi/net/FormData.zig | 396 +++++++++++++++++++++++++++- 1 file changed, 385 insertions(+), 11 deletions(-) diff --git a/src/browser/webapi/net/FormData.zig b/src/browser/webapi/net/FormData.zig index 9623f2719..b209f5337 100644 --- a/src/browser/webapi/net/FormData.zig +++ b/src/browser/webapi/net/FormData.zig @@ -24,9 +24,11 @@ const Page = @import("../../Page.zig"); const Frame = @import("../../Frame.zig"); const Form = @import("../element/html/Form.zig"); const Element = @import("../Element.zig"); +const Blob = @import("../Blob.zig"); const File = @import("../File.zig"); const Blob = @import("../Blob.zig"); const KeyValueList = @import("../KeyValueList.zig"); +const simd = @import("../../../simd.zig"); const log = lp.log; const String = lp.String; @@ -119,6 +121,46 @@ pub fn init(form_: ?*Form, submitter: ?*Element, exec: *const Execution) !*FormD return form_data; } +// Fetch §6.4 "package data" with type FormData: parse an +// application/x-www-form-urlencoded body back into a FormData. +pub fn initFromUrlEncoded(bytes: []const u8, exec: *const Execution) !*FormData { + const arena = try exec.getArena(.small, "FormData"); + errdefer exec.releaseArena(arena); + + const form_data = try arena.create(FormData); + form_data.* = .{ + ._rc = .{}, + ._arena = arena, + ._entries = .empty, + }; + try form_data.parseUrlEncoded(bytes); + return form_data; +} + +// Fetch §6.4 "package data" with type FormData: parse a multipart/form-data +// body back into a FormData. `boundary` is the Content-Type boundary param. +pub fn initFromMultipart(bytes: []const u8, boundary: []const u8, exec: *const Execution) !*FormData { + const arena = try exec.getArena(.small, "FormData"); + errdefer exec.releaseArena(arena); + + const form_data = try arena.create(FormData); + form_data.* = .{ + ._rc = .{}, + ._arena = arena, + ._entries = .empty, + }; + + // On failure, drop the refs parseMultipart acquired on the file entries + // appended so far (runs before the arena release above frees the list). + errdefer for (form_data._entries.items) |entry| switch (entry.value) { + .file => |file| file.releaseRef(exec.page), + else => {}, + }; + + try form_data.parseMultipart(exec.page, bytes, boundary); + return form_data; +} + pub fn deinit(self: *FormData, page: *Page) void { for (self._entries.items) |entry| { switch (entry.value) { @@ -399,11 +441,11 @@ pub fn parseUrlEncoded(self: *FormData, bytes: []const u8) !void { } if (std.mem.indexOfScalar(u8, pair, '=')) |idx| { try self.append( - try formUrlDecode(self._arena, pair[0..idx]), - try formUrlDecode(self._arena, pair[idx + 1 ..]), + try urlDecode(self._arena, pair[0..idx]), + try urlDecode(self._arena, pair[idx + 1 ..]), ); } else { - const key = try formUrlDecode(self._arena, pair); + const key = try urlDecode(self._arena, pair); // Insert with empty value. try self.append(key, ""); } @@ -437,18 +479,24 @@ fn indexOfSpecial(slice: []const u8) ?usize { } /// URL-decodes passed `raw` slice; returned value may or may not be heap allocated. -fn formUrlDecode(arena: Allocator, raw: []const u8) ![]const u8 { - const start = indexOfSpecial(raw) orelse return raw; +/// TODO: This can be more efficient I believe. +fn urlDecode(arena: Allocator, raw: []const u8) ![]const u8 { + // Get where to start decoding. + var i: usize = indexOfSpecial(raw) orelse return raw; var out: std.ArrayList(u8) = try .initCapacity(arena, raw.len); - out.appendSliceAssumeCapacity(raw[0..start]); + out.appendSliceAssumeCapacity(raw[0..i]); - var i: usize = start; - while (i < raw.len) : (i += 1) { + while (i < raw.len) { const c = raw[i]; switch (c) { - '+' => out.appendAssumeCapacity(' '), + '+' => { + out.appendAssumeCapacity(' '); + i += 1; + }, '%' => { + // Per URL §5.1 percent-decode, an invalid or truncated + // escape sequence passes through verbatim. const decoded: ?u8 = blk: { if (i + 2 >= raw.len) break :blk null; const hi = std.fmt.charToDigit(raw[i + 1], 16) catch break :blk null; @@ -457,18 +505,179 @@ fn formUrlDecode(arena: Allocator, raw: []const u8) ![]const u8 { }; if (decoded) |b| { out.appendAssumeCapacity(b); - i += 2; + i += 3; } else { out.appendAssumeCapacity('%'); + i += 1; } }, - else => out.appendAssumeCapacity(c), + else => { + out.appendAssumeCapacity(c); + i += 1; + }, } } return out.items; } +// Inverse of multipartEncode: strict multipart/form-data parsing (no +// preamble, CRLF line breaks). Parts carrying a filename become File +// entries — the FormData holds a ref on each, released in deinit — and the +// rest become string entries. +fn parseMultipart(self: *FormData, page: *Page, bytes: []const u8, boundary: []const u8) !void { + // The body must open with the dash-boundary: "--" boundary. + if (!std.mem.startsWith(u8, bytes, "--") or !std.mem.startsWith(u8, bytes[2..], boundary)) { + return error.InvalidFormData; + } + // Skip-past boundary. + var cursor = bytes[2 + boundary.len ..]; + + const double_dash: u16 = @bitCast([_]u8{ '-', '-' }); + const crlf: u16 = @bitCast([_]u8{ '\r', '\n' }); + + while (true) { + if (cursor.len < 2) { + return error.InvalidFormData; + } + const prefix: u16 = @bitCast(cursor[0..2].*); + // Check if we've reached the end. + if (prefix == double_dash) { + return; + } + // If we haven't reached the end, CRLF is required. + if (prefix != crlf) { + return error.InvalidFormData; + } + // Consume prefix. + cursor = cursor[2..]; + + // Content-Disposition can appear once a part, and is required. + var disposition: ?simd.Disposition = null; + // Default Content-Type; can be overwritten while parsing headers. + var content_type: []const u8 = "text/plain"; + // Reused for parsing headers. + var header: simd.HttpHeader = undefined; + + // Parse the whole header block; the content starts only after the + // terminating empty line. + while (true) { + if (cursor.len == 0) { + return error.InvalidFormData; + } + + // Check if headers part has finished. + switch (cursor[0]) { + '\n' => { + // End of headers. + cursor = cursor[1..]; + break; + }, + '\r' => { + // We need a `\n` character too. + if (cursor.len < 2 or cursor[1] != '\n') { + return error.InvalidFormData; + } + + // End of headers. + cursor = cursor[2..]; + break; + }, + else => {}, + } + + const consumed = try simd.parseHttpHeader(cursor, &header); + cursor = cursor[consumed..]; + + if (std.ascii.eqlIgnoreCase(header.key, "content-type")) { + content_type = header.value; + } else if (std.ascii.eqlIgnoreCase(header.key, "content-disposition")) { + if (disposition != null) { + // Content-Disposition found twice, report an error. + return error.InvalidFormData; + } + disposition = try simd.parseDisposition(header.value); + } + } + + const parsed = disposition orelse return error.InvalidFormData; + const name = try decodeMultipartName(self._arena, parsed.name orelse return error.InvalidFormData); + + const content_end = indexOfBoundary(cursor, boundary) orelse return error.InvalidFormData; + const content = cursor[0..content_end]; + cursor = cursor[content_end + "\r\n--".len + boundary.len ..]; + + // Got a file. + if (parsed.filename) |filename| { + const blob = try Blob.initFromBytes(content, content_type, page); + errdefer blob.deinit(page); + + const file = try blob._arena.create(File); + file.* = .{ + ._proto = blob, + ._name = try blob._arena.dupe(u8, try decodeMultipartName(self._arena, filename)), + ._last_modified = std.time.milliTimestamp(), + }; + blob._type = .{ .file = file }; + + file.acquireRef(); + try self._entries.append(self._arena, .{ + .name = try String.init(self._arena, name, .{}), + .value = .{ .file = file }, + }); + } else { + try self.append(name, content); + } + } +} + +// Finds the "\r\n--" ++ boundary delimiter in haystack without materializing +// the needle. Per RFC 2046 §5.1.1 the delimiter must be followed by CRLF (or +// "--" for the close delimiter), so a value containing the boundary as a +// prefix of longer text does not terminate the part. +fn indexOfBoundary(haystack: []const u8, boundary: []const u8) ?usize { + var start: usize = 0; + while (std.mem.indexOfPos(u8, haystack, start, "\r\n--")) |i| { + const rest = haystack[i + 4 ..]; + if (std.mem.startsWith(u8, rest, boundary)) { + const after = rest[boundary.len..]; + if (std.mem.startsWith(u8, after, "\r\n") or std.mem.startsWith(u8, after, "--")) { + return i; + } + } + start = i + 1; + } + return null; +} + +// "Parse a multipart/form-data name": undo writeMultipartName's escapes +// (%0A, %0D, %22); any other percent sequence passes through verbatim. +fn decodeMultipartName(arena: Allocator, raw: []const u8) ![]const u8 { + if (std.mem.indexOfScalar(u8, raw, '%') == null) { + return raw; + } + + var out: std.ArrayList(u8) = try .initCapacity(arena, raw.len); + var i: usize = 0; + while (i < raw.len) { + const rest = raw[i..]; + if (std.mem.startsWith(u8, rest, "%22")) { + out.appendAssumeCapacity('"'); + i += 3; + } else if (std.mem.startsWith(u8, rest, "%0D")) { + out.appendAssumeCapacity('\r'); + i += 3; + } else if (std.mem.startsWith(u8, rest, "%0A")) { + out.appendAssumeCapacity('\n'); + i += 3; + } else { + out.appendAssumeCapacity(raw[i]); + i += 1; + } + } + return out.items; +} + // Used by URLSearchParams to ingest a FormData; file entries collapse via Value.asString. pub fn toKeyValueList(self: *const FormData, arena: Allocator) !KeyValueList { var list: KeyValueList = .empty; @@ -960,3 +1169,168 @@ test "FormData: plaintext empty body" { try testing.expectString("", buf.written()); } + +test "FormData: urlencoded parse" { + const allocator = testing.arena_allocator; + + var fd = FormData{ + ._rc = .{}, + ._arena = allocator, + ._entries = .empty, + }; + try fd.parseUrlEncoded("a=1&b=hello+world&c=%26%3D&no_value&&bad=100%zz"); + + try testing.expectEqual(5, fd._entries.items.len); + try testing.expectString("1", fd.get(.wrap("a")).?); + try testing.expectString("hello world", fd.get(.wrap("b")).?); + try testing.expectString("&=", fd.get(.wrap("c")).?); + try testing.expectString("", fd.get(.wrap("no_value")).?); + // An invalid percent sequence passes through verbatim. + try testing.expectString("100%zz", fd.get(.wrap("bad")).?); +} + +test "FormData: urlencoded parse exercises the vectorized guard" { + const allocator = testing.arena_allocator; + + var fd = FormData{ + ._rc = .{}, + ._arena = allocator, + ._entries = .empty, + }; + // Values longer than any SIMD vector length, with the lone special + // character early so only the vectorized loop (not the scalar tail) + // can spot it — a regression guard for needsDecoding's chunk mask. + try fd.parseUrlEncoded("plus=aaa+aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ++ + "&pct=aaa%41aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ++ + "&clean=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + + try testing.expectString("aaa aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", fd.get(.wrap("plus")).?); + try testing.expectString("aaaAaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", fd.get(.wrap("pct")).?); + try testing.expectString("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", fd.get(.wrap("clean")).?); +} + +test "FormData: multipart parse" { + const allocator = testing.arena_allocator; + const frame = try testing.createFrame(); + defer testing.test_session.closeAllPages(); + + var fd = FormData{ + ._rc = .{}, + ._arena = allocator, + ._entries = .empty, + }; + try fd.parseMultipart(frame._page, "--BOUNDARY\r\n" ++ + "Content-Disposition: form-data; name=\"name\"\r\n\r\n" ++ + "John\r\n" ++ + "--BOUNDARY\r\n" ++ + "Content-Disposition: form-data; name=\"a%22b%0D%0Ac\"\r\n\r\n" ++ + "two\r\nlines\r\n" ++ + "--BOUNDARY\r\n" ++ + "Content-Disposition: form-data; name=\"tricky\"\r\n\r\n" ++ + "a\r\n--BOUNDARYx b\r\n" ++ + "--BOUNDARY--\r\n", "BOUNDARY"); + + try testing.expectEqual(3, fd._entries.items.len); + try testing.expectString("John", fd.get(.wrap("name")).?); + // Escaped name decodes, and a value containing CRLF survives. + try testing.expectString("a\"b\r\nc", fd._entries.items[1].name.str()); + try testing.expectString("two\r\nlines", fd._entries.items[1].value.asString()); + // The boundary as a prefix of longer text is not a delimiter. + try testing.expectString("a\r\n--BOUNDARYx b", fd.get(.wrap("tricky")).?); +} + +test "FormData: multipart parse with file" { + const allocator = testing.arena_allocator; + const frame = try testing.createFrame(); + defer testing.test_session.closeAllPages(); + + var fd = FormData{ + ._rc = .{}, + ._arena = allocator, + ._entries = .empty, + }; + try fd.parseMultipart(frame._page, "--B\r\n" ++ + "Content-Disposition: form-data; name=\"upload\"; filename=\"hello.txt\"\r\n" ++ + "Content-Type: text/plain\r\n\r\n" ++ + "hello\r\n" ++ + "--B\r\n" ++ + "Content-Disposition: form-data; name=\"raw\"; filename=\"raw.bin\"\r\n\r\n" ++ + "bytes\r\n" ++ + "--B--\r\n", "B"); + defer for (fd._entries.items) |entry| switch (entry.value) { + .file => |file| file.releaseRef(frame._page), + else => {}, + }; + + try testing.expectEqual(2, fd._entries.items.len); + + const file = fd._entries.items[0].value.file; + try testing.expectString("upload", fd._entries.items[0].name.str()); + try testing.expectString("hello.txt", file.getName()); + try testing.expectString("hello", file._proto._slice); + try testing.expectString("text/plain", file._proto._mime); + + // A file part without a Content-Type header defaults to text/plain. + const raw = fd._entries.items[1].value.file; + try testing.expectString("raw.bin", raw.getName()); + try testing.expectString("bytes", raw._proto._slice); + try testing.expectString("text/plain", raw._proto._mime); +} + +test "FormData: multipart parse rejects malformed bodies" { + const allocator = testing.arena_allocator; + const frame = try testing.createFrame(); + defer testing.test_session.closeAllPages(); + + const cases = [_][]const u8{ + "", // no dash-boundary + "--OTHER\r\n", // wrong boundary + "--B\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\nv\r\n", // unterminated + "--B\r\n\r\nv\r\n--B--\r\n", // no Content-Disposition + "--B\r\nContent-Disposition: inline; name=\"a\"\r\n\r\nv\r\n--B--\r\n", // not form-data + "--B\r\nContent-Disposition: form-data\r\n\r\nv\r\n--B--\r\n", // no name + }; + for (cases) |case| { + var fd = FormData{ + ._rc = .{}, + ._arena = allocator, + ._entries = .empty, + }; + try testing.expectError(error.InvalidFormData, fd.parseMultipart(frame._page, case, "B")); + } +} + +test "FormData: multipart round-trip" { + const allocator = testing.arena_allocator; + const frame = try testing.createFrame(); + defer testing.test_session.closeAllPages(); + + var src = FormData{ + ._rc = .{}, + ._arena = allocator, + ._entries = .empty, + }; + try src.append("username", "alice"); + try src.append("username", "bob"); + try src.append("a\"b\r\nc", "quoted \"value\""); + + var buf = std.Io.Writer.Allocating.init(allocator); + try src.write(.{ + .encoding = .{ .formdata = "BOUNDARY" }, + .allocator = allocator, + }, &buf.writer); + + var fd = FormData{ + ._rc = .{}, + ._arena = allocator, + ._entries = .empty, + }; + try fd.parseMultipart(frame._page, buf.written(), "BOUNDARY"); + + try testing.expectEqual(3, fd._entries.items.len); + try testing.expectString("username", fd._entries.items[0].name.str()); + try testing.expectString("alice", fd._entries.items[0].value.asString()); + try testing.expectString("username", fd._entries.items[1].name.str()); + try testing.expectString("bob", fd._entries.items[1].value.asString()); + try testing.expectString("quoted \"value\"", fd.get(.wrap("a\"b\r\nc")).?); +} From 50d942f807c5a58e0d055f7831ccd9cd0ce28314 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 15 Jul 2026 14:22:18 +0300 Subject: [PATCH 04/23] `Request`: add `Request#formData` --- src/browser/webapi/net/Request.zig | 16 ++++++++++ src/browser/webapi/net/body_init.zig | 46 ++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/src/browser/webapi/net/Request.zig b/src/browser/webapi/net/Request.zig index b9b28c31e..e3b46c70f 100644 --- a/src/browser/webapi/net/Request.zig +++ b/src/browser/webapi/net/Request.zig @@ -298,6 +298,21 @@ pub fn bytes(self: *Request, exec: *const Execution) !js.Promise { return local.resolvePromise(js.TypedArray(u8){ .values = self._body orelse "" }); } +pub fn formData(self: *Request, exec: *const Execution) !js.Promise { + const local = exec.js.local.?; + if (self.consume(local)) |rejected| { + return rejected; + } + + const headers = try self.getHeaders(exec); + const content_type = try headers.get("content-type", exec) orelse ""; + const form_data = body_init.parseFormData(self._body orelse "", content_type, exec) catch |err| switch (err) { + error.OutOfMemory => return err, + else => return local.rejectPromise(.{ .type_error = "Failed to parse body as FormData" }), + }; + return local.resolvePromise(form_data); +} + pub fn clone(self: *const Request, exec: *const Execution) !*Request { const arena = try exec.getArena(if (self._body) |b| b.len else 512, "Request.clone"); errdefer exec.releaseArena(arena); @@ -340,6 +355,7 @@ pub const JsApi = struct { pub const json = bridge.function(Request.json, .{}); pub const arrayBuffer = bridge.function(Request.arrayBuffer, .{}); pub const bytes = bridge.function(Request.bytes, .{}); + pub const formData = bridge.function(Request.formData, .{}); pub const clone = bridge.function(Request.clone, .{}); }; diff --git a/src/browser/webapi/net/body_init.zig b/src/browser/webapi/net/body_init.zig index d47ecad31..551a03613 100644 --- a/src/browser/webapi/net/body_init.zig +++ b/src/browser/webapi/net/body_init.zig @@ -125,6 +125,43 @@ pub const Extracted = struct { content_type: ?[]const u8, }; +pub fn parseFormData(bytes: []const u8, content_type: []const u8, exec: *const js.Execution) !*FormData { + const end = std.mem.indexOfScalar(u8, content_type, ';') orelse content_type.len; + const essence = std.mem.trim(u8, content_type[0..end], " \t"); + + if (std.ascii.eqlIgnoreCase(essence, "multipart/form-data")) { + const boundary = multipartBoundary(content_type) orelse return error.InvalidFormData; + return FormData.initFromMultipart(bytes, boundary, exec); + } + if (std.ascii.eqlIgnoreCase(essence, "application/x-www-form-urlencoded")) { + return FormData.initFromUrlEncoded(bytes, exec); + } + return error.InvalidFormData; +} + +// Extracts the boundary param from a multipart/form-data Content-Type. +// Splitting on ';' is safe: RFC 2046 §5.1.1 bchars never include ';', even +// in the quoted form. +fn multipartBoundary(content_type: []const u8) ?[]const u8 { + var it = std.mem.splitScalar(u8, content_type, ';'); + _ = it.next(); // skip the type/subtype + while (it.next()) |param_| { + const param = std.mem.trim(u8, param_, " \t"); + if (!std.ascii.startsWithIgnoreCase(param, "boundary=")) { + continue; + } + var value = param["boundary=".len..]; + if (value.len >= 2 and value[0] == '"' and value[value.len - 1] == '"') { + value = value[1 .. value.len - 1]; + } + if (value.len == 0) { + return null; + } + return value; + } + return null; +} + // "UTF-8 decode" (Encoding §4.2) strips a leading BOM; consuming a body as // text/json must use it, while arrayBuffer/blob/bytes keep the raw bytes. pub fn stripUtf8Bom(bytes: []const u8) []const u8 { @@ -188,6 +225,15 @@ test "BodyInit: buffer source has no default Content-Type" { try testing.expectEqual(true, r.content_type == null); } +test "multipartBoundary" { + try testing.expectString("----abc", multipartBoundary("multipart/form-data; boundary=----abc").?); + try testing.expectString("a b", multipartBoundary("multipart/form-data; boundary=\"a b\"").?); + try testing.expectString("x", multipartBoundary("multipart/form-data; charset=UTF-8; Boundary=x").?); + try testing.expectEqual(null, multipartBoundary("multipart/form-data")); + try testing.expectEqual(null, multipartBoundary("multipart/form-data; boundary=")); + try testing.expectEqual(null, multipartBoundary("multipart/form-data; charset=UTF-8")); +} + test "stripUtf8Bom" { try testing.expectString("abc", stripUtf8Bom("\xef\xbb\xbfabc")); try testing.expectString("abc", stripUtf8Bom("abc")); From 03b1e611bd69f8ec3e684ac2a430fa3fc463b16e Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 15 Jul 2026 14:22:40 +0300 Subject: [PATCH 05/23] update tests --- src/browser/tests/net/request.html | 56 ++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/browser/tests/net/request.html b/src/browser/tests/net/request.html index 0bca84d45..955bc58bf 100644 --- a/src/browser/tests/net/request.html +++ b/src/browser/tests/net/request.html @@ -259,6 +259,62 @@ } + + + + diff --git a/src/browser/tests/net/response.html b/src/browser/tests/net/response.html index 082f1741d..0a7ea102c 100644 --- a/src/browser/tests/net/response.html +++ b/src/browser/tests/net/response.html @@ -116,6 +116,13 @@ let rejected5 = false; try { await res1.formData(); } catch (e) { rejected5 = e instanceof TypeError; } + // A bodyless Response has no Content-Type: formData() rejects (never + // throws synchronously) with a TypeError. + let rejected6 = false; + const p6 = new Response().formData(); + const promised6 = p6 instanceof Promise; + try { await p6; } catch (e) { rejected6 = e instanceof TypeError; } + state.resolve(); await state.done(() => { testing.expectEqual(true, fd1 instanceof FormData); @@ -134,6 +141,9 @@ testing.expectEqual(true, rejected4); testing.expectEqual(true, rejected5); + + testing.expectEqual(true, promised6); + testing.expectEqual(true, rejected6); }); From 4c96f3edef41923bd20c3c5f7d628837651e708a Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Tue, 28 Jul 2026 14:26:30 +0300 Subject: [PATCH 17/23] `FormData`: changes after rebase --- src/browser/webapi/net/FormData.zig | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/browser/webapi/net/FormData.zig b/src/browser/webapi/net/FormData.zig index f784152f4..590770008 100644 --- a/src/browser/webapi/net/FormData.zig +++ b/src/browser/webapi/net/FormData.zig @@ -26,7 +26,7 @@ const Form = @import("../element/html/Form.zig"); const Element = @import("../Element.zig"); const Blob = @import("../Blob.zig"); const File = @import("../File.zig"); -const Blob = @import("../Blob.zig"); + const KeyValueList = @import("../KeyValueList.zig"); const header_parser = @import("../../../network/header_parser.zig"); @@ -440,14 +440,14 @@ pub fn parseUrlEncoded(self: *FormData, bytes: []const u8) !void { continue; } if (std.mem.indexOfScalar(u8, pair, '=')) |idx| { - try self.append( + try self.appendText( try urlDecode(self._arena, pair[0..idx]), try urlDecode(self._arena, pair[idx + 1 ..]), ); } else { const key = try urlDecode(self._arena, pair); // Insert with empty value. - try self.append(key, ""); + try self.appendText(key, ""); } } } @@ -627,7 +627,7 @@ fn parseMultipart(self: *FormData, page: *Page, bytes: []const u8, boundary: []c .value = .{ .file = file }, }); } else { - try self.append(name, content); + try self.appendText(name, content); } } } @@ -1311,9 +1311,9 @@ test "FormData: multipart round-trip" { ._arena = allocator, ._entries = .empty, }; - try src.append("username", "alice"); - try src.append("username", "bob"); - try src.append("a\"b\r\nc", "quoted \"value\""); + try src.appendText("username", "alice"); + try src.appendText("username", "bob"); + try src.appendText("a\"b\r\nc", "quoted \"value\""); var buf = std.Io.Writer.Allocating.init(allocator); try src.write(.{ From 7b29ec4a1a6233d68982439071396327298ea1a0 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 29 Jul 2026 12:40:03 +0300 Subject: [PATCH 18/23] `header_parser`: simplify `broadcast` --- src/network/header_parser.zig | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/network/header_parser.zig b/src/network/header_parser.zig index d3fce0a6b..4c00c18cb 100644 --- a/src/network/header_parser.zig +++ b/src/network/header_parser.zig @@ -41,18 +41,8 @@ inline fn isHeaderValueByte(byte: u8) bool { } /// Returns an integer filled with a given byte. -inline fn broadcast(comptime T: type, byte: u8) T { - comptime { - const bits = @ctz(@as(T, 0)); - const b = @as(T, byte); - return switch (bits) { - 8 => b * 0x01, - 16 => b * 0x01_01, - 32 => b * 0x01_01_01_01, - 64 => b * 0x01_01_01_01_01_01_01_01, - else => @compileError("unexpected broadcast size"), - }; - } +fn broadcast(byte: u8) usize { + return @as(usize, @intCast(byte)) * 0x01_01_01_01_01_01_01_01; } inline fn matchHeaderKey(cursor: *Cursor) void { From 7d0524073c170938099e50f2dcbf1daab1a34cd8 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 29 Jul 2026 12:42:47 +0300 Subject: [PATCH 19/23] `header_parser`: allow HTAB in header values --- src/network/header_parser.zig | 44 +++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/src/network/header_parser.zig b/src/network/header_parser.zig index 4c00c18cb..9801b334f 100644 --- a/src/network/header_parser.zig +++ b/src/network/header_parser.zig @@ -31,15 +31,23 @@ inline fn isHeaderKeyByte(byte: u8) bool { }; } -/// Whether a byte is allowed in a header value; controls (excluding space) -/// and DEL are excluded. -inline fn isHeaderValueByte(byte: u8) bool { +/// Whether a byte is allowed in a header value; controls other than HTAB, +/// and DEL, are excluded. HTAB is legal field content per RFC 9110. +fn isHeaderValueByte(byte: u8) bool { return switch (byte) { - 0...0x1f, 0x7f => false, + 0...0x08, 0x0a...0x1f, 0x7f => false, else => true, }; } +/// Returns a mask with the high bit of each byte set where the corresponding +/// byte of `v` is zero. Exact per byte: carries never cross byte lanes +/// (Hacker's Delight zero-byte detection). +fn zeroBytes(v: usize) usize { + const low7 = comptime broadcast(0x7f); + return ~(((v & low7) + low7) | v | low7); +} + /// Returns an integer filled with a given byte. fn broadcast(byte: u8) usize { return @as(usize, @intCast(byte)) * 0x01_01_01_01_01_01_01_01; @@ -106,15 +114,17 @@ inline fn matchHeaderValue(cursor: *Cursor) void { const deletes: Vec = @splat(0x7f); // Fill a vector with US (31). const full_31: Vec = @splat(0x1f); + // Fill a vector with HTAB (9). + const tabs: Vec = @splat('\t'); // Load the next chunk from the buffer. const chunk = cursor.asVector(vec_size); // Per-lane `isHeaderValueByte`: a lane is 1 when its byte is // valid, above US (which rules out the controls but keeps space) - // and not DEL. Inverting the mask makes invalid lanes 1s, so - // `@ctz` yields the index of the first invalid byte, or + // or an HTAB, and not DEL. Inverting the mask makes invalid lanes + // 1s, so `@ctz` yields the index of the first invalid byte, or // `vec_size` when the whole chunk is valid. - const mask = @intFromBool(chunk > full_31) & ~@intFromBool(chunk == deletes); + const mask = (@intFromBool(chunk > full_31) | @intFromBool(chunk == tabs)) & ~@intFromBool(chunk == deletes); const advance_by = @ctz(~@as(Int, @bitCast(mask))); cursor.advance(advance_by); if (advance_by != vec_size) { @@ -125,19 +135,17 @@ inline fn matchHeaderValue(cursor: *Cursor) void { // SWAR path. while (cursor.hasLength(block_size)) { - const spaces = comptime broadcast(usize, ' '); - const ones = comptime broadcast(usize, 0x01); - const dels = comptime broadcast(usize, 0x7f); - const full_128 = comptime broadcast(usize, 128); + const tabs = comptime broadcast('\t'); + const dels = comptime broadcast(0x7f); + // A byte is below space (32) exactly when its top three bits are + // all zero. + const high3 = comptime broadcast(0xe0); const chunk = cursor.asInteger(usize); - // When a byte is less than a space (32), subtraction will wrap around - // and set the high bit; the AND NOT makes sure only bytes below 128 - // can report so. - const lt = (chunk -% spaces) & ~chunk; - const xor_dels = chunk ^ dels; - const eq_del = (xor_dels -% ones) & ~xor_dels; - const advance_by = @ctz((lt | eq_del) & full_128) >> 3; + const is_ctl = zeroBytes(chunk & high3); + const is_tab = zeroBytes(chunk ^ tabs); + const is_del = zeroBytes(chunk ^ dels); + const advance_by = @ctz((is_ctl & ~is_tab) | is_del) >> 3; cursor.advance(advance_by); if (advance_by != block_size) { From 50076f538afc38bd8b572d7cd91a78361e1c01bf Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 29 Jul 2026 12:44:20 +0300 Subject: [PATCH 20/23] `header_parser`: `Cursor.skipSpaces` now skips HTAB --- src/network/header_parser.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/network/header_parser.zig b/src/network/header_parser.zig index 9801b334f..cc268b18a 100644 --- a/src/network/header_parser.zig +++ b/src/network/header_parser.zig @@ -344,8 +344,8 @@ pub const Cursor = struct { } /// Moves the cursor until no leading spaces there are. - pub inline fn skipSpaces(cursor: *Cursor) void { - while (cursor.end - cursor.current() > 0 and cursor.char() == ' ') : (cursor.advance(1)) {} + pub fn skipSpaces(cursor: *Cursor) void { + while (cursor.end - cursor.current() > 0 and (cursor.char() == ' ' or cursor.char() == '\t')) : (cursor.advance(1)) {} } /// Returns true if `Cursor` reached end. From f4a98a9e68fa047dcda18c7c3b05d6f61c4aae6b Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 29 Jul 2026 12:45:53 +0300 Subject: [PATCH 21/23] `header_parser`: de-inline everything --- src/network/header_parser.zig | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/network/header_parser.zig b/src/network/header_parser.zig index cc268b18a..4c866482f 100644 --- a/src/network/header_parser.zig +++ b/src/network/header_parser.zig @@ -24,7 +24,7 @@ const block_size = @sizeOf(usize); /// Whether a byte is allowed in a header key; controls (including space), /// the `:` delimiter and DEL are excluded. -inline fn isHeaderKeyByte(byte: u8) bool { +fn isHeaderKeyByte(byte: u8) bool { return switch (byte) { 0...' ', ':', 0x7f => false, else => true, @@ -53,7 +53,7 @@ fn broadcast(byte: u8) usize { return @as(usize, @intCast(byte)) * 0x01_01_01_01_01_01_01_01; } -inline fn matchHeaderKey(cursor: *Cursor) void { +fn matchHeaderKey(cursor: *Cursor) void { // Pick a good default for relatively small strings. const vec_size = 16; const use_vectors = comptime if (std.simd.suggestVectorLength(u8)) |recommended| @@ -97,7 +97,7 @@ inline fn matchHeaderKey(cursor: *Cursor) void { } } -inline fn matchHeaderValue(cursor: *Cursor) void { +fn matchHeaderValue(cursor: *Cursor) void { const maybe_vec_size: ?usize = comptime blk: { if (std.simd.suggestVectorLength(u8)) |recommended| { break :blk if (recommended >= 64) 32 else recommended; @@ -303,43 +303,43 @@ pub const Cursor = struct { start: [*]const u8, /// Returns the current position. - pub inline fn current(cursor: *const Cursor) [*]const u8 { + pub fn current(cursor: *const Cursor) [*]const u8 { return cursor.idx; } /// Returns the current character. - pub inline fn char(cursor: *const Cursor) u8 { + pub fn char(cursor: *const Cursor) u8 { return cursor.idx[0]; } /// Advances the position of the cursor by given value. /// SAFETY: This function doesn't check if out of bounds reachable. - pub inline fn advance(cursor: *Cursor, by: usize) void { + pub fn advance(cursor: *Cursor, by: usize) void { cursor.idx += by; } /// Checks if buffer has `len` length of characters. /// `(cursor.end - cursor.idx >= len)` - pub inline fn hasLength(cursor: *const Cursor, len: usize) bool { + pub fn hasLength(cursor: *const Cursor, len: usize) bool { return cursor.end - cursor.idx >= len; } /// Loads a `@Vector(len, u8)` from the current position of cursor without advancing. /// SAFETY: This function doesn't check if out of bounds reachable. - pub inline fn asVector(cursor: *const Cursor, len: comptime_int) @Vector(len, u8) { + pub fn asVector(cursor: *const Cursor, len: comptime_int) @Vector(len, u8) { return cursor.idx[0..len].*; } /// Creates an integer from the current position of the cursor without advancing. /// SAFETY: This function doesn't check if out of bounds reachable. /// SAFETY: T must be an integer with bit size >= @bitSizeOf(u8). - pub inline fn asInteger(cursor: *const Cursor, comptime T: type) T { + pub fn asInteger(cursor: *const Cursor, comptime T: type) T { return @bitCast(cursor.idx[0 .. @bitSizeOf(T) / @bitSizeOf(u8)].*); } /// Peek the current and the next but don't advance. /// SAFETY: This function doesn't check if out of bounds reachable. - pub inline fn peek2(cursor: *const Cursor, c0: u8, c1: u8) bool { + pub fn peek2(cursor: *const Cursor, c0: u8, c1: u8) bool { return cursor.asInteger(u16) == @as(u16, @bitCast([2]u8{ c0, c1 })); } @@ -349,12 +349,12 @@ pub const Cursor = struct { } /// Returns true if `Cursor` reached end. - pub inline fn reachedEnd(cursor: *const Cursor) bool { + pub fn reachedEnd(cursor: *const Cursor) bool { return cursor.idx == cursor.end; } /// Returns the unread portion of the buffer. - pub inline fn remaining(cursor: *const Cursor) []const u8 { + pub fn remaining(cursor: *const Cursor) []const u8 { return cursor.idx[0 .. cursor.end - cursor.idx]; } }; From dff92a6fb5259fc51b0750bce2db75a5d19a6cf3 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 29 Jul 2026 12:47:26 +0300 Subject: [PATCH 22/23] `FormData`: `std.Io.Clock.now` -> `lp.datetime.milliTimestamp` --- src/browser/webapi/net/FormData.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/webapi/net/FormData.zig b/src/browser/webapi/net/FormData.zig index 590770008..947086bc0 100644 --- a/src/browser/webapi/net/FormData.zig +++ b/src/browser/webapi/net/FormData.zig @@ -617,7 +617,7 @@ fn parseMultipart(self: *FormData, page: *Page, bytes: []const u8, boundary: []c file.* = .{ ._proto = blob, ._name = try blob._arena.dupe(u8, try decodeMultipartName(self._arena, filename)), - ._last_modified = std.Io.Clock.now(.real, lp.io).toMilliseconds(), + ._last_modified = @intCast(lp.datetime.milliTimestamp(.real)), }; blob._type = .{ .file = file }; From 844c8d0846f7e5f1b64f18595fb39dc491c6bec4 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 29 Jul 2026 12:50:55 +0300 Subject: [PATCH 23/23] update tests --- src/browser/tests/net/request.html | 2 ++ src/browser/tests/net/response.html | 29 ++++++++++++++++++++++++++++ src/browser/webapi/net/FormData.zig | 6 +++++- src/network/header_parser.zig | 30 ++++++++++++++++++++++++----- 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/browser/tests/net/request.html b/src/browser/tests/net/request.html index abcb0a795..0f78a259e 100644 --- a/src/browser/tests/net/request.html +++ b/src/browser/tests/net/request.html @@ -274,6 +274,7 @@ const src = new FormData(); src.append('username', 'alice'); src.append('note', 'two\r\nlines'); + src.append('a\tb', 'tabbed'); src.append('username', 'bob'); const req2 = new Request('https://example.com', { method: 'POST', body: src }); const fd2 = await req2.formData(); @@ -340,6 +341,7 @@ testing.expectEqual('alice', fd2.getAll('username')[0]); testing.expectEqual('bob', fd2.getAll('username')[1]); testing.expectEqual('two\r\nlines', fd2.get('note')); + testing.expectEqual('tabbed', fd2.get('a\tb')); testing.expectEqual('y z', fd3.get('x')); diff --git a/src/browser/tests/net/response.html b/src/browser/tests/net/response.html index 0a7ea102c..6fd33d006 100644 --- a/src/browser/tests/net/response.html +++ b/src/browser/tests/net/response.html @@ -99,6 +99,7 @@ const src = new FormData(); src.append('username', 'alice'); src.append('note', 'two\r\nlines'); + src.append('a\tb', 'tabbed'); src.append('username', 'bob'); const res2 = new Response(src); const fd2 = await res2.formData(); @@ -123,6 +124,28 @@ const promised6 = p6 instanceof Promise; try { await p6; } catch (e) { rejected6 = e instanceof TypeError; } + // A null body with a form Content-Type acts as an empty byte sequence and + // resolves with an empty FormData. + const res7 = new Response(null, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + const fd7 = await res7.formData(); + + // A multipart Content-Type without a boundary rejects with a TypeError. + let rejected8 = false; + const res8 = new Response('x', { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + try { await res8.formData(); } catch (e) { rejected8 = e instanceof TypeError; } + + // A part with an unquoted RFC 5987 filename* parameter — which real + // servers emit — parses; the parameter itself is skipped. + const res9 = new Response( + '--B\r\nContent-Disposition: form-data; filename*=UTF-8\'\'a.jpg; name="n"\r\n\r\nv\r\n--B--\r\n', + { headers: { 'Content-Type': 'multipart/form-data; boundary=B' } }, + ); + const fd9 = await res9.formData(); + state.resolve(); await state.done(() => { testing.expectEqual(true, fd1 instanceof FormData); @@ -136,6 +159,7 @@ testing.expectEqual('alice', fd2.getAll('username')[0]); testing.expectEqual('bob', fd2.getAll('username')[1]); testing.expectEqual('two\r\nlines', fd2.get('note')); + testing.expectEqual('tabbed', fd2.get('a\tb')); testing.expectEqual('y z', fd3.get('x')); @@ -144,6 +168,11 @@ testing.expectEqual(true, promised6); testing.expectEqual(true, rejected6); + + testing.expectEqual(true, fd7 instanceof FormData); + testing.expectEqual(0, [...fd7].length); + testing.expectEqual(true, rejected8); + testing.expectEqual('v', fd9.get('n')); }); diff --git a/src/browser/webapi/net/FormData.zig b/src/browser/webapi/net/FormData.zig index 947086bc0..04ba28d2d 100644 --- a/src/browser/webapi/net/FormData.zig +++ b/src/browser/webapi/net/FormData.zig @@ -1314,6 +1314,9 @@ test "FormData: multipart round-trip" { try src.appendText("username", "alice"); try src.appendText("username", "bob"); try src.appendText("a\"b\r\nc", "quoted \"value\""); + // HTAB in a name survives the trip: the encoder writes it raw into the + // Content-Disposition value and the parser accepts it (RFC 9110). + try src.appendText("a\tb", "tabbed"); var buf = std.Io.Writer.Allocating.init(allocator); try src.write(.{ @@ -1328,10 +1331,11 @@ test "FormData: multipart round-trip" { }; try fd.parseMultipart(frame._page, buf.written(), "BOUNDARY"); - try testing.expectEqual(3, fd._entries.items.len); + try testing.expectEqual(4, fd._entries.items.len); try testing.expectString("username", fd._entries.items[0].name.str()); try testing.expectString("alice", fd._entries.items[0].value.asString()); try testing.expectString("username", fd._entries.items[1].name.str()); try testing.expectString("bob", fd._entries.items[1].value.asString()); try testing.expectString("quoted \"value\"", fd.get(.wrap("a\"b\r\nc")).?); + try testing.expectString("tabbed", fd.get(.wrap("a\tb")).?); } diff --git a/src/network/header_parser.zig b/src/network/header_parser.zig index 4c866482f..54e672d03 100644 --- a/src/network/header_parser.zig +++ b/src/network/header_parser.zig @@ -390,16 +390,22 @@ test "header_parser: parse HTTP header" { try testing.expectString("def", header.value); try testing.expectEqual(true, cursor.reachedEnd()); - // Leading whitespaces of the value are skipped, trailing ones are kept. - cursor = initCursor("Key: value \r\n"); + // Leading whitespaces of the value — spaces and HTABs — are skipped, + // trailing ones are kept. + cursor = initCursor("Key: \t value \t\r\n"); try header.parse(&cursor); - try testing.expectString("value ", header.value); + try testing.expectString("value \t", header.value); // 0 length values are fine. cursor = initCursor("Key:\r\n"); try header.parse(&cursor); try testing.expectString("Key", header.key); try testing.expectString("", header.value); + + // HTAB is legal field content, including next to spaces. + cursor = initCursor("Key: a\tb\t c\r\n"); + try header.parse(&cursor); + try testing.expectString("a\tb\t c", header.value); } test "header_parser: parse HTTP header incomplete" { @@ -490,8 +496,8 @@ test "header_parser: cursor" { try testing.expectEqual(true, cursor.reachedEnd()); try testing.expectString("", cursor.remaining()); - // Skipping spaces stops at the end of the buffer. - cursor = initCursor(" "); + // Skipping spaces covers HTABs too and stops at the end of the buffer. + cursor = initCursor(" \t \t"); cursor.skipSpaces(); try testing.expectEqual(true, cursor.reachedEnd()); } @@ -525,6 +531,20 @@ test "header_parser: match functions against scalar reference" { } } + // HTAB is the one valid byte below space; pair it with every byte at + // every position to prove it never disturbs its neighbor's verdict + // (a cross-lane borrow in the SWAR path would). + for (2..buf.len + 1) |len| { + for (0..len - 1) |pos| { + for (0..256) |c| { + @memset(buf[0..len], 'a'); + buf[pos] = '\t'; + buf[pos + 1] = @intCast(c); + try expectMatchesReference(buf[0..len]); + } + } + } + // Randomized: fully random buffers to exercise multiple invalid bytes per // chunk at once. var prng = std.Random.DefaultPrng.init(0x5eed);