From d5d5cf9639a817b70da1ca177e4f301015bd6abe Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Fri, 21 Aug 2026 21:00:42 +0200 Subject: [PATCH] fix: implement the RFC 9110 media-type grammar strictly Addresses the review on #81: the quirks carried over from the previous regular expressions were bugs, not behaviour worth preserving. - tchar includes "`" in type and subtype, as in parameter names - qdtext accepts HTAB, not VT - quoted-pair accepts HTAB / SP / VCHAR / obs-text; DEL (0x7f) is rejected - whitespace is OWS (SP / HTAB) only: the String.prototype.trim() set, CR, LF, FF, VT and Unicode whitespace are rejected - OWS is accepted on both sides of ";" and after the last parameter - empty parameters ("text/html;", "text/html; ; a=b") are accepted, as allowed by RFC 9110 Section 5.6.6 - the first occurrence of a duplicate parameter wins, matching util.MIMEType, the WHATWG MIME Sniffing Standard and content-type Verified by differential fuzzing against a reference parser transcribed from the RFC 9110 ABNF: 5M inputs, 0 mismatches. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018GeeQqdAmma5RNEEr7r75C --- README.md | 35 +++++- index.js | 143 ++++++++++++------------ package.json | 4 +- test/index.test.js | 268 ++++++++++++++++++++++++++++++++------------- 4 files changed, 303 insertions(+), 147 deletions(-) diff --git a/README.md b/README.md index 14716de..04ab522 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-brightgreen?style=flat)](https://github.com/neostandard/neostandard) [![Security Responsible Disclosure](https://img.shields.io/badge/Security-Responsible%20Disclosure-yellow.svg)](https://github.com/fastify/.github/blob/main/SECURITY.md) -Parse HTTP Content-Type header according to RFC 7231. +Parse HTTP Content-Type header according to [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110#section-8.3.1). ## Installation @@ -57,6 +57,39 @@ properties (examples are shown for the string `'application/json; charset=utf-8' In case the header is invalid, it will return an object with an empty string `''` as type and an empty Object for `parameters`. +## Grammar + +The parser implements the `media-type` grammar of +[RFC 9110 Section 8.3.1](https://www.rfc-editor.org/rfc/rfc9110#section-8.3.1) +exactly, without extensions: + +``` +media-type = type "/" subtype parameters +type = token +subtype = token +parameters = *( OWS ";" OWS [ parameter ] ) +parameter = parameter-name "=" parameter-value +parameter-name = token +parameter-value = ( token / quoted-string ) +OWS = *( SP / HTAB ) +``` + +In particular: + +- Only spaces and horizontal tabs (`OWS`) are accepted around the media type + and the `;` separators. Any other whitespace, including `CR`, `LF` and + Unicode whitespace, is rejected. +- Empty parameters (`text/html;`, `text/html; ; charset=utf-8`) are accepted, + as allowed by RFC 9110. +- `type`, `subtype` and parameter names are case-insensitive and are + lower-cased. Parameter values are returned as-is. +- Quoted-pairs in `quoted-string` values are unescaped. +- When a parameter appears more than once, the first occurrence wins, matching + `util.MIMEType`, the [WHATWG MIME Sniffing Standard](https://mimesniff.spec.whatwg.org/#parsing-a-mime-type) + and the `content-type` package. +- `parameters` is a null-prototype object, so parameter names such as + `__proto__` or `constructor` are ordinary keys. + ## Benchmarks ```sh diff --git a/index.js b/index.js index f3b3a01..92b05cd 100644 --- a/index.js +++ b/index.js @@ -3,6 +3,7 @@ const NullObject = function NullObject () { } NullObject.prototype = Object.create(null) +const HTAB = 0x09 // '\t' const SP = 0x20 // ' ' const SEMI = 0x3b // ';' const EQ = 0x3d // '=' @@ -14,60 +15,39 @@ const BSLASH = 0x5c // '\' * Character class lookup table, indexed by UTF-16 code unit. It covers the * whole code unit range so that lookups are never out of bounds and always * yield a small integer, which keeps the scanning loops on V8's fast path. + * Code units above 0xff have no class: header field values are octets. * - * tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" - * / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" - * / DIGIT / ALPHA - * ; any VCHAR, except delimiters + * RFC 9110 Section 5.6.2: + * tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" + * / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" + * / DIGIT / ALPHA + * ; any VCHAR, except delimiters * - * qdtext = HTAB / SP / %x21 / %x23-5B / %x5D-7E / obs-text - * obs-text = %x80-FF - * - * MEDIA_TYPE_TCHAR intentionally omits "`" and QDTEXT accepts VT (0x0b) - * rather than HTAB, to keep the behaviour of the regular expressions that - * were previously used for validation. + * RFC 9110 Section 5.6.4: + * qdtext = HTAB / SP / %x21 / %x23-5B / %x5D-7E / obs-text + * quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text ) + * obs-text = %x80-FF */ -const MEDIA_TYPE_TCHAR = 1 -const PARAM_TCHAR = 2 -const QDTEXT = 4 +const TCHAR = 1 +const QDTEXT = 2 +const QUOTED_PAIR = 4 const UPPER = 8 const CHAR_CLASS = new Uint8Array(0x10000) -for (const ch of '!#$%&\'*+-.^_|~0123456789abcdefghijklmnopqrstuvwxyz') { - CHAR_CLASS[ch.charCodeAt(0)] = MEDIA_TYPE_TCHAR | PARAM_TCHAR +for (const ch of '!#$%&\'*+-.^_`|~0123456789abcdefghijklmnopqrstuvwxyz') { + CHAR_CLASS[ch.charCodeAt(0)] = TCHAR } for (const ch of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') { - CHAR_CLASS[ch.charCodeAt(0)] = MEDIA_TYPE_TCHAR | PARAM_TCHAR | UPPER -} -CHAR_CLASS[0x60] = PARAM_TCHAR // '`' -CHAR_CLASS[0x0b] |= QDTEXT -CHAR_CLASS[0x20] |= QDTEXT -CHAR_CLASS[0x21] |= QDTEXT -for (let i = 0x23; i <= 0x5b; i++) CHAR_CLASS[i] |= QDTEXT -for (let i = 0x5d; i <= 0x7e; i++) CHAR_CLASS[i] |= QDTEXT -for (let i = 0x80; i <= 0xff; i++) CHAR_CLASS[i] |= QDTEXT - -/** - * Whitespace as removed by `String.prototype.trim()`: WhiteSpace and - * LineTerminator code points per ECMA-262. - */ -function isTrimWhitespace (code) { - if (code <= 0x20) { - return code === 0x20 || (code >= 0x09 && code <= 0x0d) - } - if (code < 0xa0) { - return false - } - return code === 0xa0 || - code === 0x1680 || - (code >= 0x2000 && code <= 0x200a) || - code === 0x2028 || - code === 0x2029 || - code === 0x202f || - code === 0x205f || - code === 0x3000 || - code === 0xfeff + CHAR_CLASS[ch.charCodeAt(0)] = TCHAR | UPPER } +CHAR_CLASS[HTAB] |= QDTEXT | QUOTED_PAIR +CHAR_CLASS[SP] |= QDTEXT | QUOTED_PAIR +CHAR_CLASS[0x21] |= QDTEXT | QUOTED_PAIR +CHAR_CLASS[DQUOTE] |= QUOTED_PAIR +for (let i = 0x23; i <= 0x5b; i++) CHAR_CLASS[i] |= QDTEXT | QUOTED_PAIR +CHAR_CLASS[BSLASH] |= QUOTED_PAIR +for (let i = 0x5d; i <= 0x7e; i++) CHAR_CLASS[i] |= QDTEXT | QUOTED_PAIR +for (let i = 0x80; i <= 0xff; i++) CHAR_CLASS[i] |= QDTEXT | QUOTED_PAIR /** * Remove the backslashes of the quoted-pairs in header[start, end). @@ -96,7 +76,19 @@ const invalidParameterFormat = { type: '', parameters: defaultContentType.parame Object.freeze(invalidParameterFormat) /** - * Parse media type to object. + * Parse media type to object, following RFC 9110 Section 8.3.1: + * + * media-type = type "/" subtype parameters + * type = token + * subtype = token + * parameters = *( OWS ";" OWS [ parameter ] ) + * parameter = parameter-name "=" parameter-value + * parameter-name = token + * parameter-value = ( token / quoted-string ) + * OWS = *( SP / HTAB ) + * + * Leading and trailing OWS is tolerated, as a field parser is required to + * strip it before evaluating the field value (RFC 9110 Section 5.5). * * Returns `defaultContentType` when the media type is invalid and * `invalidParameterFormat` when the parameters are malformed. @@ -110,20 +102,20 @@ function parseHeader (header) { let code = 0 let flags = 0 - // skip leading whitespace + // leading OWS while (index < len) { code = header.charCodeAt(index) - if (!isTrimWhitespace(code)) break + if (code !== SP && code !== HTAB) break index++ } - // media-type = type "/" subtype + // type "/" subtype const typeStart = index let upper = 0 while (index < len) { code = header.charCodeAt(index) flags = CHAR_CLASS[code] - if ((flags & MEDIA_TYPE_TCHAR) === 0) break + if ((flags & TCHAR) === 0) break upper |= flags index++ } @@ -137,7 +129,7 @@ function parseHeader (header) { while (index < len) { code = header.charCodeAt(index) flags = CHAR_CLASS[code] - if ((flags & MEDIA_TYPE_TCHAR) === 0) break + if ((flags & TCHAR) === 0) break upper |= flags index++ } @@ -148,10 +140,10 @@ function parseHeader (header) { const typeEnd = index - // skip trailing whitespace + // OWS while (index < len) { code = header.charCodeAt(index) - if (!isTrimWhitespace(code)) break + if (code !== SP && code !== HTAB) break index++ } @@ -169,23 +161,34 @@ function parseHeader (header) { return result } - // parse parameters const parameters = result.parameters - // *( ";" parameter ) - // parameter = token "=" ( token / quoted-string ) + // *( OWS ";" OWS [ parameter ] ) while (index < len) { index++ // skip ";" - while (index < len && header.charCodeAt(index) === SP) { + + // OWS + while (index < len) { + code = header.charCodeAt(index) + if (code !== SP && code !== HTAB) break index++ } + // empty parameter: trailing ";" or ";;" + if (index === len) { + return result + } + if (code === SEMI) { + continue + } + + // parameter-name const keyStart = index upper = 0 while (index < len) { code = header.charCodeAt(index) flags = CHAR_CLASS[code] - if ((flags & PARAM_TCHAR) === 0) break + if ((flags & TCHAR) === 0) break upper |= flags index++ } @@ -197,6 +200,7 @@ function parseHeader (header) { const key = header.slice(keyStart, index) index++ // skip "=" + // parameter-value let value if (index < len && header.charCodeAt(index) === DQUOTE) { // quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE @@ -215,11 +219,7 @@ function parseHeader (header) { } // quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text ) index++ - if (index === len) { - return invalidParameterFormat - } - code = header.charCodeAt(index) - if (!(code === 0x0b || (code >= 0x20 && code <= 0xff))) { + if (index === len || (CHAR_CLASS[header.charCodeAt(index)] & QUOTED_PAIR) === 0) { return invalidParameterFormat } escaped = true @@ -235,8 +235,9 @@ function parseHeader (header) { : header.slice(valueStart, index) index++ // skip closing DQUOTE } else { + // token const valueStart = index - while (index < len && (CHAR_CLASS[header.charCodeAt(index)] & PARAM_TCHAR) !== 0) { + while (index < len && (CHAR_CLASS[header.charCodeAt(index)] & TCHAR) !== 0) { index++ } @@ -247,15 +248,23 @@ function parseHeader (header) { value = header.slice(valueStart, index) } - while (index < len && header.charCodeAt(index) === SP) { + // OWS + while (index < len) { + code = header.charCodeAt(index) + if (code !== SP && code !== HTAB) break index++ } - if (index !== len && header.charCodeAt(index) !== SEMI) { + if (index !== len && code !== SEMI) { return invalidParameterFormat } - parameters[(upper & UPPER) !== 0 ? key.toLowerCase() : key] = value + // parameter names are case-insensitive; the first occurrence wins, + // matching util.MIMEType, the WHATWG MIME Sniffing standard and content-type + const name = (upper & UPPER) !== 0 ? key.toLowerCase() : key + if (parameters[name] === undefined) { + parameters[name] = value + } } return result diff --git a/package.json b/package.json index ebc153a..64cc3a7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "fast-content-type-parse", "version": "3.0.0", - "description": "Parse HTTP Content-Type header according to RFC 7231", + "description": "Parse HTTP Content-Type header according to RFC 9110", "main": "index.js", "type": "commonjs", "types": "./types/index.d.ts", @@ -17,7 +17,7 @@ }, "keywords": [ "content-type", - "rfc7231" + "rfc9110" ], "author": "Aras Abbasi ", "contributors": [ diff --git a/test/index.test.js b/test/index.test.js index 89a21bd..32df770 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -4,21 +4,73 @@ const { test } = require('node:test') const { parse, safeParse, defaultContentType } = require('..') const invalidTypes = [ + '', ' ', + '\t', 'null', 'undefined', '/', + 'text/', + '/plain', 'text / plain', 'text/;plain', 'text/"plain"', - 'text/p£ain', + 'text/p\u00a3ain', 'text/(plain)', 'text/@plain', - 'text/plain,wrong' + 'text/plain,wrong', + 'text/plain/wrong', + 'text/plain wrong', + // only OWS (SP / HTAB) may surround the media type + '\r\ntext/html\r\n', + '\ntext/html', + 'text/html\n', + 'text/html\r', + 'text/html\x0b', + 'text/html\x0c', + 'text/html\n; charset=utf-8', + '\u00a0text/html\u00a0', + '\u2003text/html\u2003', + '\u3000text/html\ufeff', + '\u2028text/html\u2029', + '\ufefftext/html' +] + +const invalidParameters = [ + 'text/plain; foo="bar', + 'text/plain; profile=http://localhost; foo=bar', + 'text/plain; profile=http://localhost', + 'text/plain; foo', + 'text/plain; =bar', + 'text/plain; foo ="bar"', + 'text/plain; foo= bar', + 'text/plain; foo=', + 'text/plain; foo=;', + 'text/plain; foo=bar baz', + 'text/plain; foo=bar\n', + 'text/plain; foo=bar\r\n', + 'text/plain; foo=bar\x0b', + 'text/plain; foo="bar"baz', + 'text/plain; foo="bar" baz', + 'text/plain; foo="bar\\', + 'text/plain; foo="bar\\"', + 'text/plain; foo="b\x0bar"', + 'text/plain; foo="ba\\\x0br"', + 'text/plain; foo="b\x7far"', + 'text/plain; foo="ba\\\x7fr"', + 'text/plain; foo="b\nar"', + 'text/plain; foo="ba\\\nr"', + 'text/plain; foo="b\u0100ar"', + 'text/plain; foo="ba\\\u0100r"', + 'text/plain; foo=b\xffar', + 'text/plain; fo\xffo=bar', + 'text/plain; foo="bar"; =baz', + 'text/plain; foo=bar; baz', + 'text/plain; foo=bar, baz=qux' ] test('parse', async function (t) { - t.plan(14 + invalidTypes.length) + t.plan(19 + invalidTypes.length) await t.test('should parse basic type', function (t) { t.plan(1) const type = parse('text/html') @@ -31,21 +83,20 @@ test('parse', async function (t) { t.assert.deepStrictEqual(type.type, 'image/svg+xml') }) - await t.test('should parse basic type with surrounding OWS', function (t) { - t.plan(1) - const type = parse(' text/html ') - t.assert.deepStrictEqual(type.type, 'text/html') + await t.test('should parse every tchar in type, subtype and parameters', function (t) { + t.plan(2) + const tchars = '!#$%&\'*+-.^_`|~0123456789abcdefghijklmnopqrstuvwxyz' + const type = parse(`${tchars}/${tchars}; ${tchars}=${tchars}`) + t.assert.deepStrictEqual(type.type, `${tchars}/${tchars}`) + t.assert.deepEqual(type.parameters, { [tchars]: tchars }) }) - await t.test('should parse basic type with surrounding unicode whitespace', function (t) { - t.plan(7) + await t.test('should parse basic type with surrounding OWS', function (t) { + t.plan(4) + t.assert.deepStrictEqual(parse(' text/html ').type, 'text/html') t.assert.deepStrictEqual(parse('\ttext/html\t').type, 'text/html') - t.assert.deepStrictEqual(parse('\r\ntext/html\r\n').type, 'text/html') - t.assert.deepStrictEqual(parse('\u00a0text/html\u00a0').type, 'text/html') - t.assert.deepStrictEqual(parse('\u2003text/html\u2003').type, 'text/html') - t.assert.deepStrictEqual(parse('\u3000text/html\ufeff').type, 'text/html') - t.assert.deepStrictEqual(parse('\u2028text/html\u2029').type, 'text/html') - t.assert.deepStrictEqual(parse('text/html\n; charset=utf-8').parameters.charset, 'utf-8') + t.assert.deepStrictEqual(parse(' \t text/html \t ').type, 'text/html') + t.assert.deepEqual(parse('\ttext/html\t;\tcharset=utf-8\t').parameters, { charset: 'utf-8' }) }) await t.test('should parse parameters', function (t) { @@ -58,14 +109,37 @@ test('parse', async function (t) { }) }) - await t.test('should parse parameters with extra LWS', function (t) { + await t.test('should parse parameters with extra OWS', function (t) { + t.plan(4) + t.assert.deepEqual(parse('text/html ; charset=utf-8 ; foo=bar').parameters, { charset: 'utf-8', foo: 'bar' }) + t.assert.deepEqual(parse('text/html\t;\tcharset=utf-8\t;\tfoo=bar').parameters, { charset: 'utf-8', foo: 'bar' }) + t.assert.deepEqual(parse('text/html;charset=utf-8;foo=bar').parameters, { charset: 'utf-8', foo: 'bar' }) + t.assert.deepEqual(parse('text/html \t ; \t charset="utf-8" \t ; \t foo=bar \t ').parameters, { charset: 'utf-8', foo: 'bar' }) + }) + + await t.test('should parse empty parameters', function (t) { + t.plan(7) + t.assert.deepEqual(parse('text/html;').parameters, {}) + t.assert.deepEqual(parse('text/html; ').parameters, {}) + t.assert.deepEqual(parse('text/html ;').parameters, {}) + t.assert.deepEqual(parse('text/html;;;').parameters, {}) + t.assert.deepEqual(parse('text/html; ; charset=utf-8').parameters, { charset: 'utf-8' }) + t.assert.deepEqual(parse('text/html; charset=utf-8;').parameters, { charset: 'utf-8' }) + t.assert.deepEqual(parse('text/html; charset=utf-8; ; foo=bar ; ;').parameters, { charset: 'utf-8', foo: 'bar' }) + }) + + await t.test('should keep the first occurrence of a duplicate parameter', function (t) { t.plan(2) - const type = parse('text/html ; charset=utf-8 ; foo=bar') - t.assert.deepStrictEqual(type.type, 'text/html') - t.assert.deepEqual(type.parameters, { - charset: 'utf-8', - foo: 'bar' - }) + t.assert.deepEqual(parse('text/html; charset=utf-8; charset=latin1').parameters, { charset: 'utf-8' }) + t.assert.deepEqual(parse('text/html; charset=utf-8; CHARSET="latin1"; foo=bar').parameters, { charset: 'utf-8', foo: 'bar' }) + }) + + await t.test('should not be confused by special property names', function (t) { + t.plan(3) + const type = parse('text/html; __proto__=a; constructor=b; hasownproperty=c') + t.assert.deepStrictEqual(Object.getPrototypeOf(Object.getPrototypeOf(type.parameters)), null) + t.assert.deepStrictEqual(Object.keys(type.parameters), ['__proto__', 'constructor', 'hasownproperty']) + t.assert.deepStrictEqual(Object.getOwnPropertyDescriptor(type.parameters, '__proto__').value, 'a') }) await t.test('should lower-case type', function (t) { @@ -84,12 +158,13 @@ test('parse', async function (t) { }) await t.test('should unquote parameter values', function (t) { - t.plan(2) + t.plan(3) const type = parse('text/html; charset="UTF-8"') t.assert.deepStrictEqual(type.type, 'text/html') t.assert.deepEqual(type.parameters, { charset: 'UTF-8' }) + t.assert.deepEqual(parse('text/html; foo=""').parameters, { foo: '' }) }) await t.test('should unquote parameter values with escapes', function (t) { @@ -101,6 +176,15 @@ test('parse', async function (t) { }) }) + await t.test('should accept qdtext and quoted-pair characters', function (t) { + t.plan(5) + t.assert.deepEqual(parse('text/plain; foo="b\tar"').parameters, { foo: 'b\tar' }) + t.assert.deepEqual(parse('text/plain; foo="ba\\\tr"').parameters, { foo: 'ba\tr' }) + t.assert.deepEqual(parse('text/plain; foo="b ar\\ "').parameters, { foo: 'b ar ' }) + t.assert.deepEqual(parse('text/plain; foo="\x80\xff\\\x80\\\xff"').parameters, { foo: '\x80\xff\x80\xff' }) + t.assert.deepEqual(parse('text/plain; foo="\\!\\~"').parameters, { foo: '!~' }) + }) + await t.test('should handle balanced quotes', function (t) { t.plan(2) const type = parse('text/html; param="charset=\\"utf-8\\"; foo=bar"; bar=foo') @@ -111,26 +195,25 @@ test('parse', async function (t) { }) }) + await t.test('should return a null prototype parameters object', function (t) { + t.plan(2) + const type = parse('text/html; charset=utf-8') + t.assert.deepStrictEqual(Object.getPrototypeOf(Object.getPrototypeOf(type.parameters)), null) + t.assert.deepStrictEqual(typeof type.parameters.toString, 'undefined') + }) + invalidTypes.forEach(async function (type) { - await t.test('should throw on invalid media type ' + type, function (t) { + await t.test('should throw on invalid media type ' + JSON.stringify(type), function (t) { t.plan(1) t.assert.throws(parse.bind(null, type), new TypeError('invalid media type')) }) }) await t.test('should throw on invalid parameter format', function (t) { - t.plan(11) - t.assert.throws(parse.bind(null, 'text/plain; foo="bar'), new TypeError('invalid parameter format')) - t.assert.throws(parse.bind(null, 'text/plain; profile=http://localhost; foo=bar'), new TypeError('invalid parameter format')) - t.assert.throws(parse.bind(null, 'text/plain; profile=http://localhost'), new TypeError('invalid parameter format')) - t.assert.throws(parse.bind(null, 'text/plain; foo'), new TypeError('invalid parameter format')) - t.assert.throws(parse.bind(null, 'text/plain; =bar'), new TypeError('invalid parameter format')) - t.assert.throws(parse.bind(null, 'text/plain; foo ="bar"'), new TypeError('invalid parameter format')) - t.assert.throws(parse.bind(null, 'text/plain; foo='), new TypeError('invalid parameter format')) - t.assert.throws(parse.bind(null, 'text/plain; foo= bar'), new TypeError('invalid parameter format')) - t.assert.throws(parse.bind(null, 'text/plain; foo="ba\\\tr"'), new TypeError('invalid parameter format')) - t.assert.throws(parse.bind(null, 'text/plain; foo="bar\\'), new TypeError('invalid parameter format')) - t.assert.throws(parse.bind(null, 'text/plain; foo="b\tar"'), new TypeError('invalid parameter format')) + t.plan(invalidParameters.length) + for (const header of invalidParameters) { + t.assert.throws(parse.bind(null, header), new TypeError('invalid parameter format'), JSON.stringify(header)) + } }) await t.test('should require argument', function (t) { @@ -147,7 +230,7 @@ test('parse', async function (t) { }) test('safeParse', async function (t) { - t.plan(14 + invalidTypes.length) + t.plan(19 + invalidTypes.length) await t.test('should safeParse basic type', function (t) { t.plan(1) const type = safeParse('text/html') @@ -160,21 +243,20 @@ test('safeParse', async function (t) { t.assert.deepStrictEqual(type.type, 'image/svg+xml') }) - await t.test('should safeParse basic type with surrounding OWS', function (t) { - t.plan(1) - const type = safeParse(' text/html ') - t.assert.deepStrictEqual(type.type, 'text/html') + await t.test('should safeParse every tchar in type, subtype and parameters', function (t) { + t.plan(2) + const tchars = '!#$%&\'*+-.^_`|~0123456789abcdefghijklmnopqrstuvwxyz' + const type = safeParse(`${tchars}/${tchars}; ${tchars}=${tchars}`) + t.assert.deepStrictEqual(type.type, `${tchars}/${tchars}`) + t.assert.deepEqual(type.parameters, { [tchars]: tchars }) }) - await t.test('should safeParse basic type with surrounding unicode whitespace', function (t) { - t.plan(7) + await t.test('should safeParse basic type with surrounding OWS', function (t) { + t.plan(4) + t.assert.deepStrictEqual(safeParse(' text/html ').type, 'text/html') t.assert.deepStrictEqual(safeParse('\ttext/html\t').type, 'text/html') - t.assert.deepStrictEqual(safeParse('\r\ntext/html\r\n').type, 'text/html') - t.assert.deepStrictEqual(safeParse('\u00a0text/html\u00a0').type, 'text/html') - t.assert.deepStrictEqual(safeParse('\u2003text/html\u2003').type, 'text/html') - t.assert.deepStrictEqual(safeParse('\u3000text/html\ufeff').type, 'text/html') - t.assert.deepStrictEqual(safeParse('\u2028text/html\u2029').type, 'text/html') - t.assert.deepStrictEqual(safeParse('text/html\n; charset=utf-8').parameters.charset, 'utf-8') + t.assert.deepStrictEqual(safeParse(' \t text/html \t ').type, 'text/html') + t.assert.deepEqual(safeParse('\ttext/html\t;\tcharset=utf-8\t').parameters, { charset: 'utf-8' }) }) await t.test('should safeParse parameters', function (t) { @@ -187,14 +269,37 @@ test('safeParse', async function (t) { }) }) - await t.test('should safeParse parameters with extra LWS', function (t) { + await t.test('should safeParse parameters with extra OWS', function (t) { + t.plan(4) + t.assert.deepEqual(safeParse('text/html ; charset=utf-8 ; foo=bar').parameters, { charset: 'utf-8', foo: 'bar' }) + t.assert.deepEqual(safeParse('text/html\t;\tcharset=utf-8\t;\tfoo=bar').parameters, { charset: 'utf-8', foo: 'bar' }) + t.assert.deepEqual(safeParse('text/html;charset=utf-8;foo=bar').parameters, { charset: 'utf-8', foo: 'bar' }) + t.assert.deepEqual(safeParse('text/html \t ; \t charset="utf-8" \t ; \t foo=bar \t ').parameters, { charset: 'utf-8', foo: 'bar' }) + }) + + await t.test('should safeParse empty parameters', function (t) { + t.plan(7) + t.assert.deepEqual(safeParse('text/html;').parameters, {}) + t.assert.deepEqual(safeParse('text/html; ').parameters, {}) + t.assert.deepEqual(safeParse('text/html ;').parameters, {}) + t.assert.deepEqual(safeParse('text/html;;;').parameters, {}) + t.assert.deepEqual(safeParse('text/html; ; charset=utf-8').parameters, { charset: 'utf-8' }) + t.assert.deepEqual(safeParse('text/html; charset=utf-8;').parameters, { charset: 'utf-8' }) + t.assert.deepEqual(safeParse('text/html; charset=utf-8; ; foo=bar ; ;').parameters, { charset: 'utf-8', foo: 'bar' }) + }) + + await t.test('should keep the first occurrence of a duplicate parameter', function (t) { t.plan(2) - const type = safeParse('text/html ; charset=utf-8 ; foo=bar') - t.assert.deepStrictEqual(type.type, 'text/html') - t.assert.deepEqual(type.parameters, { - charset: 'utf-8', - foo: 'bar' - }) + t.assert.deepEqual(safeParse('text/html; charset=utf-8; charset=latin1').parameters, { charset: 'utf-8' }) + t.assert.deepEqual(safeParse('text/html; charset=utf-8; CHARSET="latin1"; foo=bar').parameters, { charset: 'utf-8', foo: 'bar' }) + }) + + await t.test('should not be confused by special property names', function (t) { + t.plan(3) + const type = safeParse('text/html; __proto__=a; constructor=b; hasownproperty=c') + t.assert.deepStrictEqual(Object.getPrototypeOf(Object.getPrototypeOf(type.parameters)), null) + t.assert.deepStrictEqual(Object.keys(type.parameters), ['__proto__', 'constructor', 'hasownproperty']) + t.assert.deepStrictEqual(Object.getOwnPropertyDescriptor(type.parameters, '__proto__').value, 'a') }) await t.test('should lower-case type', function (t) { @@ -213,12 +318,13 @@ test('safeParse', async function (t) { }) await t.test('should unquote parameter values', function (t) { - t.plan(2) + t.plan(3) const type = safeParse('text/html; charset="UTF-8"') t.assert.deepStrictEqual(type.type, 'text/html') t.assert.deepEqual(type.parameters, { charset: 'UTF-8' }) + t.assert.deepEqual(safeParse('text/html; foo=""').parameters, { foo: '' }) }) await t.test('should unquote parameter values with escapes', function (t) { @@ -230,6 +336,15 @@ test('safeParse', async function (t) { }) }) + await t.test('should accept qdtext and quoted-pair characters', function (t) { + t.plan(5) + t.assert.deepEqual(safeParse('text/plain; foo="b\tar"').parameters, { foo: 'b\tar' }) + t.assert.deepEqual(safeParse('text/plain; foo="ba\\\tr"').parameters, { foo: 'ba\tr' }) + t.assert.deepEqual(safeParse('text/plain; foo="b ar\\ "').parameters, { foo: 'b ar ' }) + t.assert.deepEqual(safeParse('text/plain; foo="\x80\xff\\\x80\\\xff"').parameters, { foo: '\x80\xff\x80\xff' }) + t.assert.deepEqual(safeParse('text/plain; foo="\\!\\~"').parameters, { foo: '!~' }) + }) + await t.test('should handle balanced quotes', function (t) { t.plan(2) const type = safeParse('text/html; param="charset=\\"utf-8\\"; foo=bar"; bar=foo') @@ -240,33 +355,32 @@ test('safeParse', async function (t) { }) }) + await t.test('should return a null prototype parameters object', function (t) { + t.plan(2) + const type = safeParse('text/html; charset=utf-8') + t.assert.deepStrictEqual(Object.getPrototypeOf(Object.getPrototypeOf(type.parameters)), null) + t.assert.deepStrictEqual(typeof type.parameters.toString, 'undefined') + }) + invalidTypes.forEach(async function (type) { - await t.test('should return dummyContentType on invalid media type ' + type, function (t) { - t.plan(2) + await t.test('should return defaultContentType on invalid media type ' + JSON.stringify(type), function (t) { + t.plan(3) + t.assert.strictEqual(safeParse(type), defaultContentType) t.assert.deepStrictEqual(safeParse(type).type, '') t.assert.deepStrictEqual(Object.keys(safeParse(type).parameters).length, 0) }) }) - await t.test('should return dummyContentType on invalid parameter format', function (t) { - t.plan(11) - t.assert.deepStrictEqual(safeParse('text/plain; foo="bar').type, '') - t.assert.deepStrictEqual(Object.keys(safeParse('text/plain; foo="bar').parameters).length, 0) - - t.assert.deepStrictEqual(safeParse('text/plain; profile=http://localhost; foo=bar').type, '') - t.assert.deepStrictEqual(Object.keys(safeParse('text/plain; profile=http://localhost; foo=bar').parameters).length, 0) - - t.assert.deepStrictEqual(safeParse('text/plain; profile=http://localhost').type, '') - t.assert.deepStrictEqual(Object.keys(safeParse('text/plain; profile=http://localhost').parameters).length, 0) - - t.assert.strictEqual(safeParse('text/plain; foo'), defaultContentType) - t.assert.strictEqual(safeParse('text/plain; =bar'), defaultContentType) - t.assert.strictEqual(safeParse('text/plain; foo='), defaultContentType) - t.assert.strictEqual(safeParse('text/plain; foo="ba\\\tr"'), defaultContentType) - t.assert.strictEqual(safeParse('text/plain; foo="bar\\'), defaultContentType) + await t.test('should return defaultContentType on invalid parameter format', function (t) { + t.plan(invalidParameters.length * 3) + for (const header of invalidParameters) { + t.assert.strictEqual(safeParse(header), defaultContentType, JSON.stringify(header)) + t.assert.deepStrictEqual(safeParse(header).type, '') + t.assert.deepStrictEqual(Object.keys(safeParse(header).parameters).length, 0) + } }) - await t.test('should return dummyContentType on missing argument', function (t) { + await t.test('should return defaultContentType on missing argument', function (t) { t.plan(2) // @ts-expect-error should reject non-strings t.assert.deepStrictEqual(safeParse().type, '') @@ -274,7 +388,7 @@ test('safeParse', async function (t) { t.assert.deepStrictEqual(Object.keys(safeParse().parameters).length, 0) }) - await t.test('should return dummyContentType on non-strings', function (t) { + await t.test('should return defaultContentType on non-strings', function (t) { t.plan(2) // @ts-expect-error should reject non-strings t.assert.deepStrictEqual(safeParse(null).type, '')