node/lib/internal/mime.js
Gaurish Sethia 6a1abd2c03
util: pass invalidSubtypeIndex instead of trimmedSubtype to error
PR-URL: https://github.com/nodejs/node/pull/51264
Reviewed-By: Luigi Pinca <luigipinca@gmail.com>
Reviewed-By: Deokjin Kim <deokjin81.kim@gmail.com>
2023-12-25 04:29:00 +00:00

390 lines
11 KiB
JavaScript

'use strict';
const {
FunctionPrototypeCall,
ObjectDefineProperty,
RegExpPrototypeExec,
SafeMap,
SafeStringPrototypeSearch,
StringPrototypeCharAt,
StringPrototypeIndexOf,
StringPrototypeSlice,
StringPrototypeToLowerCase,
SymbolIterator,
} = primordials;
const {
ERR_INVALID_MIME_SYNTAX,
} = require('internal/errors').codes;
const NOT_HTTP_TOKEN_CODE_POINT = /[^!#$%&'*+\-.^_`|~A-Za-z0-9]/g;
const NOT_HTTP_QUOTED_STRING_CODE_POINT = /[^\t\u0020-~\u0080-\u00FF]/g;
const END_BEGINNING_WHITESPACE = /[^\r\n\t ]|$/;
const START_ENDING_WHITESPACE = /[\r\n\t ]*$/;
function toASCIILower(str) {
let result = '';
for (let i = 0; i < str.length; i++) {
const char = str[i];
result += char >= 'A' && char <= 'Z' ?
StringPrototypeToLowerCase(char) :
char;
}
return result;
}
const SOLIDUS = '/';
const SEMICOLON = ';';
function parseTypeAndSubtype(str) {
// Skip only HTTP whitespace from start
let position = SafeStringPrototypeSearch(str, END_BEGINNING_WHITESPACE);
// read until '/'
const typeEnd = StringPrototypeIndexOf(str, SOLIDUS, position);
const trimmedType = typeEnd === -1 ?
StringPrototypeSlice(str, position) :
StringPrototypeSlice(str, position, typeEnd);
const invalidTypeIndex = SafeStringPrototypeSearch(trimmedType,
NOT_HTTP_TOKEN_CODE_POINT);
if (trimmedType === '' || invalidTypeIndex !== -1 || typeEnd === -1) {
throw new ERR_INVALID_MIME_SYNTAX('type', str, invalidTypeIndex);
}
// skip type and '/'
position = typeEnd + 1;
const type = toASCIILower(trimmedType);
// read until ';'
const subtypeEnd = StringPrototypeIndexOf(str, SEMICOLON, position);
const rawSubtype = subtypeEnd === -1 ?
StringPrototypeSlice(str, position) :
StringPrototypeSlice(str, position, subtypeEnd);
position += rawSubtype.length;
if (subtypeEnd !== -1) {
// skip ';'
position += 1;
}
const trimmedSubtype = StringPrototypeSlice(
rawSubtype,
0,
SafeStringPrototypeSearch(rawSubtype, START_ENDING_WHITESPACE));
const invalidSubtypeIndex = SafeStringPrototypeSearch(trimmedSubtype,
NOT_HTTP_TOKEN_CODE_POINT);
if (trimmedSubtype === '' || invalidSubtypeIndex !== -1) {
throw new ERR_INVALID_MIME_SYNTAX('subtype', str, invalidSubtypeIndex);
}
const subtype = toASCIILower(trimmedSubtype);
return [
type,
subtype,
position,
];
}
const EQUALS_SEMICOLON_OR_END = /[;=]|$/;
const QUOTED_VALUE_PATTERN = /^(?:([\\]$)|[\\][\s\S]|[^"])*(?:(")|$)/u;
function removeBackslashes(str) {
let ret = '';
// We stop at str.length - 1 because we want to look ahead one character.
let i;
for (i = 0; i < str.length - 1; i++) {
const c = str[i];
if (c === '\\') {
i++;
ret += str[i];
} else {
ret += c;
}
}
// We add the last character if we didn't skip to it.
if (i === str.length - 1) {
ret += str[i];
}
return ret;
}
function escapeQuoteOrSolidus(str) {
let result = '';
for (let i = 0; i < str.length; i++) {
const char = str[i];
result += (char === '"' || char === '\\') ? `\\${char}` : char;
}
return result;
}
const encode = (value) => {
if (value.length === 0) return '""';
const encode = SafeStringPrototypeSearch(value, NOT_HTTP_TOKEN_CODE_POINT) !== -1;
if (!encode) return value;
const escaped = escapeQuoteOrSolidus(value);
return `"${escaped}"`;
};
class MIMEParams {
#data = new SafeMap();
// We set the flag the MIMEParams instance as processed on initialization
// to defer the parsing of a potentially large string.
#processed = true;
#string = null;
/**
* Used to instantiate a MIMEParams object within the MIMEType class and
* to allow it to be parsed lazily.
*/
static instantiateMimeParams(str) {
const instance = new MIMEParams();
instance.#string = str;
instance.#processed = false;
return instance;
}
delete(name) {
this.#parse();
this.#data.delete(name);
}
get(name) {
this.#parse();
const data = this.#data;
if (data.has(name)) {
return data.get(name);
}
return null;
}
has(name) {
this.#parse();
return this.#data.has(name);
}
set(name, value) {
this.#parse();
const data = this.#data;
name = `${name}`;
value = `${value}`;
const invalidNameIndex = SafeStringPrototypeSearch(name, NOT_HTTP_TOKEN_CODE_POINT);
if (name.length === 0 || invalidNameIndex !== -1) {
throw new ERR_INVALID_MIME_SYNTAX(
'parameter name',
name,
invalidNameIndex,
);
}
const invalidValueIndex = SafeStringPrototypeSearch(
value,
NOT_HTTP_QUOTED_STRING_CODE_POINT);
if (invalidValueIndex !== -1) {
throw new ERR_INVALID_MIME_SYNTAX(
'parameter value',
value,
invalidValueIndex,
);
}
data.set(name, value);
}
*entries() {
this.#parse();
yield* this.#data.entries();
}
*keys() {
this.#parse();
yield* this.#data.keys();
}
*values() {
this.#parse();
yield* this.#data.values();
}
toString() {
this.#parse();
let ret = '';
for (const { 0: key, 1: value } of this.#data) {
const encoded = encode(value);
// Ensure they are separated
if (ret.length) ret += ';';
ret += `${key}=${encoded}`;
}
return ret;
}
// Used to act as a friendly class to stringifying stuff
// not meant to be exposed to users, could inject invalid values
#parse() {
if (this.#processed) return; // already parsed
const paramsMap = this.#data;
let position = 0;
const str = this.#string;
const endOfSource = SafeStringPrototypeSearch(
StringPrototypeSlice(str, position),
START_ENDING_WHITESPACE,
) + position;
while (position < endOfSource) {
// Skip any whitespace before parameter
position += SafeStringPrototypeSearch(
StringPrototypeSlice(str, position),
END_BEGINNING_WHITESPACE,
);
// Read until ';' or '='
const afterParameterName = SafeStringPrototypeSearch(
StringPrototypeSlice(str, position),
EQUALS_SEMICOLON_OR_END,
) + position;
const parameterString = toASCIILower(
StringPrototypeSlice(str, position, afterParameterName),
);
position = afterParameterName;
// If we found a terminating character
if (position < endOfSource) {
// Safe to use because we never do special actions for surrogate pairs
const char = StringPrototypeCharAt(str, position);
// Skip the terminating character
position += 1;
// Ignore parameters without values
if (char === ';') {
continue;
}
}
// If we are at end of the string, it cannot have a value
if (position >= endOfSource) break;
// Safe to use because we never do special actions for surrogate pairs
const char = StringPrototypeCharAt(str, position);
let parameterValue = null;
if (char === '"') {
// Handle quoted-string form of values
// skip '"'
position += 1;
// Find matching closing '"' or end of string
// use $1 to see if we terminated on unmatched '\'
// use $2 to see if we terminated on a matching '"'
// so we can skip the last char in either case
const insideMatch = RegExpPrototypeExec(
QUOTED_VALUE_PATTERN,
StringPrototypeSlice(str, position));
position += insideMatch[0].length;
// Skip including last character if an unmatched '\' or '"' during
// unescape
const inside = insideMatch[1] || insideMatch[2] ?
StringPrototypeSlice(insideMatch[0], 0, -1) :
insideMatch[0];
// Unescape '\' quoted characters
parameterValue = removeBackslashes(inside);
// If we did have an unmatched '\' add it back to the end
if (insideMatch[1]) parameterValue += '\\';
} else {
// Handle the normal parameter value form
const valueEnd = StringPrototypeIndexOf(str, SEMICOLON, position);
const rawValue = valueEnd === -1 ?
StringPrototypeSlice(str, position) :
StringPrototypeSlice(str, position, valueEnd);
position += rawValue.length;
const trimmedValue = StringPrototypeSlice(
rawValue,
0,
SafeStringPrototypeSearch(rawValue, START_ENDING_WHITESPACE),
);
// Ignore parameters without values
if (trimmedValue === '') continue;
parameterValue = trimmedValue;
}
if (
parameterString !== '' &&
SafeStringPrototypeSearch(parameterString,
NOT_HTTP_TOKEN_CODE_POINT) === -1 &&
SafeStringPrototypeSearch(parameterValue,
NOT_HTTP_QUOTED_STRING_CODE_POINT) === -1 &&
paramsMap.has(parameterString) === false
) {
paramsMap.set(parameterString, parameterValue);
}
position++;
}
this.#data = paramsMap;
this.#processed = true;
}
}
const MIMEParamsStringify = MIMEParams.prototype.toString;
ObjectDefineProperty(MIMEParams.prototype, SymbolIterator, {
__proto__: null,
configurable: true,
value: MIMEParams.prototype.entries,
writable: true,
});
ObjectDefineProperty(MIMEParams.prototype, 'toJSON', {
__proto__: null,
configurable: true,
value: MIMEParamsStringify,
writable: true,
});
const { instantiateMimeParams } = MIMEParams;
delete MIMEParams.instantiateMimeParams;
class MIMEType {
#type;
#subtype;
#parameters;
constructor(string) {
string = `${string}`;
const data = parseTypeAndSubtype(string);
this.#type = data[0];
this.#subtype = data[1];
this.#parameters = instantiateMimeParams(StringPrototypeSlice(string, data[2]));
}
get type() {
return this.#type;
}
set type(v) {
v = `${v}`;
const invalidTypeIndex = SafeStringPrototypeSearch(v, NOT_HTTP_TOKEN_CODE_POINT);
if (v.length === 0 || invalidTypeIndex !== -1) {
throw new ERR_INVALID_MIME_SYNTAX('type', v, invalidTypeIndex);
}
this.#type = toASCIILower(v);
}
get subtype() {
return this.#subtype;
}
set subtype(v) {
v = `${v}`;
const invalidSubtypeIndex = SafeStringPrototypeSearch(v, NOT_HTTP_TOKEN_CODE_POINT);
if (v.length === 0 || invalidSubtypeIndex !== -1) {
throw new ERR_INVALID_MIME_SYNTAX('subtype', v, invalidSubtypeIndex);
}
this.#subtype = toASCIILower(v);
}
get essence() {
return `${this.#type}/${this.#subtype}`;
}
get params() {
return this.#parameters;
}
toString() {
let ret = `${this.#type}/${this.#subtype}`;
const paramStr = FunctionPrototypeCall(MIMEParamsStringify, this.#parameters);
if (paramStr.length) ret += `;${paramStr}`;
return ret;
}
}
ObjectDefineProperty(MIMEType.prototype, 'toJSON', {
__proto__: null,
configurable: true,
value: MIMEType.prototype.toString,
writable: true,
});
module.exports = {
MIMEParams,
MIMEType,
};