mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-07-30 09:16:07 -04:00
Merge pull request #2969 from lightpanda-io/nikneym/req-res-form-data
webapi: add `Request#formData` and `Response#formData`
This commit is contained in:
@@ -90,6 +90,70 @@ pub fn contentTypeString(mime: *const Mime) []const u8 {
|
||||
};
|
||||
}
|
||||
|
||||
/// Parses `essence` and parameters of `Content-Type` header value.
|
||||
pub const ContentTypeIterator = struct {
|
||||
/// Header value.
|
||||
rest: []const u8,
|
||||
/// Always the first.
|
||||
essence: []const u8,
|
||||
|
||||
/// Initializes a `ContentTypeIterator`.
|
||||
pub fn init(content_type: []const u8) ContentTypeIterator {
|
||||
// Skip whitespace.
|
||||
const trimmed = std.mem.trimStart(u8, content_type, &.{ ' ', '\t' });
|
||||
// Find semicolon delimiter; or just use the end position.
|
||||
const end = std.mem.indexOfScalar(u8, trimmed, ';') orelse trimmed.len;
|
||||
const essence = std.mem.trimEnd(u8, trimmed[0..end], &.{ ' ', '\t' });
|
||||
|
||||
// Rest of the parameters.
|
||||
const rest = trimmed[end..];
|
||||
return .{ .rest = rest, .essence = essence };
|
||||
}
|
||||
|
||||
pub const Parameter = struct {
|
||||
key: []const u8,
|
||||
/// `value` can be an empty string ("").
|
||||
value: []const u8,
|
||||
};
|
||||
|
||||
/// Returns the next parameter or null if there aren't any parameters.
|
||||
pub fn next(self: *ContentTypeIterator) ?Parameter {
|
||||
while (self.rest.len > 0) {
|
||||
// `rest` always sits at the `;` that introduced this parameter.
|
||||
var param = self.rest[1..];
|
||||
const end = std.mem.indexOfScalar(u8, param, ';') orelse param.len;
|
||||
self.rest = param[end..];
|
||||
param = std.mem.trim(u8, param[0..end], " \t");
|
||||
|
||||
// Parameters without `=` are malformed; skip them.
|
||||
const eq = std.mem.indexOfScalar(u8, param, '=') orelse continue;
|
||||
const key = std.mem.trimEnd(u8, param[0..eq], " \t");
|
||||
if (key.len == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = std.mem.trimStart(u8, param[eq + 1 ..], " \t");
|
||||
if (value.len >= 2 and value[0] == '"' and value[value.len - 1] == '"') {
|
||||
value = value[1 .. value.len - 1];
|
||||
}
|
||||
return .{ .key = key, .value = value };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Returns boundary string; consuming other parameters along the way.
|
||||
/// Boundary can be an empty string.
|
||||
pub fn findBoundary(self: *ContentTypeIterator) []const u8 {
|
||||
while (self.next()) |p| {
|
||||
if (std.ascii.eqlIgnoreCase(p.key, "boundary")) {
|
||||
return p.value;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
/// Returns the null-terminated charset value.
|
||||
pub fn charsetStringZ(mime: *const Mime) [:0]const u8 {
|
||||
return mime.charset[0..mime.charset_len :0];
|
||||
|
||||
@@ -259,6 +259,104 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=form_data type=module>
|
||||
const state = await testing.async();
|
||||
|
||||
// An urlencoded body parses into a FormData.
|
||||
const req1 = new Request('https://example.com', {
|
||||
method: 'POST',
|
||||
body: 'a=1&b=hello+world&c=%26%3D&no_value',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
});
|
||||
const fd1 = await req1.formData();
|
||||
|
||||
// A FormData body round-trips through the multipart encoding.
|
||||
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();
|
||||
|
||||
// A URLSearchParams body parses back (urlencoded default Content-Type).
|
||||
const req3 = new Request('https://example.com', {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({ x: 'y z' }),
|
||||
});
|
||||
const fd3 = await req3.formData();
|
||||
|
||||
// A non-form Content-Type rejects with a TypeError.
|
||||
let rejected4 = false;
|
||||
const req4 = new Request('https://example.com', { method: 'POST', body: 'plain' });
|
||||
try { await req4.formData(); } catch (e) { rejected4 = e instanceof TypeError; }
|
||||
|
||||
// So does re-consuming an already-used body.
|
||||
let rejected5 = false;
|
||||
try { await req1.formData(); } catch (e) { rejected5 = e instanceof TypeError; }
|
||||
|
||||
// A missing Content-Type rejects (never throws synchronously) with a
|
||||
// TypeError.
|
||||
let rejected6 = false;
|
||||
const p6 = new Request('https://example.com', { method: 'POST' }).formData();
|
||||
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 req7 = new Request('https://example.com', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
});
|
||||
const fd7 = await req7.formData();
|
||||
|
||||
// A multipart Content-Type without a boundary rejects with a TypeError.
|
||||
let rejected8 = false;
|
||||
const req8 = new Request('https://example.com', {
|
||||
method: 'POST',
|
||||
body: 'x',
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
try { await req8.formData(); } catch (e) { rejected8 = e instanceof TypeError; }
|
||||
|
||||
// A part with an unquoted RFC 5987 filename* parameter parses; the
|
||||
// parameter itself is skipped.
|
||||
const req9 = new Request('https://example.com', {
|
||||
method: 'POST',
|
||||
body: '--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 req9.formData();
|
||||
|
||||
state.resolve();
|
||||
await state.done(() => {
|
||||
testing.expectEqual(true, fd1 instanceof FormData);
|
||||
testing.expectEqual('1', fd1.get('a'));
|
||||
testing.expectEqual('hello world', fd1.get('b'));
|
||||
testing.expectEqual('&=', fd1.get('c'));
|
||||
testing.expectEqual('', fd1.get('no_value'));
|
||||
|
||||
testing.expectEqual(true, fd2 instanceof FormData);
|
||||
testing.expectEqual(2, fd2.getAll('username').length);
|
||||
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'));
|
||||
|
||||
testing.expectEqual(true, rejected4);
|
||||
testing.expectEqual(true, rejected5);
|
||||
|
||||
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'));
|
||||
});
|
||||
</script>
|
||||
|
||||
<script id=priority>
|
||||
{
|
||||
new Request('https://example.com', { priority: 'high' });
|
||||
|
||||
@@ -86,6 +86,96 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<script id=form_data type=module>
|
||||
const state = await testing.async();
|
||||
|
||||
// An urlencoded body parses into a FormData.
|
||||
const res1 = new Response('a=1&b=hello+world&c=%26%3D&no_value', {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
});
|
||||
const fd1 = await res1.formData();
|
||||
|
||||
// A FormData body round-trips through the multipart encoding.
|
||||
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();
|
||||
|
||||
// A URLSearchParams body parses back (urlencoded default Content-Type).
|
||||
const res3 = new Response(new URLSearchParams({ x: 'y z' }));
|
||||
const fd3 = await res3.formData();
|
||||
|
||||
// A non-form Content-Type rejects with a TypeError.
|
||||
let rejected4 = false;
|
||||
const res4 = new Response('plain');
|
||||
try { await res4.formData(); } catch (e) { rejected4 = e instanceof TypeError; }
|
||||
|
||||
// So does re-consuming an already-used body.
|
||||
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; }
|
||||
|
||||
// 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);
|
||||
testing.expectEqual('1', fd1.get('a'));
|
||||
testing.expectEqual('hello world', fd1.get('b'));
|
||||
testing.expectEqual('&=', fd1.get('c'));
|
||||
testing.expectEqual('', fd1.get('no_value'));
|
||||
|
||||
testing.expectEqual(true, fd2 instanceof FormData);
|
||||
testing.expectEqual(2, fd2.getAll('username').length);
|
||||
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'));
|
||||
|
||||
testing.expectEqual(true, rejected4);
|
||||
testing.expectEqual(true, rejected5);
|
||||
|
||||
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'));
|
||||
});
|
||||
</script>
|
||||
|
||||
<script id=clone type=module>
|
||||
const state = await testing.async();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
@@ -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 File = @import("../File.zig");
|
||||
const Blob = @import("../Blob.zig");
|
||||
const File = @import("../File.zig");
|
||||
|
||||
const KeyValueList = @import("../KeyValueList.zig");
|
||||
const header_parser = @import("../../../network/header_parser.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) {
|
||||
@@ -388,6 +430,255 @@ 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.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.appendText(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.
|
||||
/// 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..i]);
|
||||
|
||||
while (i < raw.len) {
|
||||
const c = raw[i];
|
||||
switch (c) {
|
||||
'+' => {
|
||||
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;
|
||||
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 += 3;
|
||||
} else {
|
||||
out.appendAssumeCapacity('%');
|
||||
i += 1;
|
||||
}
|
||||
},
|
||||
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.
|
||||
const slice = bytes[2 + boundary.len ..];
|
||||
|
||||
var cursor = header_parser.Cursor{
|
||||
.idx = slice.ptr,
|
||||
.end = slice.ptr + slice.len,
|
||||
.start = slice.ptr,
|
||||
};
|
||||
|
||||
while (true) {
|
||||
if (!cursor.hasLength(2)) {
|
||||
return error.InvalidFormData;
|
||||
}
|
||||
// Check if we've reached the end.
|
||||
if (cursor.peek2('-', '-')) {
|
||||
return;
|
||||
}
|
||||
// If we haven't reached the end, CRLF is required.
|
||||
if (!cursor.peek2('\r', '\n')) {
|
||||
return error.InvalidFormData;
|
||||
}
|
||||
// Consume prefix.
|
||||
cursor.advance(2);
|
||||
|
||||
// Content-Disposition can appear once a part, and is required.
|
||||
var disposition: ?header_parser.Disposition = null;
|
||||
// Default Content-Type; can be overwritten while parsing headers.
|
||||
var content_type: []const u8 = "text/plain";
|
||||
// Reused for parsing headers.
|
||||
var header: header_parser.Header = undefined;
|
||||
|
||||
// Parse the whole header block; the content starts only after the
|
||||
// terminating empty line.
|
||||
while (true) {
|
||||
if (cursor.reachedEnd()) {
|
||||
return error.InvalidFormData;
|
||||
}
|
||||
|
||||
// Check if headers part has finished.
|
||||
switch (cursor.char()) {
|
||||
'\n' => {
|
||||
// End of headers.
|
||||
cursor.advance(1);
|
||||
break;
|
||||
},
|
||||
'\r' => {
|
||||
// We need an LF too.
|
||||
if (!cursor.hasLength(2) or !cursor.peek2('\r', '\n')) {
|
||||
return error.InvalidFormData;
|
||||
}
|
||||
// End of headers.
|
||||
cursor.advance(2);
|
||||
break;
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
|
||||
// Parse an HTTP header.
|
||||
try header.parse(&cursor);
|
||||
|
||||
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 header_parser.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.remaining(), boundary) orelse return error.InvalidFormData;
|
||||
const content = cursor.remaining()[0..content_end];
|
||||
cursor.advance(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 = @intCast(lp.datetime.milliTimestamp(.real)),
|
||||
};
|
||||
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.appendText(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;
|
||||
@@ -879,3 +1170,172 @@ 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.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(.{
|
||||
.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(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")).?);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
@@ -26,8 +26,10 @@ const URL = @import("../URL.zig");
|
||||
const Page = @import("../../Page.zig");
|
||||
const Blob = @import("../Blob.zig");
|
||||
const AbortSignal = @import("../AbortSignal.zig");
|
||||
const ContentTypeIterator = @import("../../Mime.zig").ContentTypeIterator;
|
||||
|
||||
const Headers = @import("Headers.zig");
|
||||
const FormData = @import("FormData.zig");
|
||||
const body_init = @import("body_init.zig");
|
||||
const BodyInit = body_init.BodyInit;
|
||||
|
||||
@@ -298,6 +300,49 @@ 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;
|
||||
}
|
||||
|
||||
// Per Fetch, a null body acts as an empty byte sequence.
|
||||
const body = self._body orelse "";
|
||||
|
||||
const headers = try self.getHeaders(exec);
|
||||
const content_type = try headers.get("content-type", exec) orelse {
|
||||
return local.rejectPromise(.{ .type_error = "Failed to parse body as FormData" });
|
||||
};
|
||||
var it = ContentTypeIterator.init(content_type);
|
||||
const essence = it.essence;
|
||||
|
||||
// [RFC7578]
|
||||
// Parse bytes, using the value of the `boundary` parameter from mimeType,
|
||||
// per the rules set forth in Returning Values from Forms: multipart/form-data.
|
||||
if (std.ascii.eqlIgnoreCase(essence, "multipart/form-data")) {
|
||||
const boundary = it.findBoundary();
|
||||
if (boundary.len == 0) {
|
||||
return local.rejectPromise(.{ .type_error = "Failed to parse body as FormData" });
|
||||
}
|
||||
|
||||
const form_data = FormData.initFromMultipart(body, boundary, 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);
|
||||
}
|
||||
|
||||
if (std.ascii.eqlIgnoreCase(essence, "application/x-www-form-urlencoded")) {
|
||||
const form_data = FormData.initFromUrlEncoded(body, 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);
|
||||
}
|
||||
|
||||
return local.rejectPromise(.{ .type_error = "Failed to parse body as FormData" });
|
||||
}
|
||||
|
||||
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 +385,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, .{});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
@@ -26,10 +26,13 @@ const Transfer = @import("../../../network/HttpClient.zig").Transfer;
|
||||
|
||||
const Blob = @import("../Blob.zig");
|
||||
const ReadableStream = @import("../streams/ReadableStream.zig");
|
||||
const FormData = @import("FormData.zig");
|
||||
|
||||
const Headers = @import("Headers.zig");
|
||||
const body_init = @import("body_init.zig");
|
||||
|
||||
const ContentTypeIterator = @import("../../Mime.zig").ContentTypeIterator;
|
||||
|
||||
const Execution = js.Execution;
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
@@ -456,6 +459,48 @@ pub fn bytes(self: *Response, exec: *const Execution) !js.Promise {
|
||||
return local.resolvePromise(js.TypedArray(u8){ .values = body });
|
||||
}
|
||||
|
||||
pub fn formData(self: *Response, exec: *const Execution) !js.Promise {
|
||||
const local = exec.js.local.?;
|
||||
if (self.consume(local)) |rejected| return rejected;
|
||||
const body = switch (self._body) {
|
||||
.bytes => |b| b,
|
||||
.empty => "",
|
||||
.stream => return local.rejectPromise(.{ .type_error = "Cannot read FormData from stream body" }),
|
||||
};
|
||||
|
||||
const content_type = try self._headers.get("content-type", exec) orelse {
|
||||
return local.rejectPromise(.{ .type_error = "Failed to parse body as FormData" });
|
||||
};
|
||||
var it = ContentTypeIterator.init(content_type);
|
||||
const essence = it.essence;
|
||||
|
||||
// [RFC7578]
|
||||
// Parse bytes, using the value of the `boundary` parameter from mimeType,
|
||||
// per the rules set forth in Returning Values from Forms: multipart/form-data.
|
||||
if (std.ascii.eqlIgnoreCase(essence, "multipart/form-data")) {
|
||||
const boundary = it.findBoundary();
|
||||
if (boundary.len == 0) {
|
||||
return local.rejectPromise(.{ .type_error = "Failed to parse body as FormData" });
|
||||
}
|
||||
|
||||
const form_data = FormData.initFromMultipart(body, boundary, 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);
|
||||
}
|
||||
|
||||
if (std.ascii.eqlIgnoreCase(essence, "application/x-www-form-urlencoded")) {
|
||||
const form_data = FormData.initFromUrlEncoded(body, 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);
|
||||
}
|
||||
|
||||
return local.rejectPromise(.{ .type_error = "Failed to parse body as FormData" });
|
||||
}
|
||||
|
||||
pub fn clone(self: *const Response, exec: *const Execution) !*Response {
|
||||
const session = exec.session;
|
||||
const body_len = switch (self._body) {
|
||||
@@ -519,6 +564,7 @@ pub const JsApi = struct {
|
||||
pub const arrayBuffer = bridge.function(Response.arrayBuffer, .{});
|
||||
pub const blob = bridge.function(Response.blob, .{});
|
||||
pub const bytes = bridge.function(Response.bytes, .{});
|
||||
pub const formData = bridge.function(Response.formData, .{});
|
||||
pub const clone = bridge.function(Response.clone, .{});
|
||||
};
|
||||
|
||||
|
||||
557
src/network/header_parser.zig
Normal file
557
src/network/header_parser.zig
Normal file
@@ -0,0 +1,557 @@
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
|
||||
/// Block size of the CPU.
|
||||
const block_size = @sizeOf(usize);
|
||||
|
||||
/// Whether a byte is allowed in a header key; controls (including space),
|
||||
/// the `:` delimiter and DEL are excluded.
|
||||
fn isHeaderKeyByte(byte: u8) bool {
|
||||
return switch (byte) {
|
||||
0...' ', ':', 0x7f => false,
|
||||
else => true,
|
||||
};
|
||||
}
|
||||
|
||||
/// 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...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;
|
||||
}
|
||||
|
||||
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|
|
||||
recommended >= vec_size
|
||||
else
|
||||
false;
|
||||
|
||||
if (comptime use_vectors) {
|
||||
const Vec = @Vector(vec_size, u8);
|
||||
const Int = @Int(.unsigned, vec_size);
|
||||
|
||||
while (cursor.hasLength(vec_size)) {
|
||||
const spaces: Vec = @splat(' ');
|
||||
const colons: Vec = @splat(':');
|
||||
const deletes: Vec = @splat(0x7f);
|
||||
const chunk = cursor.asVector(vec_size);
|
||||
|
||||
// Per-lane `isHeaderKeyByte`: a lane is 1 when its byte is valid,
|
||||
// above space (which also rules out the controls) and neither the
|
||||
// `:` delimiter nor 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 > spaces) & ~(@intFromBool(chunk == colons) | @intFromBool(chunk == deletes));
|
||||
const advance_by = @ctz(~@as(Int, @bitCast(mask)));
|
||||
// Advance.
|
||||
cursor.advance(advance_by);
|
||||
if (advance_by != vec_size) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: SWAR is not preferred here, this might change in the future
|
||||
// but honestly header keys are not so long.
|
||||
|
||||
// Fallback for len < `vec_size`.
|
||||
while (cursor.end - cursor.idx > 0) : (cursor.advance(1)) {
|
||||
if (isHeaderKeyByte(cursor.char()) == false) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
break :blk null;
|
||||
};
|
||||
|
||||
if (comptime maybe_vec_size) |vec_size| {
|
||||
const Vec = @Vector(vec_size, u8);
|
||||
const Int = @Int(.unsigned, vec_size);
|
||||
|
||||
while (cursor.hasLength(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);
|
||||
// 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)
|
||||
// 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 == tabs)) & ~@intFromBool(chunk == deletes);
|
||||
const advance_by = @ctz(~@as(Int, @bitCast(mask)));
|
||||
cursor.advance(advance_by);
|
||||
if (advance_by != vec_size) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SWAR path.
|
||||
while (cursor.hasLength(block_size)) {
|
||||
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);
|
||||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
while (cursor.end - cursor.idx > 0) : (cursor.advance(1)) {
|
||||
if (isHeaderValueByte(cursor.char()) == false) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a single HTTP header.
|
||||
pub const Header = struct {
|
||||
key: []const u8,
|
||||
value: []const u8,
|
||||
|
||||
pub const ParseError = error{ Incomplete, Invalid };
|
||||
|
||||
pub fn parse(header: *Header, cursor: *Cursor) ParseError!void {
|
||||
const key_start = cursor.current();
|
||||
matchHeaderKey(cursor);
|
||||
const key_end = cursor.current();
|
||||
|
||||
// Buffer has been consumed fully without a delimiter; the caller can
|
||||
// read more data and try to parse again. Checked before `char`, which
|
||||
// reads unchecked.
|
||||
if (cursor.reachedEnd()) {
|
||||
return error.Incomplete;
|
||||
}
|
||||
|
||||
switch (cursor.char()) {
|
||||
':' => {
|
||||
@branchHint(.likely);
|
||||
// 0 length header key, which is invalid.
|
||||
if (key_end == key_start) {
|
||||
return error.Invalid;
|
||||
}
|
||||
cursor.advance(1);
|
||||
},
|
||||
// Invalid character, so a malformed header. Can't go further.
|
||||
else => return error.Invalid,
|
||||
}
|
||||
|
||||
// Get rid of leading spaces if there are any.
|
||||
cursor.skipSpaces();
|
||||
|
||||
// Found where header value starts.
|
||||
const val_start = cursor.current();
|
||||
matchHeaderValue(cursor);
|
||||
const val_end = cursor.current();
|
||||
|
||||
// Buffer has been consumed fully without a line ending; the caller
|
||||
// can read more data and try to parse again.
|
||||
if (cursor.reachedEnd()) {
|
||||
return error.Incomplete;
|
||||
}
|
||||
|
||||
// Both LF and CRLF indicate the end of the value part.
|
||||
switch (cursor.char()) {
|
||||
'\n' => cursor.advance(1),
|
||||
'\r' => {
|
||||
// We need an LF too.
|
||||
if (!cursor.hasLength(2)) {
|
||||
return error.Incomplete;
|
||||
}
|
||||
if (!cursor.peek2('\r', '\n')) {
|
||||
@branchHint(.unlikely);
|
||||
return error.Invalid;
|
||||
}
|
||||
cursor.advance(2);
|
||||
},
|
||||
// Any other character is invalid.
|
||||
else => return error.Invalid,
|
||||
}
|
||||
|
||||
header.* = .{
|
||||
.key = key_start[0 .. key_end - key_start],
|
||||
.value = val_start[0 .. val_end - val_start],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
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.
|
||||
// Parameters that are not `key="quoted"` pairs — e.g. RFC 5987
|
||||
// `filename*=UTF-8''...`, which real servers emit — are skipped rather than
|
||||
// failing the whole parse, matching browser leniency.
|
||||
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.trimStart(u8, rest, " \t");
|
||||
if (rest.len == 0) {
|
||||
return disposition;
|
||||
}
|
||||
if (rest[0] != ';') {
|
||||
return error.InvalidFormData;
|
||||
}
|
||||
rest = std.mem.trimStart(u8, rest[1..], " \t");
|
||||
|
||||
// An unquoted value cannot contain a `;`, so the next one (or the end
|
||||
// of the header) bounds this parameter when it has to be skipped.
|
||||
const semi = std.mem.indexOfScalar(u8, rest, ';') orelse rest.len;
|
||||
const eq = std.mem.indexOfScalar(u8, rest, '=') orelse rest.len;
|
||||
if (eq >= semi) {
|
||||
// No `=` in this parameter; skip it.
|
||||
rest = rest[semi..];
|
||||
continue;
|
||||
}
|
||||
const key = rest[0..eq];
|
||||
const after_eq = rest[eq + 1 ..];
|
||||
|
||||
if (after_eq.len == 0 or after_eq[0] != '"') {
|
||||
// Unquoted value; skip it.
|
||||
rest = rest[semi..];
|
||||
continue;
|
||||
}
|
||||
const end = std.mem.indexOfScalarPos(u8, after_eq, 1, '"') orelse {
|
||||
// Unterminated quoted value; ignore the rest of the header.
|
||||
return disposition;
|
||||
};
|
||||
const param_value = after_eq[1..end];
|
||||
rest = after_eq[end + 1 ..];
|
||||
|
||||
if (std.ascii.eqlIgnoreCase(key, "name")) {
|
||||
disposition.name = param_value;
|
||||
} else if (std.ascii.eqlIgnoreCase(key, "filename")) {
|
||||
disposition.filename = param_value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper for wandering around & parsing things along the way.
|
||||
pub const Cursor = struct {
|
||||
/// Pointer to current position of cursor.
|
||||
idx: [*]const u8,
|
||||
/// Pointer to end of the buffer.
|
||||
end: [*]const u8,
|
||||
/// Pointer to start of the buffer.
|
||||
start: [*]const u8,
|
||||
|
||||
/// Returns the current position.
|
||||
pub fn current(cursor: *const Cursor) [*]const u8 {
|
||||
return cursor.idx;
|
||||
}
|
||||
|
||||
/// Returns the current character.
|
||||
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 fn advance(cursor: *Cursor, by: usize) void {
|
||||
cursor.idx += by;
|
||||
}
|
||||
|
||||
/// Checks if buffer has `len` length of characters.
|
||||
/// `(cursor.end - cursor.idx >= len)`
|
||||
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 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 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 fn peek2(cursor: *const Cursor, c0: u8, c1: u8) bool {
|
||||
return cursor.asInteger(u16) == @as(u16, @bitCast([2]u8{ c0, c1 }));
|
||||
}
|
||||
|
||||
/// Moves the cursor until no leading spaces there are.
|
||||
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.
|
||||
pub fn reachedEnd(cursor: *const Cursor) bool {
|
||||
return cursor.idx == cursor.end;
|
||||
}
|
||||
|
||||
/// Returns the unread portion of the buffer.
|
||||
pub fn remaining(cursor: *const Cursor) []const u8 {
|
||||
return cursor.idx[0 .. cursor.end - cursor.idx];
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
|
||||
fn initCursor(bytes: []const u8) Cursor {
|
||||
return .{ .idx = bytes.ptr, .end = bytes.ptr + bytes.len, .start = bytes.ptr };
|
||||
}
|
||||
|
||||
fn consumed(cursor: *const Cursor) usize {
|
||||
return cursor.idx - cursor.start;
|
||||
}
|
||||
|
||||
test "header_parser: parse HTTP header" {
|
||||
const bytes = "Content-Disposition: attachment; filename*=UTF-8''file%20name.jpg\r\nrest";
|
||||
var cursor = initCursor(bytes);
|
||||
var header: Header = undefined;
|
||||
try header.parse(&cursor);
|
||||
|
||||
try testing.expectEqual(bytes.len - "rest".len, consumed(&cursor));
|
||||
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; the cursor is left at the next
|
||||
// header, so parsing can continue from there.
|
||||
cursor = initCursor("Host: abc\nHost: def\n");
|
||||
try header.parse(&cursor);
|
||||
try testing.expectEqual(10, consumed(&cursor));
|
||||
try testing.expectString("Host", header.key);
|
||||
try testing.expectString("abc", header.value);
|
||||
try header.parse(&cursor);
|
||||
try testing.expectString("def", header.value);
|
||||
try testing.expectEqual(true, cursor.reachedEnd());
|
||||
|
||||
// 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 \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" {
|
||||
var header: Header = undefined;
|
||||
|
||||
// Buffer may end anywhere before the line ending is complete.
|
||||
const bytes = "Content-Type: text/plain\r\n";
|
||||
for (0..bytes.len) |len| {
|
||||
var cursor = initCursor(bytes[0..len]);
|
||||
try testing.expectError(error.Incomplete, header.parse(&cursor));
|
||||
}
|
||||
// The lone `\n` variant completes a byte earlier.
|
||||
var cursor = initCursor("Content-Type: text/plain\n");
|
||||
try header.parse(&cursor);
|
||||
try testing.expectEqual(true, cursor.reachedEnd());
|
||||
}
|
||||
|
||||
test "header_parser: parse HTTP header invalid" {
|
||||
var header: Header = undefined;
|
||||
|
||||
const cases = [_][]const u8{
|
||||
": value\r\n", // 0 length key
|
||||
"Key\x01: value\r\n", // invalid character in the key
|
||||
"Key name: value\r\n", // space in the key
|
||||
"Key: val\x01ue\r\n", // invalid character in the value
|
||||
"Key: val\x7fue\r\n", // DEL in the value
|
||||
"Key: value\rX\n", // `\r` must be followed by `\n`
|
||||
};
|
||||
for (cases) |case| {
|
||||
var cursor = initCursor(case);
|
||||
try testing.expectError(error.Invalid, header.parse(&cursor));
|
||||
}
|
||||
}
|
||||
|
||||
test "header_parser: parse Content-Disposition" {
|
||||
var d = try parseDisposition("form-data; name=\"a\"; filename=\"b.txt\"");
|
||||
try testing.expectString("a", d.name.?);
|
||||
try testing.expectString("b.txt", d.filename.?);
|
||||
|
||||
// Type and parameter names are case-insensitive; surrounding whitespace
|
||||
// is tolerated.
|
||||
d = try parseDisposition(" Form-Data; NAME=\"x\" ");
|
||||
try testing.expectString("x", d.name.?);
|
||||
try testing.expectEqual(null, d.filename);
|
||||
|
||||
// No parameters at all is fine; both fields stay null.
|
||||
d = try parseDisposition("form-data");
|
||||
try testing.expectEqual(null, d.name);
|
||||
try testing.expectEqual(null, d.filename);
|
||||
|
||||
// A raw ';' inside a quoted value is preserved; unknown parameters are
|
||||
// skipped.
|
||||
d = try parseDisposition("form-data; foo=\"bar\"; name=\"a;b\"");
|
||||
try testing.expectString("a;b", d.name.?);
|
||||
|
||||
// Parameters without `=` or with an unquoted value — e.g. RFC 5987
|
||||
// `filename*` — are skipped without failing the parse; later well-formed
|
||||
// parameters still apply.
|
||||
d = try parseDisposition("form-data; filename*=UTF-8''file%20name.jpg; name=\"n\"");
|
||||
try testing.expectString("n", d.name.?);
|
||||
try testing.expectEqual(null, d.filename);
|
||||
d = try parseDisposition("form-data; foo; name=bare; filename=\"f.txt\"");
|
||||
try testing.expectEqual(null, d.name);
|
||||
try testing.expectString("f.txt", d.filename.?);
|
||||
|
||||
// An unterminated quoted value ends the parse, keeping what was found.
|
||||
d = try parseDisposition("form-data; name=\"a\"; filename=\"f");
|
||||
try testing.expectString("a", d.name.?);
|
||||
try testing.expectEqual(null, d.filename);
|
||||
|
||||
// Not form-data at all, or a parameter without a `;` separator, is
|
||||
// rejected.
|
||||
try testing.expectError(error.InvalidFormData, parseDisposition("attachment; name=\"a\""));
|
||||
try testing.expectError(error.InvalidFormData, parseDisposition("form-data name=\"a\""));
|
||||
}
|
||||
|
||||
test "header_parser: cursor" {
|
||||
var cursor = initCursor(" ab\r\ncd");
|
||||
try testing.expectEqual(8, cursor.remaining().len);
|
||||
cursor.skipSpaces();
|
||||
try testing.expectEqual(2, consumed(&cursor));
|
||||
try testing.expectEqual('a', cursor.char());
|
||||
try testing.expectEqual(true, cursor.peek2('a', 'b'));
|
||||
try testing.expectEqual(false, cursor.peek2('a', 'c'));
|
||||
try testing.expectEqual(true, cursor.hasLength(6));
|
||||
try testing.expectEqual(false, cursor.hasLength(7));
|
||||
cursor.advance(6);
|
||||
try testing.expectEqual(true, cursor.reachedEnd());
|
||||
try testing.expectString("", cursor.remaining());
|
||||
|
||||
// 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());
|
||||
}
|
||||
|
||||
fn expectMatchesReference(bytes: []const u8) !void {
|
||||
var key_expected: usize = 0;
|
||||
while (key_expected < bytes.len and isHeaderKeyByte(bytes[key_expected])) key_expected += 1;
|
||||
var value_expected: usize = 0;
|
||||
while (value_expected < bytes.len and isHeaderValueByte(bytes[value_expected])) value_expected += 1;
|
||||
|
||||
var cursor = initCursor(bytes);
|
||||
matchHeaderKey(&cursor);
|
||||
try testing.expectEqual(key_expected, consumed(&cursor));
|
||||
|
||||
cursor = initCursor(bytes);
|
||||
matchHeaderValue(&cursor);
|
||||
try testing.expectEqual(value_expected, consumed(&cursor));
|
||||
}
|
||||
|
||||
test "header_parser: 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);
|
||||
try expectMatchesReference(buf[0..len]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
const random = prng.random();
|
||||
for (0..20_000) |_| {
|
||||
const len = random.intRangeAtMost(usize, 0, buf.len);
|
||||
random.bytes(buf[0..len]);
|
||||
try expectMatchesReference(buf[0..len]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user