@ 261105.16
current
'use strict';

const {
  FunctionPrototypeCall,
  ObjectDefineProperty,
  SafeMap,
  StringPrototypeCharCodeAt,
  StringPrototypeSlice,
  StringPrototypeToLowerCase,
  Symbol,
  SymbolIterator,
  Uint8Array,
} = primordials;
const {
  ERR_ILLEGAL_CONSTRUCTOR,
  ERR_INVALID_MIME_SYNTAX,
} = require('internal/errors').codes;

// Lookup table for the code point classes used by the MIME Sniffing
// standard. Only code points <= 0xFF can be in either class.
// https://mimesniff.spec.whatwg.org/#http-token-code-point
// https://mimesniff.spec.whatwg.org/#http-quoted-string-token-code-point
const kHTTPToken = 1;
const kHTTPQuotedStringToken = 2;
const codePointClass = new Uint8Array(256);
{
  codePointClass[0x09] = kHTTPQuotedStringToken;
  for (let c = 0x20; c <= 0x7E; c++) codePointClass[c] = kHTTPQuotedStringToken;
  for (let c = 0x80; c <= 0xFF; c++) codePointClass[c] = kHTTPQuotedStringToken;
  const tokens = "!#$%&'*+-.^_`|~0123456789" +
    'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
  for (let i = 0; i < tokens.length; i++) {
    codePointClass[StringPrototypeCharCodeAt(tokens, i)] |= kHTTPToken;
  }
}

const kNoThrow = Symbol('kNoThrow');

// https://fetch.spec.whatwg.org/#http-whitespace
function isHTTPWhitespace(c) {
  return c === 0x20 || c === 0x09 || c === 0x0A || c === 0x0D;
}

/**
 * Returns the offset from `start` of the first code point in
 * `str[start, end)` that is not in `cls`, or -1 if they all are.
 * @param {string} str
 * @param {number} start
 * @param {number} end
 * @param {number} cls
 * @returns {number}
 */
function findInvalid(str, start, end, cls) {
  for (let i = start; i < end; i++) {
    const c = StringPrototypeCharCodeAt(str, i);
    if (c > 0xFF || (codePointClass[c] & cls) === 0) return i - start;
  }
  return -1;
}

function toASCIILower(str) {
  let hasUpper = false;
  for (let i = 0; i < str.length; i++) {
    const c = StringPrototypeCharCodeAt(str, i);
    if (c > 0x7F) {
      let result = '';
      for (let j = 0; j < str.length; j++) {
        const char = str[j];
        result += char >= 'A' && char <= 'Z' ?
          StringPrototypeToLowerCase(char) :
          char;
      }
      return result;
    }
    if (c <= 0x5A && c >= 0x41) hasUpper = true;
  }
  // Returning the input unchanged when possible avoids an allocation and
  // keeps its cached hash for Map lookups.
  return hasUpper ? StringPrototypeToLowerCase(str) : str;
}

// Results of parseTypeAndSubtype(). Kept in module state to avoid allocating
// a result object per parse; they are consumed synchronously by the caller.
let parsedType = '';
let parsedSubtype = '';
let parsedParamsStart = 0;
let parsedParamsEnd = 0;
let failedProduction = '';
let failedIndex = -1;

/**
 * Parses the type and subtype of a MIME type string and locates its
 * parameters. Returns false on failure, with failedProduction and
 * failedIndex describing the error.
 * @see https://mimesniff.spec.whatwg.org/#parse-a-mime-type
 * @param {string} str
 * @returns {boolean}
 */
function parseTypeAndSubtype(str) {
  const length = str.length;

  // Skip leading HTTP whitespace.
  let position = 0;
  while (position < length &&
         isHTTPWhitespace(StringPrototypeCharCodeAt(str, position))) {
    position++;
  }

  // Collect the type, up to '/'.
  const typeStart = position;
  let invalidTypeIndex = -1;
  let typeHasUpper = false;
  for (; position < length; position++) {
    const c = StringPrototypeCharCodeAt(str, position);
    if (c === 0x2F /* / */) break;
    if (c > 0xFF || (codePointClass[c] & kHTTPToken) === 0) {
      if (invalidTypeIndex === -1) invalidTypeIndex = position - typeStart;
    } else if (c <= 0x5A && c >= 0x41) {
      typeHasUpper = true;
    }
  }
  if (position === typeStart || invalidTypeIndex !== -1 || position >= length) {
    failedProduction = 'type';
    failedIndex = invalidTypeIndex;
    return false;
  }
  const typeEnd = position;

  // Skip '/' and collect the subtype, up to ';'. In the same pass, find the
  // first non-token code point and where the trailing HTTP whitespace starts.
  position++;
  const subtypeStart = position;
  let subtypeEnd = position;
  let firstNonToken = -1;
  let subtypeHasUpper = false;
  for (; position < length; position++) {
    const c = StringPrototypeCharCodeAt(str, position);
    if (c === 0x3B /* ; */) break;
    if (c <= 0xFF && (codePointClass[c] & kHTTPToken) !== 0) {
      if (c <= 0x5A && c >= 0x41) subtypeHasUpper = true;
    } else if (firstNonToken === -1) {
      firstNonToken = position;
    }
    if (!isHTTPWhitespace(c)) subtypeEnd = position + 1;
  }
  const subtypeRawEnd = position;
  // Whitespace is not a token code point, so a non-token code point at or
  // after subtypeEnd is part of the trailing whitespace, which is removed.
  const invalidSubtypeIndex = firstNonToken !== -1 && firstNonToken < subtypeEnd ?
    firstNonToken - subtypeStart : -1;
  if (subtypeEnd === subtypeStart || invalidSubtypeIndex !== -1) {
    failedProduction = 'subtype';
    failedIndex = invalidSubtypeIndex;
    return false;
  }

  // Parameters are everything after the ';' up to the trailing whitespace.
  let paramsEnd = length;
  while (paramsEnd > subtypeRawEnd &&
         isHTTPWhitespace(StringPrototypeCharCodeAt(str, paramsEnd - 1))) {
    paramsEnd--;
  }

  const type = StringPrototypeSlice(str, typeStart, typeEnd);
  const subtype = StringPrototypeSlice(str, subtypeStart, subtypeEnd);
  // Both are ASCII-only at this point, so toLowerCase() is ASCII lowercase.
  parsedType = typeHasUpper ? StringPrototypeToLowerCase(type) : type;
  parsedSubtype = subtypeHasUpper ? StringPrototypeToLowerCase(subtype) : subtype;
  parsedParamsStart = subtypeRawEnd + 1;
  parsedParamsEnd = paramsEnd;
  return true;
}

/**
 * Parses MIME type parameters from `str[position, end)` into `params`.
 * `position` is just past the ';' that follows the subtype, and `end`
 * excludes trailing HTTP whitespace. This is step 11 of
 * https://mimesniff.spec.whatwg.org/#parse-a-mime-type
 * @param {string} str
 * @param {number} position
 * @param {number} end
 * @param {SafeMap<string, string>} params
 */
function parseParameters(str, position, end, params) {
  while (position < end) {
    // Skip HTTP whitespace.
    while (position < end &&
           isHTTPWhitespace(StringPrototypeCharCodeAt(str, position))) {
      position++;
    }

    // Collect the parameter name, up to ';' or '='.
    const nameStart = position;
    let nameIsToken = true;
    let nameHasUpper = false;
    for (; position < end; position++) {
      const c = StringPrototypeCharCodeAt(str, position);
      if (c === 0x3B /* ; */ || c === 0x3D /* = */) break;
      if (c > 0xFF || (codePointClass[c] & kHTTPToken) === 0) {
        nameIsToken = false;
      } else if (c <= 0x5A && c >= 0x41) {
        nameHasUpper = true;
      }
    }
    const nameEnd = position;

    if (position < end) {
      // Parameters without a value are ignored.
      if (StringPrototypeCharCodeAt(str, position) === 0x3B /* ; */) {
        position++;
        continue;
      }
      // Skip '='.
      position++;
    }
    if (position >= end) break;

    let value;
    if (StringPrototypeCharCodeAt(str, position) === 0x22 /* " */) {
      // Collect an HTTP quoted string with the extract-value flag.
      // https://fetch.spec.whatwg.org/#collect-an-http-quoted-string
      position++;
      value = '';
      let chunkStart = position;
      while (true) {
        while (position < end) {
          const c = StringPrototypeCharCodeAt(str, position);
          if (c === 0x22 /* " */ || c === 0x5C /* \ */) break;
          position++;
        }
        if (position >= end) {
          value += StringPrototypeSlice(str, chunkStart, position);
          break;
        }
        const quoteOrBackslash = StringPrototypeCharCodeAt(str, position);
        value += StringPrototypeSlice(str, chunkStart, position);
        position++;
        if (quoteOrBackslash === 0x5C /* \ */) {
          if (position >= end) {
            value += '\\';
            break;
          }
          // The escaped code point starts the next chunk.
          chunkStart = position;
          position++;
        } else {
          break;
        }
      }
      // Skip anything else up to the next ';'.
      while (position < end &&
             StringPrototypeCharCodeAt(str, position) !== 0x3B /* ; */) {
        position++;
      }
      if (findInvalid(value, 0, value.length, kHTTPQuotedStringToken) !== -1) {
        position++;
        continue;
      }
    } else {
      // Collect the value up to ';', validating it and finding where its
      // trailing HTTP whitespace starts in the same pass. CR and LF are not
      // quoted-string token code points, but trailing ones are removed, so
      // the value is only invalid if a bad code point comes before valueEnd.
      const valueStart = position;
      let valueEnd = position;
      let firstInvalid = -1;
      for (; position < end; position++) {
        const c = StringPrototypeCharCodeAt(str, position);
        if (c === 0x3B /* ; */) break;
        if (firstInvalid === -1 &&
            (c > 0xFF || (codePointClass[c] & kHTTPQuotedStringToken) === 0)) {
          firstInvalid = position;
        }
        if (!isHTTPWhitespace(c)) valueEnd = position + 1;
      }
      // Parameters with an empty or invalid value are ignored.
      if (valueEnd === valueStart ||
          (firstInvalid !== -1 && firstInvalid < valueEnd)) {
        position++;
        continue;
      }
      value = StringPrototypeSlice(str, valueStart, valueEnd);
    }

    if (nameEnd !== nameStart && nameIsToken) {
      // The name is ASCII-only, so toLowerCase() is ASCII lowercase.
      const name = nameHasUpper ?
        StringPrototypeToLowerCase(StringPrototypeSlice(str, nameStart, nameEnd)) :
        StringPrototypeSlice(str, nameStart, nameEnd);
      if (!params.has(name)) params.set(name, value);
    }
    // Skip ';'.
    position++;
  }
}

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 '""';
  if (findInvalid(value, 0, value.length, kHTTPToken) === -1) return value;
  return `"${escapeQuoteOrSolidus(value)}"`;
};

class MIMEParams {
  // Parsing is deferred until the parameters are first accessed, as most
  // users only need the type and subtype. Until then #data is null and
  // #string[#start, #end) holds the unparsed parameters.
  #data = null;
  #string = null;
  #start = 0;
  #end = 0;

  /**
   * Used to instantiate a MIMEParams object within the MIMEType class and
   * to allow it to be parsed lazily.
   * @returns {MIMEParams}
   */
  static instantiateMimeParams(str, start, end) {
    const instance = new MIMEParams();
    instance.#string = str;
    instance.#start = start;
    instance.#end = end;
    return instance;
  }

  /**
   * @param {string} name
   * @returns {void}
   */
  delete(name) {
    this.#parse().delete(toASCIILower(`${name}`));
  }

  get(name) {
    const value = this.#parse().get(toASCIILower(`${name}`));
    return value === undefined ? null : value;
  }

  has(name) {
    return this.#parse().has(toASCIILower(`${name}`));
  }

  set(name, value) {
    const data = this.#parse();
    name = toASCIILower(`${name}`);
    value = `${value}`;
    const invalidNameIndex = findInvalid(name, 0, name.length, kHTTPToken);
    if (name.length === 0 || invalidNameIndex !== -1) {
      throw new ERR_INVALID_MIME_SYNTAX(
        'parameter name',
        name,
        invalidNameIndex,
      );
    }
    const invalidValueIndex = findInvalid(value, 0, value.length,
                                          kHTTPQuotedStringToken);
    if (invalidValueIndex !== -1) {
      throw new ERR_INVALID_MIME_SYNTAX(
        'parameter value',
        value,
        invalidValueIndex,
      );
    }
    data.set(name, value);
  }

  *entries() {
    yield* this.#parse().entries();
  }

  *keys() {
    yield* this.#parse().keys();
  }

  *values() {
    yield* this.#parse().values();
  }

  toString() {
    let ret = '';
    for (const { 0: key, 1: value } of this.#parse()) {
      const encoded = encode(value);
      // Ensure they are separated
      if (ret.length) ret += ';';
      ret += `${key}=${encoded}`;
    }
    return ret;
  }

  /**
   * Parses the deferred parameter string on first use.
   * @returns {SafeMap<string, string>}
   */
  #parse() {
    let data = this.#data;
    if (data !== null) return data;
    data = this.#data = new SafeMap();
    const str = this.#string;
    if (str !== null) {
      this.#string = null;
      parseParameters(str, this.#start, this.#end, data);
    }
    return data;
  }
}
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, noThrowSymbol = null) {
    // MIMEType.parse() has already parsed the string successfully.
    if (noThrowSymbol === kNoThrow) {
      this.#init(string);
      return;
    }
    string = `${string}`;
    if (noThrowSymbol != null) {
      throw new ERR_ILLEGAL_CONSTRUCTOR();
    }
    if (!parseTypeAndSubtype(string)) {
      throw new ERR_INVALID_MIME_SYNTAX(failedProduction, string, failedIndex);
    }
    this.#init(string);
  }

  // Consumes the results of a successful parseTypeAndSubtype(string).
  #init(string) {
    this.#type = parsedType;
    this.#subtype = parsedSubtype;
    this.#parameters = instantiateMimeParams(string, parsedParamsStart,
                                             parsedParamsEnd);
  }

  // Like the constructor, but returns null instead of throwing on invalid input.
  static parse(string) {
    string = `${string}`;
    if (!parseTypeAndSubtype(string)) return null;
    return new MIMEType(string, kNoThrow);
  }

  get type() {
    return this.#type;
  }

  set type(v) {
    v = `${v}`;
    const invalidTypeIndex = findInvalid(v, 0, v.length, kHTTPToken);
    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 = findInvalid(v, 0, v.length, kHTTPToken);
    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,
};