diff --git a/src/browser/Mime.zig b/src/browser/Mime.zig index 5db40a406..16a644fa4 100644 --- a/src/browser/Mime.zig +++ b/src/browser/Mime.zig @@ -17,6 +17,7 @@ // along with this program. If not, see . 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" { // try testing.expectEqual("utf-8", Mime.prescanCharset("").?); diff --git a/src/browser/tests/blob.html b/src/browser/tests/blob.html index 5f59ade78..dade9b1b7 100644 --- a/src/browser/tests/blob.html +++ b/src/browser/tests/blob.html @@ -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()); }); diff --git a/src/browser/tests/net/headers.html b/src/browser/tests/net/headers.html index efa4f48bb..057ec603e 100644 --- a/src/browser/tests/net/headers.html +++ b/src/browser/tests/net/headers.html @@ -159,6 +159,36 @@ } + +