mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-07-31 17:55:59 -04:00
Merge pull request #2757 from lightpanda-io/blob_mimesniff
wpt: Better blob mime handling
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const Mime = @This();
|
||||
content_type: ContentType,
|
||||
@@ -90,29 +91,6 @@ pub fn charsetString(mime: *const Mime) []const u8 {
|
||||
return mime.charset[0..mime.charset_len];
|
||||
}
|
||||
|
||||
/// Removes quotes of value if quotes are given.
|
||||
///
|
||||
/// Currently we don't validate the charset.
|
||||
/// See section 2.3 Naming Requirements:
|
||||
/// https://datatracker.ietf.org/doc/rfc2978/
|
||||
fn parseCharset(value: []const u8) error{ CharsetTooBig, Invalid }![]const u8 {
|
||||
// Cannot be larger than 40.
|
||||
// https://datatracker.ietf.org/doc/rfc2978/
|
||||
if (value.len > 40) return error.CharsetTooBig;
|
||||
|
||||
// If the first char is a quote, look for a pair.
|
||||
if (value[0] == '"') {
|
||||
if (value.len < 3 or value[value.len - 1] != '"') {
|
||||
return error.Invalid;
|
||||
}
|
||||
|
||||
return value[1 .. value.len - 1];
|
||||
}
|
||||
|
||||
// No quotes.
|
||||
return value;
|
||||
}
|
||||
|
||||
pub fn parse(input: []const u8) !Mime {
|
||||
if (input.len > 255) {
|
||||
return error.TooBig;
|
||||
@@ -128,40 +106,17 @@ pub fn parse(input: []const u8) !Mime {
|
||||
return .{ .content_type = content_type };
|
||||
}
|
||||
|
||||
const params = trimLeft(normalized[type_len..]);
|
||||
|
||||
var charset: [41]u8 = default_charset;
|
||||
var charset_len: usize = default_charset_len;
|
||||
var has_explicit_charset = false;
|
||||
|
||||
var it = std.mem.splitScalar(u8, params, ';');
|
||||
while (it.next()) |attr| {
|
||||
const i = std.mem.indexOfScalarPos(u8, attr, 0, '=') orelse continue;
|
||||
const name = trimLeft(attr[0..i]);
|
||||
|
||||
const value = trimRight(attr[i + 1 ..]);
|
||||
if (value.len == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const attribute_name = std.meta.stringToEnum(enum {
|
||||
charset,
|
||||
}, name) orelse continue;
|
||||
|
||||
switch (attribute_name) {
|
||||
.charset => {
|
||||
if (value.len == 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const attribute_value = parseCharset(value) catch continue;
|
||||
@memcpy(charset[0..attribute_value.len], attribute_value);
|
||||
// Null-terminate right after attribute value.
|
||||
charset[attribute_value.len] = 0;
|
||||
charset_len = attribute_value.len;
|
||||
has_explicit_charset = true;
|
||||
},
|
||||
}
|
||||
// normalized[type_len - 1] is the ';' that terminated the type.
|
||||
var value_buf: [40]u8 = undefined;
|
||||
if (firstCharsetValue(normalized[type_len - 1 ..], &value_buf)) |value| {
|
||||
@memcpy(charset[0..value.len], value);
|
||||
charset[value.len] = 0;
|
||||
charset_len = value.len;
|
||||
has_explicit_charset = true;
|
||||
}
|
||||
|
||||
mime.charset = charset;
|
||||
@@ -466,8 +421,298 @@ fn validType(value: []const u8) bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
fn trimLeft(s: []const u8) []const u8 {
|
||||
return std.mem.trimLeft(u8, s, &std.ascii.whitespace);
|
||||
/// Parse `input` as a MIME type and return its serialization, or "" on
|
||||
/// failure. Unlike `parse` (which classifies a Content-Type for sniffing
|
||||
/// and only tracks charset), this preserves every parameter.
|
||||
pub fn serialize(arena: Allocator, input: []const u8) ![]const u8 {
|
||||
const trimmed = std.mem.trim(u8, input, &HTTP_WHITESPACE);
|
||||
if (trimmed.len == 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// type "/" subtype
|
||||
const slash = std.mem.indexOfScalarPos(u8, trimmed, 0, '/') orelse return "";
|
||||
const type_name = trimmed[0..slash];
|
||||
if (isHttpToken(type_name) == false) {
|
||||
return "";
|
||||
}
|
||||
|
||||
var rest = trimmed[slash + 1 ..];
|
||||
const subtype_end = std.mem.indexOfScalar(u8, rest, ';') orelse rest.len;
|
||||
const subtype = std.mem.trimRight(u8, rest[0..subtype_end], &HTTP_WHITESPACE);
|
||||
if (isHttpToken(subtype) == false) {
|
||||
return "";
|
||||
}
|
||||
|
||||
var out: std.ArrayList(u8) = try .initCapacity(arena, type_name.len + 1 + subtype.len);
|
||||
for (type_name) |c| {
|
||||
out.appendAssumeCapacity(std.ascii.toLower(c));
|
||||
}
|
||||
out.appendAssumeCapacity('/');
|
||||
for (subtype) |c| {
|
||||
out.appendAssumeCapacity(std.ascii.toLower(c));
|
||||
}
|
||||
|
||||
if (subtype_end >= rest.len) {
|
||||
return out.items;
|
||||
}
|
||||
|
||||
rest = rest[subtype_end..]; // positioned at the first ';'
|
||||
|
||||
// The serialized output is the input length plus quoting overhead; reserve
|
||||
// the input length so the common (no-escape) case appends without growing.
|
||||
try out.ensureTotalCapacity(arena, trimmed.len);
|
||||
|
||||
// Lowercased names already emitted, for first-wins dedupe.
|
||||
var seen: std.ArrayList([]const u8) = .empty;
|
||||
// One scratch buffer, reused to unescape quoted values across iterations.
|
||||
var quoted: std.ArrayList(u8) = .empty;
|
||||
|
||||
var i: usize = 0;
|
||||
while (i < rest.len) {
|
||||
i += 1; // skip ';'
|
||||
while (i < rest.len and isHttpWhitespace(rest[i])) i += 1;
|
||||
|
||||
// parameter name: up to ';' or '='
|
||||
const name_start = i;
|
||||
while (i < rest.len and rest[i] != ';' and rest[i] != '=') i += 1;
|
||||
const name = rest[name_start..i];
|
||||
|
||||
if (i >= rest.len) break;
|
||||
if (rest[i] == ';') continue;
|
||||
i += 1; // skip '='
|
||||
|
||||
// A quoted value is unescaped into `quoted`; an unquoted value is used
|
||||
// in place.
|
||||
var value: []const u8 = undefined;
|
||||
if (i < rest.len and rest[i] == '"') {
|
||||
quoted.clearRetainingCapacity();
|
||||
i += 1; // opening quote
|
||||
while (i < rest.len) : (i += 1) {
|
||||
const c = rest[i];
|
||||
if (c == '\\') {
|
||||
if (i + 1 < rest.len) {
|
||||
i += 1;
|
||||
try quoted.append(arena, rest[i]);
|
||||
} else {
|
||||
try quoted.append(arena, '\\');
|
||||
}
|
||||
} else if (c == '"') {
|
||||
i += 1; // closing quote
|
||||
break;
|
||||
} else {
|
||||
try quoted.append(arena, c);
|
||||
}
|
||||
}
|
||||
// ignore any remaining bytes up to the next ';'
|
||||
while (i < rest.len and rest[i] != ';') i += 1;
|
||||
value = quoted.items;
|
||||
} else {
|
||||
const value_start = i;
|
||||
while (i < rest.len and rest[i] != ';') i += 1;
|
||||
value = std.mem.trimRight(u8, rest[value_start..i], &HTTP_WHITESPACE);
|
||||
if (value.len == 0) continue; // empty unquoted value is dropped
|
||||
}
|
||||
|
||||
if (isHttpToken(name) == false) continue;
|
||||
if (isQuotedStringValue(value) == false) continue;
|
||||
|
||||
const lname = try std.ascii.allocLowerString(arena, name);
|
||||
for (seen.items) |s| {
|
||||
if (std.mem.eql(u8, s, lname)) break;
|
||||
} else {
|
||||
try seen.append(arena, lname);
|
||||
try out.append(arena, ';');
|
||||
try out.appendSlice(arena, lname);
|
||||
try out.append(arena, '=');
|
||||
try appendParameterValue(arena, &out, value);
|
||||
}
|
||||
}
|
||||
|
||||
return out.items;
|
||||
}
|
||||
|
||||
/// HTTP token code points (RFC 7230 §3.2.6). Note these differ from
|
||||
/// `VALID_CODEPOINTS` above (which also allows `\`).
|
||||
const HTTP_TOKEN = blk: {
|
||||
var v: [256]bool = undefined;
|
||||
for (0..256) |i| {
|
||||
v[i] = switch (i) {
|
||||
'!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', '^', '_', '`', '|', '~' => true,
|
||||
else => std.ascii.isAlphanumeric(@intCast(i)),
|
||||
};
|
||||
}
|
||||
break :blk v;
|
||||
};
|
||||
|
||||
/// Whether `s` is a non-empty sequence of HTTP token code points. This is also
|
||||
/// the definition of a valid header name, so `Headers` reuses it.
|
||||
pub fn isHttpToken(s: []const u8) bool {
|
||||
if (s.len == 0) {
|
||||
return false;
|
||||
}
|
||||
for (s) |b| {
|
||||
if (HTTP_TOKEN[b] == false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Whether every code point in `value` is an "HTTP quoted-string token code
|
||||
/// point" (HT, 0x20-0x7E, or 0x80-0xFF). Decodes UTF-8 so that a code point
|
||||
/// above U+00FF (e.g. U+FFFD) is rejected even though its bytes are each >=
|
||||
/// 0x80 — the algorithm operates on code points, not bytes.
|
||||
/// https://mimesniff.spec.whatwg.org/#http-quoted-string-token-code-point
|
||||
fn isQuotedStringValue(value: []const u8) bool {
|
||||
var i: usize = 0;
|
||||
while (i < value.len) {
|
||||
const n = std.unicode.utf8ByteSequenceLength(value[i]) catch return false;
|
||||
if (i + n > value.len) {
|
||||
return false;
|
||||
}
|
||||
const cp = std.unicode.utf8Decode(value[i..][0..n]) catch return false;
|
||||
|
||||
if (!(cp == 0x09 or (cp >= 0x20 and cp <= 0x7E) or (cp >= 0x80 and cp <= 0xFF))) {
|
||||
return false;
|
||||
}
|
||||
i += n;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// HTTP whitespace: HT, LF, CR, SP. Deliberately excludes FF (0x0C) and VT
|
||||
/// (0x0B), both of which `std.ascii.whitespace` includes — neither is HTTP
|
||||
/// whitespace, so `\x0cx/x` must fail rather than strip to `x/x`.
|
||||
pub const HTTP_WHITESPACE = [_]u8{ 0x09, 0x0A, 0x0D, 0x20 };
|
||||
|
||||
fn isHttpWhitespace(b: u8) bool {
|
||||
inline for (HTTP_WHITESPACE) |whitespace| {
|
||||
if (b == whitespace) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Append a parameter value, quoting (and escaping `"`/`\`) when it is empty
|
||||
/// or contains a non-HTTP-token byte.
|
||||
fn appendParameterValue(arena: Allocator, out: *std.ArrayList(u8), value: []const u8) !void {
|
||||
if (value.len == 0) {
|
||||
return out.appendSlice(arena, "\"\"");
|
||||
}
|
||||
|
||||
// The clean prefix (up to the first non-token byte) needs no quoting; if
|
||||
// the whole value is tokens, it needs no quotes at all. Neither `"` nor `\`
|
||||
// is a token byte, so the prefix can be copied without escaping.
|
||||
const quote_from = for (value, 0..) |b, i| {
|
||||
if (HTTP_TOKEN[b] == false) break i;
|
||||
} else return out.appendSlice(arena, value);
|
||||
|
||||
// Upper bound: two quotes plus, worst case, a '\' escape for every byte.
|
||||
try out.ensureUnusedCapacity(arena, 2 * value.len + 2);
|
||||
out.appendAssumeCapacity('"');
|
||||
out.appendSliceAssumeCapacity(value[0..quote_from]);
|
||||
for (value[quote_from..]) |b| {
|
||||
if (b == '"' or b == '\\') {
|
||||
out.appendAssumeCapacity('\\');
|
||||
}
|
||||
out.appendAssumeCapacity(b);
|
||||
}
|
||||
out.appendAssumeCapacity('"');
|
||||
}
|
||||
|
||||
/// Find the value of the first valid `charset` parameter, per the WHATWG MIME
|
||||
/// parameter algorithm: parameters are scanned in order, quoted strings honor
|
||||
/// `\` escapes and may contain `;`, and the first parameter whose name is
|
||||
/// `charset` and whose value is non-empty and contains only HTTP
|
||||
/// quoted-string token code points wins. The (unescaped) value is written into
|
||||
/// `out`; values longer than `out` are treated as no match. `rest` must begin
|
||||
/// at the ';' that ends the subtype, and the input must already be lowercased
|
||||
/// (names are matched case-sensitively against "charset").
|
||||
fn firstCharsetValue(rest: []const u8, out: []u8) ?[]const u8 {
|
||||
var i: usize = 0;
|
||||
while (i < rest.len) {
|
||||
i += 1; // skip ';'
|
||||
while (i < rest.len and isHttpWhitespace(rest[i])) {
|
||||
i += 1;
|
||||
}
|
||||
|
||||
const name_start = i;
|
||||
while (i < rest.len and rest[i] != ';' and rest[i] != '=') {
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if (i >= rest.len) {
|
||||
break;
|
||||
}
|
||||
if (rest[i] == ';') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const want = std.mem.eql(u8, rest[name_start..i], "charset");
|
||||
i += 1; // skip '='
|
||||
|
||||
var len: usize = 0;
|
||||
var overflow = false;
|
||||
const put = struct {
|
||||
fn put(o: []u8, l: *usize, of: *bool, b: u8) void {
|
||||
if (l.* < o.len) {
|
||||
o[l.*] = b;
|
||||
l.* += 1;
|
||||
} else of.* = true;
|
||||
}
|
||||
}.put;
|
||||
|
||||
if (i < rest.len and rest[i] == '"') {
|
||||
i += 1; // opening quote
|
||||
while (i < rest.len) : (i += 1) {
|
||||
const c = rest[i];
|
||||
if (c == '\\') {
|
||||
if (i + 1 < rest.len) {
|
||||
i += 1;
|
||||
if (want) {
|
||||
put(out, &len, &overflow, rest[i]);
|
||||
}
|
||||
} else if (want) {
|
||||
put(out, &len, &overflow, '\\');
|
||||
}
|
||||
} else if (c == '"') {
|
||||
i += 1; // closing quote
|
||||
break;
|
||||
} else if (want) {
|
||||
put(out, &len, &overflow, c);
|
||||
}
|
||||
}
|
||||
while (i < rest.len and rest[i] != ';') {
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
const value_start = i;
|
||||
while (i < rest.len and rest[i] != ';') {
|
||||
i += 1;
|
||||
}
|
||||
|
||||
const v = std.mem.trimRight(u8, rest[value_start..i], &HTTP_WHITESPACE);
|
||||
if (v.len == 0) {
|
||||
// empty unquoted value is dropped
|
||||
continue;
|
||||
}
|
||||
|
||||
if (want) {
|
||||
if (v.len <= out.len) {
|
||||
@memcpy(out[0..v.len], v);
|
||||
len = v.len;
|
||||
} else overflow = true;
|
||||
}
|
||||
}
|
||||
|
||||
// First *valid* charset wins; otherwise keep scanning.
|
||||
if (want and overflow == false and isQuotedStringValue(out[0..len])) {
|
||||
return out[0..len];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fn trimRight(s: []const u8) []const u8 {
|
||||
@@ -600,6 +845,29 @@ test "Mime: parse charset" {
|
||||
}, "text/html;x=\"");
|
||||
}
|
||||
|
||||
test "Mime: parse charset (WHATWG parameter semantics)" {
|
||||
defer testing.reset();
|
||||
|
||||
// First charset wins (not last).
|
||||
try expect(.{ .content_type = .{ .text_html = {} }, .charset = "gbk" }, "text/html;charset=gbk;charset=utf-8");
|
||||
|
||||
// Backslash escapes inside a quoted value are unescaped.
|
||||
try expect(.{ .content_type = .{ .text_html = {} }, .charset = "gbk" }, "text/html;charset=\"\\g\\b\\k\"");
|
||||
|
||||
// A ';' inside a quoted value is part of the value, not a separator.
|
||||
try expect(.{ .content_type = .{ .text_html = {} }, .charset = "a;b" }, "text/html;charset=\"a;b\";charset=utf-8");
|
||||
|
||||
// A charset whose value isn't all quoted-string tokens (VT here) is
|
||||
// skipped, so the next valid charset wins.
|
||||
try expect(.{ .content_type = .{ .text_html = {} }, .charset = "utf-8" }, "text/html;charset=\x0bgbk;charset=utf-8");
|
||||
|
||||
// A trailing space makes the name "charset " (not "charset"): no charset.
|
||||
try expect(.{ .content_type = .{ .text_html = {} }, .charset = "UTF-8" }, "text/html;charset =gbk");
|
||||
|
||||
// A long preceding parameter doesn't hide a later charset.
|
||||
try expect(.{ .content_type = .{ .text_html = {} }, .charset = "gbk" }, "text/html;" ++ ("a" ** 130) ++ "=x;charset=gbk");
|
||||
}
|
||||
|
||||
test "Mime: isHTML" {
|
||||
defer testing.reset();
|
||||
|
||||
@@ -724,6 +992,48 @@ fn expect(expected: Expectation, input: []const u8) !void {
|
||||
}
|
||||
}
|
||||
|
||||
test "Mime: serialize" {
|
||||
defer testing.reset();
|
||||
const arena = testing.arena_allocator;
|
||||
|
||||
const expectSerialize = struct {
|
||||
fn call(a: Allocator, input: []const u8, expected: []const u8) !void {
|
||||
try testing.expectString(expected, try Mime.serialize(a, input));
|
||||
}
|
||||
}.call;
|
||||
|
||||
// Essence: lowercased, params preserved verbatim where valid.
|
||||
try expectSerialize(arena, "x/x;bonus=x", "x/x;bonus=x");
|
||||
try expectSerialize(arena, "TEXT/HTML;CHARSET=GBK", "text/html;charset=GBK");
|
||||
try expectSerialize(arena, "text/html;charset=gbk;charset=windows-1255", "text/html;charset=gbk"); // first wins
|
||||
try expectSerialize(arena, "text/html;test;charset=gbk", "text/html;charset=gbk"); // valueless param dropped
|
||||
|
||||
// Quoting on serialize.
|
||||
try expectSerialize(arena, "text/html;charset=gbk(", "text/html;charset=\"gbk(\"");
|
||||
try expectSerialize(arena, "text/html;charset= gbk", "text/html;charset=\" gbk\"");
|
||||
try expectSerialize(arena, "text/html;charset=gbk\"", "text/html;charset=\"gbk\\\"\"");
|
||||
try expectSerialize(arena, "text/html;charset=\"\\g\\b\\k\"", "text/html;charset=gbk"); // unescape
|
||||
try expectSerialize(arena, "text/html;charset=\"\";charset=GBK", "text/html;charset=\"\""); // empty quoted kept
|
||||
|
||||
// Leading/trailing HTTP whitespace (TAB LF CR SP) is stripped; FF/VT are not.
|
||||
try expectSerialize(arena, "\n\r\t x/x;x=x \n\r\t ", "x/x;x=x");
|
||||
try expectSerialize(arena, "\x0cx/x", ""); // FF is not HTTP whitespace -> invalid type
|
||||
try expectSerialize(arena, "x/x\x0c", ""); // FF in subtype -> invalid
|
||||
try expectSerialize(arena, "text/html;\x0ccharset=gbk", "text/html"); // FF before name -> dropped
|
||||
|
||||
// A parameter value containing a code point above U+00FF (here U+FFFD) is
|
||||
// dropped, even though its UTF-8 bytes are each >= 0x80.
|
||||
try expectSerialize(arena, "x/x;test=\u{FFFD};x=x", "x/x;x=x");
|
||||
// 0x80-0xFF round-trips (kept, quoted).
|
||||
try expectSerialize(arena, "x/x;x=\u{00A1};bonus=x", "x/x;x=\"\u{00A1}\";bonus=x");
|
||||
|
||||
// Total failures.
|
||||
try expectSerialize(arena, "", "");
|
||||
try expectSerialize(arena, "x", "");
|
||||
try expectSerialize(arena, "/x", "");
|
||||
try expectSerialize(arena, "x/", "");
|
||||
}
|
||||
|
||||
test "Mime: prescanCharset" {
|
||||
// <meta charset="X">
|
||||
try testing.expectEqual("utf-8", Mime.prescanCharset("<html><head><meta charset=\"utf-8\">").?);
|
||||
|
||||
@@ -204,24 +204,25 @@
|
||||
testing.expectEqual("application/json", blob.type);
|
||||
}
|
||||
|
||||
// MIME with parameters - lowercased
|
||||
// MIME is parsed and re-serialized: optional whitespace dropped, essence
|
||||
// and parameter names lowercased, but parameter values preserved as-is.
|
||||
{
|
||||
const blob = new Blob([], { type: "text/html; charset=UTF-8" });
|
||||
testing.expectEqual("text/html; charset=utf-8", blob.type);
|
||||
testing.expectEqual("text/html;charset=UTF-8", blob.type);
|
||||
}
|
||||
|
||||
// Any ASCII string is accepted and lowercased (no MIME structure validation)
|
||||
// A string that fails to parse as a MIME type yields the empty string.
|
||||
{
|
||||
const blob = new Blob([], { type: "invalid" });
|
||||
testing.expectEqual("invalid", blob.type);
|
||||
testing.expectEqual("", blob.type);
|
||||
}
|
||||
|
||||
{
|
||||
const blob = new Blob([], { type: "/" });
|
||||
testing.expectEqual("/", blob.type);
|
||||
testing.expectEqual("", blob.type);
|
||||
}
|
||||
|
||||
// Non-ASCII characters cause empty string (chars outside U+0020-U+007E)
|
||||
// Non-ASCII characters cause empty string (not HTTP token code points)
|
||||
{
|
||||
const blob = new Blob([], { type: "ý/x" });
|
||||
testing.expectEqual("", blob.type);
|
||||
@@ -267,7 +268,8 @@
|
||||
|
||||
temp = blob.slice(-4, -2, "custom");
|
||||
testing.expectEqual(2, temp.size);
|
||||
testing.expectEqual("custom", temp.type);
|
||||
// "custom" is not a valid MIME type (no "/"), so the type is empty.
|
||||
testing.expectEqual("", temp.type);
|
||||
testing.async(async () => {
|
||||
testing.expectEqual("ai", await temp.text());
|
||||
});
|
||||
|
||||
@@ -159,6 +159,36 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=value-validation>
|
||||
// The constructor validates names/values and normalizes values per Fetch.
|
||||
{
|
||||
// Leading/trailing HTTP whitespace in values is stripped.
|
||||
const headers = new Headers([["Content-Type", " text/html\t"]]);
|
||||
testing.expectEqual("text/html", headers.get("content-type"));
|
||||
}
|
||||
|
||||
// An invalid header name throws a TypeError.
|
||||
{
|
||||
let threw = false;
|
||||
try { new Headers([["a b", "x"]]); } catch (e) { threw = e instanceof TypeError; }
|
||||
testing.expectEqual(true, threw);
|
||||
}
|
||||
|
||||
// A value with a forbidden byte (here an interior newline) throws a TypeError.
|
||||
{
|
||||
let threw = false;
|
||||
try { new Headers([["x", "a\nb"]]); } catch (e) { threw = e instanceof TypeError; }
|
||||
testing.expectEqual(true, threw);
|
||||
}
|
||||
|
||||
// A value with a code point above U+00FF cannot be a byte string: TypeError.
|
||||
{
|
||||
let threw = false;
|
||||
try { new Headers({ "x": "Ā" }); } catch (e) { threw = e instanceof TypeError; }
|
||||
testing.expectEqual(true, threw);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=copy-constructor>
|
||||
// Test creating Headers from another Headers object
|
||||
{
|
||||
|
||||
@@ -67,7 +67,7 @@ pub fn init(parts_: ?[]const js.Value, opts_: ?InitOptions, page: *Page) !*Blob
|
||||
errdefer session.releaseArena(arena);
|
||||
|
||||
const opts: InitOptions = opts_ orelse .{};
|
||||
const mime = try validateMimeType(arena, opts.type, false);
|
||||
const mime = try Mime.serialize(arena, opts.type);
|
||||
|
||||
const data = blk: {
|
||||
if (parts_) |blob_parts| {
|
||||
@@ -95,11 +95,11 @@ pub fn init(parts_: ?[]const js.Value, opts_: ?InitOptions, page: *Page) !*Blob
|
||||
}
|
||||
|
||||
/// Creates a new Blob from raw byte slices (for internal Zig use).
|
||||
pub fn initFromBytes(data: []const u8, content_type: []const u8, validate_mime: bool, page: *Page) !*Blob {
|
||||
pub fn initFromBytes(data: []const u8, content_type: []const u8, page: *Page) !*Blob {
|
||||
const arena = try page.getArena(.large, "Blob");
|
||||
errdefer page.releaseArena(arena);
|
||||
|
||||
const mime = try validateMimeType(arena, content_type, validate_mime);
|
||||
const mime = try Mime.serialize(arena, content_type);
|
||||
|
||||
const self = try arena.create(Blob);
|
||||
self.* = .{
|
||||
@@ -112,32 +112,6 @@ pub fn initFromBytes(data: []const u8, content_type: []const u8, validate_mime:
|
||||
return self;
|
||||
}
|
||||
|
||||
/// Validates and normalizes MIME type according to spec.
|
||||
fn validateMimeType(arena: Allocator, mime_type: []const u8, full_validation: bool) ![]const u8 {
|
||||
if (mime_type.len == 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const buf = try arena.dupe(u8, mime_type);
|
||||
|
||||
if (full_validation) {
|
||||
// Full MIME parsing per MIME sniff spec (for Content-Type headers)
|
||||
_ = Mime.parse(buf) catch return "";
|
||||
} else {
|
||||
// Simple validation per FileAPI spec (for Blob constructor):
|
||||
// any char outside U+0020-U+007E yields the empty string.
|
||||
for (mime_type) |c| {
|
||||
if (c < 0x20 or c > 0x7E) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FileAPI: Blob.type is the type converted to ASCII lowercase.
|
||||
_ = std.ascii.lowerString(buf, buf);
|
||||
return buf;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Blob, page: *Page) void {
|
||||
page.releaseArena(self._arena);
|
||||
}
|
||||
@@ -313,7 +287,7 @@ pub fn slice(
|
||||
break :blk @min(data.len, @max(start, @as(u31, @intCast(requested_end))));
|
||||
};
|
||||
|
||||
return Blob.initFromBytes(data[start..end], content_type_ orelse "", false, page);
|
||||
return Blob.initFromBytes(data[start..end], content_type_ orelse "", page);
|
||||
}
|
||||
|
||||
/// Returns the size of the Blob in bytes.
|
||||
|
||||
@@ -597,7 +597,7 @@ test "FormData: multipart empty body" {
|
||||
const Blob = @import("../Blob.zig");
|
||||
|
||||
fn buildTestFile(arena: Allocator, page: *@import("../../Page.zig"), name: []const u8, mime: []const u8, body: []const u8) !*File {
|
||||
const blob = try Blob.initFromBytes(body, mime, false, page);
|
||||
const blob = try Blob.initFromBytes(body, mime, page);
|
||||
blob.acquireRef();
|
||||
const file = try arena.create(File);
|
||||
file.* = .{
|
||||
|
||||
@@ -2,6 +2,7 @@ const std = @import("std");
|
||||
const lp = @import("lightpanda");
|
||||
|
||||
const js = @import("../../js/js.zig");
|
||||
const Mime = @import("../../Mime.zig");
|
||||
|
||||
const KeyValueList = @import("../KeyValueList.zig");
|
||||
|
||||
@@ -20,11 +21,22 @@ pub const InitOpts = union(enum) {
|
||||
};
|
||||
|
||||
pub fn init(opts_: ?InitOpts, exec: *const Execution) !*Headers {
|
||||
const list = if (opts_) |opts| switch (opts) {
|
||||
.obj => |obj| try KeyValueList.copy(exec.arena, obj._list),
|
||||
.js_obj => |js_obj| try KeyValueList.fromJsObject(exec.arena, js_obj, normalizeHeaderName, exec.buf),
|
||||
.strings => |kvs| try KeyValueList.fromArray(exec.arena, kvs, normalizeHeaderName, exec.buf),
|
||||
} else KeyValueList.init();
|
||||
const list = blk: {
|
||||
const opts = opts_ orelse break :blk KeyValueList.init();
|
||||
switch (opts) {
|
||||
.obj => |obj| break :blk try KeyValueList.copy(exec.arena, obj._list),
|
||||
.js_obj => |js_obj| {
|
||||
var list = try KeyValueList.fromJsObject(exec.arena, js_obj, normalizeHeaderName, exec.buf);
|
||||
try validateAndNormalize(&list);
|
||||
break :blk list;
|
||||
},
|
||||
.strings => |kvs| {
|
||||
var list = try KeyValueList.fromArray(exec.arena, kvs, normalizeHeaderName, exec.buf);
|
||||
try validateAndNormalize(&list);
|
||||
break :blk list;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
return exec._factory.create(Headers{
|
||||
._list = list,
|
||||
@@ -103,6 +115,42 @@ fn normalizeHeaderName(name: []const u8, buf: []u8) []const u8 {
|
||||
return std.ascii.lowerString(buf, name);
|
||||
}
|
||||
|
||||
/// Validate names and normalize/validate values for a script-provided header
|
||||
/// init, trimming values in place. The trim is allocation-free (see
|
||||
/// `String.trim`), so an untrimmed value keeps its original storage.
|
||||
fn validateAndNormalize(list: *KeyValueList) !void {
|
||||
for (list._entries.items) |*entry| {
|
||||
// A valid header name is exactly a non-empty HTTP token.
|
||||
if (Mime.isHttpToken(entry.name.str()) == false) {
|
||||
return error.TypeError;
|
||||
}
|
||||
const trimmed = entry.value.trim(&Mime.HTTP_WHITESPACE);
|
||||
try validateHeaderValue(trimmed.str());
|
||||
entry.value = trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate an already-normalized header value. Returns `error.TypeError` —
|
||||
/// surfaced to script as a JS TypeError — when it contains a code point above
|
||||
/// U+00FF (not a valid byte string) or a 0x00/0x0A/0x0D byte.
|
||||
/// https://fetch.spec.whatwg.org/#headers-class
|
||||
fn validateHeaderValue(value: []const u8) error{TypeError}!void {
|
||||
var i: usize = 0;
|
||||
while (i < value.len) {
|
||||
const n = std.unicode.utf8ByteSequenceLength(value[i]) catch return error.TypeError;
|
||||
if (i + n > value.len) {
|
||||
return error.TypeError;
|
||||
}
|
||||
|
||||
const cp = std.unicode.utf8Decode(value[i..][0..n]) catch return error.TypeError;
|
||||
|
||||
if (cp > 0xFF or cp == 0x00 or cp == 0x0A or cp == 0x0D) {
|
||||
return error.TypeError;
|
||||
}
|
||||
i += n;
|
||||
}
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(Headers);
|
||||
|
||||
|
||||
@@ -223,7 +223,7 @@ pub fn blob(self: *Request, exec: *const Execution) !js.Promise {
|
||||
const headers = try self.getHeaders(exec);
|
||||
const content_type = try headers.get("content-type", exec) orelse "";
|
||||
|
||||
const b = try Blob.initFromBytes(body, content_type, true, exec.page);
|
||||
const b = try Blob.initFromBytes(body, content_type, exec.page);
|
||||
return local.resolvePromise(b);
|
||||
}
|
||||
|
||||
|
||||
@@ -356,7 +356,7 @@ pub fn blob(self: *Response, exec: *const Execution) !js.Promise {
|
||||
.stream => return local.rejectPromise(.{ .type_error = "Cannot read blob from stream body" }),
|
||||
};
|
||||
const content_type = try self._headers.get("content-type", exec) orelse "";
|
||||
const b = try Blob.initFromBytes(body, content_type, true, exec.page);
|
||||
const b = try Blob.initFromBytes(body, content_type, exec.page);
|
||||
return local.resolvePromise(b);
|
||||
}
|
||||
|
||||
|
||||
@@ -505,7 +505,7 @@ fn dispatchMessageEvent(self: *WebSocket, data: []const u8, frame_type: http.WsF
|
||||
switch (self._binary_type) {
|
||||
.arraybuffer => .{ .arraybuffer = .{ .values = data } },
|
||||
.blob => blk: {
|
||||
const blob = try Blob.initFromBytes(data, "", false, exec.page);
|
||||
const blob = try Blob.initFromBytes(data, "", exec.page);
|
||||
blob.acquireRef();
|
||||
break :blk .{ .blob = blob };
|
||||
},
|
||||
|
||||
@@ -248,7 +248,8 @@ pub const AuthChallenge = struct {
|
||||
};
|
||||
|
||||
pub const ResponseHead = struct {
|
||||
pub const MAX_CONTENT_TYPE_LEN = 64;
|
||||
// Matches Mime.parse's 255-byte cap
|
||||
pub const MAX_CONTENT_TYPE_LEN = 255;
|
||||
|
||||
status: u16,
|
||||
url: [*c]const u8,
|
||||
|
||||
@@ -111,6 +111,10 @@ pub const String = packed struct {
|
||||
return .init(allocator, self.str(), .{ .dupe = true });
|
||||
}
|
||||
|
||||
pub fn trim(self: *const String, values: []const u8) String {
|
||||
return wrap(std.mem.trim(u8, self.str(), values));
|
||||
}
|
||||
|
||||
pub fn concat(allocator: Allocator, parts: []const []const u8) !String {
|
||||
var total_len: usize = 0;
|
||||
for (parts) |part| {
|
||||
@@ -400,6 +404,23 @@ test "String" {
|
||||
}
|
||||
}
|
||||
|
||||
test "String.trim" {
|
||||
const expect = struct {
|
||||
fn expect(expected: []const u8, input: []const u8, values: []const u8) !void {
|
||||
const s = try String.init(testing.allocator, input, .{});
|
||||
defer s.deinit(testing.allocator);
|
||||
const trimmed = s.trim(values);
|
||||
try testing.expectEqual(expected, trimmed.str());
|
||||
}
|
||||
}.expect;
|
||||
|
||||
try expect("hi", " hi ", " "); // SSO, both ends
|
||||
try expect("hello", "hello", " "); // nothing to trim (no allocation)
|
||||
try expect("", " ", " "); // fully trimmed away
|
||||
try expect("x" ** 20, " " ++ ("x" ** 20) ++ "\t", &.{ ' ', '\t' }); // heap stays heap (view)
|
||||
try expect("abc", "abc" ++ (" " ** 6), " "); // heap trims down to SSO
|
||||
}
|
||||
|
||||
test "String.concat" {
|
||||
{
|
||||
const result = try String.concat(testing.allocator, &.{});
|
||||
|
||||
Reference in New Issue
Block a user