mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-09-16 16:01:30 -04:00
925 lines
34 KiB
Zig
925 lines
34 KiB
Zig
// 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");
|
|
|
|
/// 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);
|
|
var val_end = cursor.current();
|
|
// Trailing OWS is legal on the wire but not part of the field value;
|
|
// parsers must exclude it (RFC 9112 §5.1). Spaces and HTABs are valid
|
|
// mid-value, so the scan above can't do this on its own.
|
|
while (val_end != val_start and ((val_end - 1)[0] == ' ' or (val_end - 1)[0] == '\t')) : (val_end -= 1) {}
|
|
|
|
// 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 Iterator = struct {
|
|
cursor: Cursor,
|
|
/// Set once the blank line terminating the header block is consumed.
|
|
done: bool = false,
|
|
|
|
pub fn next(self: *Iterator) ParseError!?Header {
|
|
if (self.done) {
|
|
return null;
|
|
}
|
|
// Running out of buffer before the terminating blank line is a
|
|
// truncation, not a clean end; only the blank line yields null.
|
|
if (self.cursor.reachedEnd()) {
|
|
return error.Incomplete;
|
|
}
|
|
// Check if headers part has finished.
|
|
switch (self.cursor.char()) {
|
|
'\n' => {
|
|
// End of headers.
|
|
self.cursor.advance(1);
|
|
self.done = true;
|
|
return null;
|
|
},
|
|
'\r' => {
|
|
// We need an LF too.
|
|
if (!self.cursor.hasLength(2)) {
|
|
return error.Incomplete;
|
|
}
|
|
if (!self.cursor.peek2('\r', '\n')) {
|
|
return error.Invalid;
|
|
}
|
|
// End of headers.
|
|
self.cursor.advance(2);
|
|
self.done = true;
|
|
return null;
|
|
},
|
|
else => {},
|
|
}
|
|
|
|
var header: Header = undefined;
|
|
try header.parse(&self.cursor);
|
|
return header;
|
|
}
|
|
};
|
|
};
|
|
|
|
pub const Method = union(enum) {
|
|
get: void,
|
|
post: void,
|
|
head: void,
|
|
put: void,
|
|
delete: void,
|
|
connect: void,
|
|
options: void,
|
|
trace: void,
|
|
patch: void,
|
|
/// Non-standard HTTP method.
|
|
custom: []const u8,
|
|
};
|
|
|
|
/// Parses an HTTP method, checks for shortest possible request length too.
|
|
fn parseMethod(cursor: *Cursor) !Method {
|
|
// Though the shortest standardized HTTP method is GET, we can receive
|
|
// a single character for method on non-standard ones.
|
|
//
|
|
// A / HTTP/1.1\n
|
|
const min_request_len = 13;
|
|
if (cursor.hasLength(min_request_len) == false) {
|
|
return error.Incomplete;
|
|
}
|
|
|
|
const m_u32: u32 = cursor.asInteger(u32);
|
|
// Advance as much as integer value.
|
|
cursor.advance(4);
|
|
switch (m_u32) {
|
|
// "GET "
|
|
@as(u32, @bitCast([_]u8{ 'G', 'E', 'T', ' ' })) => {
|
|
return .{ .get = {} };
|
|
},
|
|
// "POST"
|
|
@as(u32, @bitCast([_]u8{ 'P', 'O', 'S', 'T' })) => {
|
|
if (cursor.peek(' ')) {
|
|
cursor.advance(1);
|
|
return .{ .post = {} };
|
|
}
|
|
},
|
|
// "HEAD"
|
|
@as(u32, @bitCast([_]u8{ 'H', 'E', 'A', 'D' })) => {
|
|
if (cursor.peek(' ')) {
|
|
cursor.advance(1);
|
|
return .{ .head = {} };
|
|
}
|
|
},
|
|
// "PUT "
|
|
@as(u32, @bitCast([_]u8{ 'P', 'U', 'T', ' ' })) => {
|
|
return .{ .put = {} };
|
|
},
|
|
// "DELE"
|
|
@as(u32, @bitCast([_]u8{ 'D', 'E', 'L', 'E' })) => {
|
|
// "TE "
|
|
if (cursor.peek3('T', 'E', ' ')) {
|
|
cursor.advance(3);
|
|
return .{ .delete = {} };
|
|
}
|
|
},
|
|
// "CONN"
|
|
@as(u32, @bitCast([_]u8{ 'C', 'O', 'N', 'N' })) => {
|
|
// "ECT "
|
|
if (cursor.peek4('E', 'C', 'T', ' ')) {
|
|
cursor.advance(4);
|
|
return .{ .connect = {} };
|
|
}
|
|
},
|
|
// "OPTI"
|
|
@as(u32, @bitCast([_]u8{ 'O', 'P', 'T', 'I' })) => {
|
|
// "ONS "
|
|
if (cursor.peek4('O', 'N', 'S', ' ')) {
|
|
cursor.advance(4);
|
|
return .{ .options = {} };
|
|
}
|
|
},
|
|
// "TRAC"
|
|
@as(u32, @bitCast([_]u8{ 'T', 'R', 'A', 'C' })) => {
|
|
// "E "
|
|
if (cursor.peek2('E', ' ')) {
|
|
cursor.advance(2);
|
|
return .{ .trace = {} };
|
|
}
|
|
},
|
|
// "PATC"
|
|
@as(u32, @bitCast([_]u8{ 'P', 'A', 'T', 'C' })) => {
|
|
// "H "
|
|
if (cursor.peek2('H', ' ')) {
|
|
cursor.advance(2);
|
|
return .{ .patch = {} };
|
|
}
|
|
},
|
|
// Custom method.
|
|
else => {},
|
|
}
|
|
|
|
// That 4 bytes we've consumed earlier might've a delimiter in them.
|
|
cursor.rewind(4);
|
|
// Continue until first space appearance.
|
|
const method_start = cursor.current();
|
|
while (cursor.end - cursor.current() > 0 and cursor.char() != ' ') : (cursor.advance(1)) {}
|
|
if (cursor.reachedEnd()) {
|
|
return error.Incomplete;
|
|
}
|
|
const method_end = cursor.current();
|
|
// 0 length.
|
|
if (method_end == method_start) {
|
|
return error.Invalid;
|
|
}
|
|
// Consume the delimiting space.
|
|
cursor.advance(1);
|
|
return .{ .custom = method_start[0 .. method_end - method_start] };
|
|
}
|
|
|
|
pub const HttpVersion = enum(u1) { @"1.1", @"1.0" };
|
|
|
|
pub fn parseRequest(bytes: []const u8) !struct { Method, []const u8, HttpVersion, Header.Iterator } {
|
|
var cursor = Cursor{ .idx = bytes.ptr, .start = bytes.ptr, .end = bytes.ptr + bytes.len };
|
|
const method = try parseMethod(&cursor);
|
|
// Parse path.
|
|
const path_start = cursor.current();
|
|
// Find the first space.
|
|
while (cursor.end - cursor.current() > 0 and cursor.char() != ' ') : (cursor.advance(1)) {}
|
|
const path_end = cursor.current();
|
|
// 0 length path.
|
|
if (path_start == path_end) {
|
|
return error.Invalid;
|
|
}
|
|
const path = path_start[0 .. path_end - path_start];
|
|
|
|
// Skip past the delimiting space(s); the scan above guarantees we're on
|
|
// a space or at the end, and recipients may parse on whitespace
|
|
// boundaries (RFC 9112 §3).
|
|
cursor.skipSpaces();
|
|
|
|
// HTTP/1.1(\r)\n
|
|
if (cursor.hasLength(9) == false) {
|
|
return error.Incomplete;
|
|
}
|
|
const version: HttpVersion = switch (cursor.asInteger(u64)) {
|
|
@as(u64, @bitCast(@as([]const u8, "HTTP/1.1")[0..8].*)) => .@"1.1",
|
|
@as(u64, @bitCast(@as([]const u8, "HTTP/1.0")[0..8].*)) => .@"1.0",
|
|
else => return error.Invalid,
|
|
};
|
|
cursor.advance(8);
|
|
|
|
// Trailing (CR)LF.
|
|
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,
|
|
}
|
|
|
|
return .{ method, path, version, .{ .cursor = cursor } };
|
|
}
|
|
|
|
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 {
|
|
// `Header.parse` already excludes the surrounding OWS from the value.
|
|
var rest = value;
|
|
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;
|
|
}
|
|
|
|
/// Rewinds the position of the cursor by given value.
|
|
/// SAFETY: This function doesn't check if out of bounds reachable.
|
|
pub fn rewind(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 character but don't advance.
|
|
pub fn peek(cursor: *const Cursor, c: u8) bool {
|
|
return cursor.idx[0] == c;
|
|
}
|
|
|
|
/// 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 }));
|
|
}
|
|
|
|
/// Peek the current and next 2 characters but don't advance.
|
|
/// SAFETY: This function doesn't check if out of bounds reachable.
|
|
pub fn peek3(cursor: *const Cursor, c0: u8, c1: u8, c2: u8) bool {
|
|
return cursor.idx[0] == c0 and cursor.idx[1] == c1 and cursor.idx[2] == c2;
|
|
}
|
|
|
|
/// Peek the current and next 3 characters but don't advance.
|
|
/// SAFETY: This function doesn't check if out of bounds reachable.
|
|
pub fn peek4(cursor: *const Cursor, c0: u8, c1: u8, c2: u8, c3: u8) bool {
|
|
return cursor.asInteger(u32) == @as(u32, @bitCast([4]u8{ c0, c1, c2, c3 }));
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
|
|
fn drain(it: *Header.Iterator) Header.ParseError!void {
|
|
while (try it.next()) |_| {}
|
|
}
|
|
|
|
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 and trailing whitespace of the value — spaces and HTABs — is
|
|
// excluded from the field value (RFC 9112 §5.1).
|
|
cursor = initCursor("Key: \t value \t\r\n");
|
|
try header.parse(&cursor);
|
|
try testing.expectString("value", header.value);
|
|
|
|
// 0 length values are fine, including whitespace-only ones.
|
|
cursor = initCursor("Key:\r\n");
|
|
try header.parse(&cursor);
|
|
try testing.expectString("Key", header.key);
|
|
try testing.expectString("", header.value);
|
|
cursor = initCursor("Key: \t \r\n");
|
|
try header.parse(&cursor);
|
|
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: header iterator" {
|
|
// Headers iterate until the terminating blank line; afterwards the
|
|
// iterator stays exhausted and the cursor is left at the body.
|
|
var it = Header.Iterator{ .cursor = initCursor("Host: a\r\nUpgrade: websocket\n\r\nbody") };
|
|
var header = (try it.next()).?;
|
|
try testing.expectString("Host", header.key);
|
|
try testing.expectString("a", header.value);
|
|
header = (try it.next()).?;
|
|
try testing.expectString("Upgrade", header.key);
|
|
try testing.expectString("websocket", header.value);
|
|
try testing.expectEqual(null, try it.next());
|
|
try testing.expectEqual(null, try it.next());
|
|
try testing.expectString("body", it.cursor.remaining());
|
|
|
|
// A blank line right away means no headers at all; both endings work.
|
|
it = .{ .cursor = initCursor("\r\n") };
|
|
try testing.expectEqual(null, try it.next());
|
|
it = .{ .cursor = initCursor("\n") };
|
|
try testing.expectEqual(null, try it.next());
|
|
|
|
// Running out of buffer before the terminating blank line is a
|
|
// truncation, not a clean end.
|
|
const truncated_cases = [_][]const u8{
|
|
"", // nothing at all
|
|
"Host: a\r\n", // header block never terminated
|
|
"Host: a\r\nUpg", // buffer ends mid-header
|
|
"\r", // `\r` still missing its `\n`
|
|
};
|
|
for (truncated_cases) |case| {
|
|
it = .{ .cursor = initCursor(case) };
|
|
try testing.expectError(error.Incomplete, drain(&it));
|
|
}
|
|
|
|
// Malformed headers surface as `Invalid`.
|
|
it = .{ .cursor = initCursor("Key\x01: v\r\n\r\n") };
|
|
try testing.expectError(error.Invalid, drain(&it));
|
|
it = .{ .cursor = initCursor("\rX\r\n") };
|
|
try testing.expectError(error.Invalid, drain(&it));
|
|
}
|
|
|
|
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; whitespace between
|
|
// parameters is tolerated (the value's surrounding OWS is already
|
|
// excluded by `Header.parse`).
|
|
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());
|
|
}
|
|
|
|
test "header_parser: parse request" {
|
|
// Request line plus headers; the returned iterator picks up right after
|
|
// the line ending.
|
|
const bytes = "GET /json/version HTTP/1.1\r\nHost: a\r\n\r\n";
|
|
const method, const path, const version, var it = try parseRequest(bytes);
|
|
try testing.expectEqual(true, method == .get);
|
|
try testing.expectString("/json/version", path);
|
|
try testing.expectEqual(.@"1.1", version);
|
|
const header = (try it.next()).?;
|
|
try testing.expectString("Host", header.key);
|
|
try testing.expectString("a", header.value);
|
|
try testing.expectEqual(null, try it.next());
|
|
|
|
// HTTP/1.0 and lone `\n` line endings are accepted too.
|
|
const method10, _, const version10, _ = try parseRequest("GET / HTTP/1.0\n\n");
|
|
try testing.expectEqual(true, method10 == .get);
|
|
try testing.expectEqual(.@"1.0", version10);
|
|
|
|
// Multiple spaces before the version are tolerated (RFC 9112 §3).
|
|
const req = try parseRequest("GET / HTTP/1.1\r\n\r\n");
|
|
try testing.expectString("/", req[1]);
|
|
|
|
// Longer paths are accepted (but not validated).
|
|
const req2 = try parseRequest("GET /chat/room?id=1 HTTP/1.1\r\n\r\n");
|
|
try testing.expectString("/chat/room?id=1", req2[1]);
|
|
}
|
|
|
|
test "header_parser: parse request methods" {
|
|
const cases = [_]struct { []const u8, std.meta.Tag(Method) }{
|
|
.{ "GET / HTTP/1.1\r\n\r\n", .get },
|
|
.{ "POST / HTTP/1.1\r\n\r\n", .post },
|
|
.{ "HEAD / HTTP/1.1\r\n\r\n", .head },
|
|
.{ "PUT / HTTP/1.1\r\n\r\n", .put },
|
|
.{ "DELETE / HTTP/1.1\r\n\r\n", .delete },
|
|
.{ "CONNECT / HTTP/1.1\r\n\r\n", .connect },
|
|
.{ "OPTIONS / HTTP/1.1\r\n\r\n", .options },
|
|
.{ "TRACE / HTTP/1.1\r\n\r\n", .trace },
|
|
.{ "PATCH / HTTP/1.1\r\n\r\n", .patch },
|
|
};
|
|
for (cases) |case| {
|
|
const method, _, _, _ = try parseRequest(case[0]);
|
|
try testing.expectEqual(case[1], std.meta.activeTag(method));
|
|
}
|
|
|
|
// Unknown methods fall back to `custom`; a shared 4-byte prefix with a
|
|
// standard method or a single character both take that path.
|
|
const custom_cases = [_]struct { []const u8, []const u8 }{
|
|
.{ "BREW / HTTP/1.1\r\n\r\n", "BREW" },
|
|
.{ "POSTER / HTTP/1.1\r\n\r\n", "POSTER" },
|
|
.{ "GETX / HTTP/1.1\r\n\r\n", "GETX" },
|
|
.{ "A / HTTP/1.1\r\n\r\n", "A" },
|
|
};
|
|
for (custom_cases) |case| {
|
|
const method, _, _, _ = try parseRequest(case[0]);
|
|
try testing.expectString(case[1], method.custom);
|
|
}
|
|
}
|
|
|
|
test "header_parser: parse request invalid" {
|
|
const invalid_cases = [_][]const u8{
|
|
" / HTTP/1.1\r\n\r\n", // 0 length method
|
|
"GET HTTP/1.1\r\n\r\n", // 0 length path
|
|
"GET / HTTP/1.1\r\n\r\n", // double space, so a 0 length path
|
|
"GET / HTTP/2.0\r\n\r\n", // unknown version
|
|
"GET / http/1.1\r\n\r\n", // the version is case-sensitive
|
|
"GET / HTTP/1.1X\r\n\r\n", // junk after the version
|
|
"GET / HTTP/1.1\rX\n\r\n", // `\r` must be followed by `\n`
|
|
};
|
|
for (invalid_cases) |case| {
|
|
try testing.expectError(error.Invalid, parseRequest(case));
|
|
}
|
|
|
|
// Buffer may end anywhere before the request line ending is complete;
|
|
// `DELETE` exercises the multi-byte peek continuation and the path is
|
|
// longer than the minimum request length, proving neither scan can run
|
|
// past the end of a truncated buffer.
|
|
const bytes = "DELETE /websocket/endpoint HTTP/1.1\r\n";
|
|
for (0..bytes.len) |len| {
|
|
try testing.expectError(error.Incomplete, parseRequest(bytes[0..len]));
|
|
}
|
|
}
|
|
|
|
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]);
|
|
}
|
|
}
|