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);