diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..773ca2d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true +quote_type = single diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 62562b7..0000000 --- a/.eslintignore +++ /dev/null @@ -1,2 +0,0 @@ -coverage -node_modules diff --git a/.eslintrc.yml b/.eslintrc.yml deleted file mode 100644 index d464944..0000000 --- a/.eslintrc.yml +++ /dev/null @@ -1,11 +0,0 @@ -root: true -extends: - - standard - - plugin:markdown/recommended -plugins: - - markdown -parserOptions: - ecmaVersion: 2021 -overrides: - - files: '**/*.md' - processor: 'markdown/markdown' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5517d49..8fff328 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,93 +1,26 @@ -name: ci - +name: CI on: - push: - branches: - - master - paths-ignore: - - '*.md' - pull_request: - paths-ignore: - - '*.md' - + - push + - pull_request permissions: contents: read - jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - name: Install Node.js - uses: actions/setup-node@v6 - with: - node-version: 'lts/*' - - - name: Install Node.js dependencies - run: npm install --ignore-scripts --include=dev - - - name: Lint code - run: npm run lint - - test: - name: Test - Node.js ${{ matrix.node-version }} + test: + name: Node.js ${{ matrix.node-version }} runs-on: ubuntu-latest strategy: - fail-fast: false - matrix: - # Node.js release schedule: https://nodejs.org/en/about/releases/ - node-version: [18, 19, 20, 21, 22, 23, 24, 25] - steps: - - uses: actions/checkout@v6 - - - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v6 - with: - node-version: ${{ matrix.node-version }} - - - name: Install Node.js dependencies - run: npm install - - - name: Output Node and NPM versions - run: | - echo "Node.js version: $(node -v)" - echo "NPM version: $(npm -v)" - - - name: Run tests - run: npm run test-ci - - - name: Upload code coverage - uses: actions/upload-artifact@v5 - with: - name: coverage-node-${{ matrix.node-version }} - path: ./coverage/lcov.info - retention-days: 1 - - coverage: - name: Coverage - needs: test - runs-on: ubuntu-latest - permissions: - contents: read - checks: write + matrix: + node-version: + - 18 + - '*' steps: - uses: actions/checkout@v6 - - - name: Install lcov - run: sudo apt-get -y install lcov - - - name: Collect coverage reports - uses: actions/download-artifact@v6 + - uses: actions/setup-node@v6 with: - path: ./coverage - pattern: coverage-node-* - - - name: Merge coverage reports - run: find ./coverage -name lcov.info -exec printf '-a %q\n' {} \; | xargs lcov -o ./lcov.info - - - name: Upload coverage report - uses: coverallsapp/github-action@v2 + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm test + - uses: codecov/codecov-action@v5 with: - file: ./lcov.info + name: Node.js ${{ matrix.node-version }} + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8ca67e1..8414722 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -9,16 +9,16 @@ # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # -name: "CodeQL" +name: 'CodeQL' on: push: - branches: ["master"] + branches: ['master'] pull_request: # The branches below must be a subset of the branches above - branches: ["master"] + branches: ['master'] schedule: - - cron: "0 0 * * 1" + - cron: '0 0 * * 1' permissions: contents: read @@ -63,4 +63,4 @@ jobs: - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v4 with: - category: "/language:javascript" + category: '/language:javascript' diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index f30d982..05988c7 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -12,7 +12,7 @@ on: schedule: - cron: '16 21 * * 1' push: - branches: [ "master" ] + branches: ['master'] # Declare default permissions as read only. permissions: read-all @@ -31,12 +31,12 @@ jobs: # actions: read steps: - - name: "Checkout code" + - name: 'Checkout code' uses: actions/checkout@v6 with: persist-credentials: false - - name: "Run analysis" + - name: 'Run analysis' uses: ossf/scorecard-action@v2.4.3 with: results_file: results.sarif @@ -58,7 +58,7 @@ jobs: # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # format to the repository Actions tab. - - name: "Upload artifact" + - name: 'Upload artifact' uses: actions/upload-artifact@v6 with: name: SARIF file @@ -66,7 +66,7 @@ jobs: retention-days: 5 # Upload the results to GitHub's code scanning dashboard. - - name: "Upload to code-scanning" + - name: 'Upload to code-scanning' uses: github/codeql-action/upload-sarif@v4 with: - sarif_file: results.sarif \ No newline at end of file + sarif_file: results.sarif diff --git a/.gitignore b/.gitignore index 0fa6951..7c269f2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ coverage/ node_modules/ npm-debug.log package-lock.json +dist/ +*.tsbuildinfo \ No newline at end of file diff --git a/HISTORY.md b/HISTORY.md index 0a4c996..a54b599 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,77 +1,62 @@ -unreleased -================= +# unreleased - * refactor: use simplified `basename` function and remove dependency on `node:path` +- refactor: use simplified `basename` function and remove dependency on `node:path` -1.0.1 / 2025-11-18 -================= +# 1.0.1 / 2025-11-18 - * Updated `engines` field to Node@18 or higher (fixed reference, see 1.0.0) - * Remove dependency `safe-buffer` +- Updated `engines` field to Node@18 or higher (fixed reference, see 1.0.0) +- Remove dependency `safe-buffer` -1.0.0 / 2024-08-31 -================== +# 1.0.0 / 2024-08-31 - * drop node <18 - * allow utf8 as alias for utf-8 +- drop node <18 +- allow utf8 as alias for utf-8 -0.5.4 / 2021-12-10 -================== +# 0.5.4 / 2021-12-10 - * deps: safe-buffer@5.2.1 +- deps: safe-buffer@5.2.1 -0.5.3 / 2018-12-17 -================== +# 0.5.3 / 2018-12-17 - * Use `safe-buffer` for improved Buffer API +- Use `safe-buffer` for improved Buffer API -0.5.2 / 2016-12-08 -================== +# 0.5.2 / 2016-12-08 - * Fix `parse` to accept any linear whitespace character +- Fix `parse` to accept any linear whitespace character -0.5.1 / 2016-01-17 -================== +# 0.5.1 / 2016-01-17 - * perf: enable strict mode +- perf: enable strict mode -0.5.0 / 2014-10-11 -================== +# 0.5.0 / 2014-10-11 - * Add `parse` function +- Add `parse` function -0.4.0 / 2014-09-21 -================== +# 0.4.0 / 2014-09-21 - * Expand non-Unicode `filename` to the full ISO-8859-1 charset +- Expand non-Unicode `filename` to the full ISO-8859-1 charset -0.3.0 / 2014-09-20 -================== +# 0.3.0 / 2014-09-20 - * Add `fallback` option - * Add `type` option +- Add `fallback` option +- Add `type` option -0.2.0 / 2014-09-19 -================== +# 0.2.0 / 2014-09-19 - * Reduce ambiguity of file names with hex escape in buggy browsers +- Reduce ambiguity of file names with hex escape in buggy browsers -0.1.2 / 2014-09-19 -================== +# 0.1.2 / 2014-09-19 - * Fix periodic invalid Unicode filename header +- Fix periodic invalid Unicode filename header -0.1.1 / 2014-09-19 -================== +# 0.1.1 / 2014-09-19 - * Fix invalid characters appearing in `filename*` parameter +- Fix invalid characters appearing in `filename*` parameter -0.1.0 / 2014-09-18 -================== +# 0.1.0 / 2014-09-18 - * Make the `filename` argument optional +- Make the `filename` argument optional -0.0.0 / 2014-09-18 -================== +# 0.0.0 / 2014-09-18 - * Initial release +- Initial release diff --git a/README.md b/README.md index 75c91a9..b407bdc 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ $ npm install content-disposition ## API ```js -const contentDisposition = require('content-disposition') +const contentDisposition = require('content-disposition'); ``` ### contentDisposition(filename, options) @@ -27,7 +27,7 @@ if supplied. The `filename` is optional and if no file name is desired, but you want to specify `options`, set `filename` to `undefined`. ```js -res.setHeader('Content-Disposition', contentDisposition('∫ maths.pdf')) +res.setHeader('Content-Disposition', contentDisposition('∫ maths.pdf')); ``` **note** HTTP headers are of the ISO-8859-1 character set. If you are writing this @@ -47,11 +47,11 @@ a ISO-8859-1 version of the file name is automatically generated. This specifies the ISO-8859-1 file name to override the automatic generation or disables the generation all together, defaults to `true`. - - A string will specify the ISO-8859-1 file name to use in place of automatic - generation. - - `false` will disable including a ISO-8859-1 file name and only include the - Unicode version (unless the file name is already ISO-8859-1). - - `true` will enable automatic generation if the file name is outside ISO-8859-1. +- A string will specify the ISO-8859-1 file name to use in place of automatic + generation. +- `false` will disable including a ISO-8859-1 file name and only include the + Unicode version (unless the file name is already ISO-8859-1). +- `true` will enable automatic generation if the file name is outside ISO-8859-1. If the `filename` option is ISO-8859-1 and this option is specified and has a different value, then the `filename` option is encoded in the extended field @@ -67,7 +67,9 @@ it). The type is normalized to lower-case. ### contentDisposition.parse(string) ```js -const disposition = contentDisposition.parse('attachment; filename="EURO rates.txt"; filename*=UTF-8\'\'%e2%82%ac%20rates.txt') +const disposition = contentDisposition.parse( + 'attachment; filename="EURO rates.txt"; filename*=UTF-8\'\'%e2%82%ac%20rates.txt', +); ``` Parse a `Content-Disposition` header string. This automatically handles extended @@ -75,36 +77,36 @@ Parse a `Content-Disposition` header string. This automatically handles extended parameter name. This will return an object with the following properties (examples are shown for the string `'attachment; filename="EURO rates.txt"; filename*=UTF-8\'\'%e2%82%ac%20rates.txt'`): - - `type`: The disposition type (always lower case). Example: `'attachment'` +- `type`: The disposition type (always lower case). Example: `'attachment'` - - `parameters`: An object of the parameters in the disposition (name of parameter - always lower case and extended versions replace non-extended versions). Example: - `{filename: "€ rates.txt"}` +- `parameters`: An object of the parameters in the disposition (name of parameter + always lower case and extended versions replace non-extended versions). Example: + `{filename: "€ rates.txt"}` ## Examples ### Send a file for download ```js -const contentDisposition = require('content-disposition') -const fs = require('fs') -const http = require('http') -const onFinished = require('on-finished') +const contentDisposition = require('content-disposition'); +const fs = require('fs'); +const http = require('http'); +const onFinished = require('on-finished'); -const filePath = '/path/to/public/plans.pdf' +const filePath = '/path/to/public/plans.pdf'; -http.createServer(function onRequest (req, res) { +http.createServer(function onRequest(req, res) { // set headers - res.setHeader('Content-Type', 'application/pdf') - res.setHeader('Content-Disposition', contentDisposition(filePath)) + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', contentDisposition(filePath)); // send file - const stream = fs.createReadStream(filePath) - stream.pipe(res) + const stream = fs.createReadStream(filePath); + stream.pipe(res); onFinished(res, function () { - stream.destroy() - }) -}) + stream.destroy(); + }); +}); ``` ## Testing diff --git a/index.js b/index.js deleted file mode 100644 index d1e313a..0000000 --- a/index.js +++ /dev/null @@ -1,536 +0,0 @@ -/*! - * content-disposition - * Copyright(c) 2014-2017 Douglas Christopher Wilson - * MIT Licensed - */ - -'use strict' - -/** - * Module exports. - * @public - */ - -module.exports = contentDisposition -module.exports.parse = parse - -/** - * TextDecoder instance for UTF-8 decoding when decodeURIComponent fails due to invalid byte sequences. - * @type {TextDecoder} - * @private - */ -const utf8Decoder = new TextDecoder('utf-8') - -/** - * RegExp to match non attr-char, *after* encodeURIComponent (i.e. not including "%") - * @private - */ - -var ENCODE_URL_ATTR_CHAR_REGEXP = /[\x00-\x20"'()*,/:;<=>?@[\\\]{}\x7f]/g // eslint-disable-line no-control-regex - -/** - * RegExp to match non-latin1 characters. - * @private - */ - -var NON_LATIN1_REGEXP = /[^\x20-\x7e\xa0-\xff]/g - -/** - * RegExp to match quoted-pair in RFC 2616 - * - * quoted-pair = "\" CHAR - * CHAR = - * @private - */ - -var QESC_REGEXP = /\\([\u0000-\u007f])/g // eslint-disable-line no-control-regex - -/** - * RegExp to match chars that must be quoted-pair in RFC 2616 - * @private - */ - -var QUOTE_REGEXP = /([\\"])/g - -/** - * RegExp for various RFC 2616 grammar - * - * parameter = token "=" ( token | quoted-string ) - * token = 1* - * separators = "(" | ")" | "<" | ">" | "@" - * | "," | ";" | ":" | "\" | <"> - * | "/" | "[" | "]" | "?" | "=" - * | "{" | "}" | SP | HT - * quoted-string = ( <"> *(qdtext | quoted-pair ) <"> ) - * qdtext = > - * quoted-pair = "\" CHAR - * CHAR = - * TEXT = - * LWS = [CRLF] 1*( SP | HT ) - * CRLF = CR LF - * CR = - * LF = - * SP = - * HT = - * CTL = - * OCTET = - * @private - */ - -var PARAM_REGEXP = /;[\x09\x20]*([!#$%&'*+.0-9A-Z^_`a-z|~-]+)[\x09\x20]*=[\x09\x20]*("(?:[\x20!\x23-\x5b\x5d-\x7e\x80-\xff]|\\[\x20-\x7e])*"|[!#$%&'*+.0-9A-Z^_`a-z|~-]+)[\x09\x20]*/g // eslint-disable-line no-control-regex -var TEXT_REGEXP = /^[\x20-\x7e\x80-\xff]+$/ -var TOKEN_REGEXP = /^[!#$%&'*+.0-9A-Z^_`a-z|~-]+$/ - -/** - * RegExp for various RFC 5987 grammar - * - * ext-value = charset "'" [ language ] "'" value-chars - * charset = "UTF-8" / "ISO-8859-1" / mime-charset - * mime-charset = 1*mime-charsetc - * mime-charsetc = ALPHA / DIGIT - * / "!" / "#" / "$" / "%" / "&" - * / "+" / "-" / "^" / "_" / "`" - * / "{" / "}" / "~" - * language = ( 2*3ALPHA [ extlang ] ) - * / 4ALPHA - * / 5*8ALPHA - * extlang = *3( "-" 3ALPHA ) - * value-chars = *( pct-encoded / attr-char ) - * pct-encoded = "%" HEXDIG HEXDIG - * attr-char = ALPHA / DIGIT - * / "!" / "#" / "$" / "&" / "+" / "-" / "." - * / "^" / "_" / "`" / "|" / "~" - * @private - */ - -var EXT_VALUE_REGEXP = /^([A-Za-z0-9!#$%&+\-^_`{}~]+)'(?:[A-Za-z]{2,3}(?:-[A-Za-z]{3}){0,3}|[A-Za-z]{4,8}|)'((?:%[0-9A-Fa-f]{2}|[A-Za-z0-9!#$&+.^_`|~-])+)$/ - -/** - * RegExp for various RFC 6266 grammar - * - * disposition-type = "inline" | "attachment" | disp-ext-type - * disp-ext-type = token - * disposition-parm = filename-parm | disp-ext-parm - * filename-parm = "filename" "=" value - * | "filename*" "=" ext-value - * disp-ext-parm = token "=" value - * | ext-token "=" ext-value - * ext-token = - * @private - */ - -var DISPOSITION_TYPE_REGEXP = /^([!#$%&'*+.0-9A-Z^_`a-z|~-]+)[\x09\x20]*(?:$|;)/ // eslint-disable-line no-control-regex - -/** - * Create an attachment Content-Disposition header. - * - * @param {string} [filename] - * @param {object} [options] - * @param {string} [options.type=attachment] - * @param {string|boolean} [options.fallback=true] - * @return {string} - * @public - */ - -function contentDisposition (filename, options) { - var opts = options || {} - - // get type - var type = opts.type || 'attachment' - - // get parameters - var params = createparams(filename, opts.fallback) - - // format into string - return format(new ContentDisposition(type, params)) -} - -/** - * Create parameters object from filename and fallback. - * - * @param {string} [filename] - * @param {string|boolean} [fallback=true] - * @return {object} - * @private - */ - -function createparams (filename, fallback) { - if (filename === undefined) { - return - } - - var params = {} - - if (typeof filename !== 'string') { - throw new TypeError('filename must be a string') - } - - // fallback defaults to true - if (fallback === undefined) { - fallback = true - } - - if (typeof fallback !== 'string' && typeof fallback !== 'boolean') { - throw new TypeError('fallback must be a string or boolean') - } - - if (typeof fallback === 'string' && NON_LATIN1_REGEXP.test(fallback)) { - throw new TypeError('fallback must be ISO-8859-1 string') - } - - // restrict to file base name - var name = basename(filename) - - // determine if name is suitable for quoted string - var isQuotedString = TEXT_REGEXP.test(name) - - // generate fallback name - var fallbackName = typeof fallback !== 'string' - ? fallback && getlatin1(name) - : basename(fallback) - var hasFallback = typeof fallbackName === 'string' && fallbackName !== name - - // set extended filename parameter - if (hasFallback || !isQuotedString || hasHexEscape(name)) { - params['filename*'] = name - } - - // set filename parameter - if (isQuotedString || hasFallback) { - params.filename = hasFallback - ? fallbackName - : name - } - - return params -} - -/** - * Format object to Content-Disposition header. - * - * @param {object} obj - * @param {string} obj.type - * @param {object} [obj.parameters] - * @return {string} - * @private - */ - -function format (obj) { - var parameters = obj.parameters - var type = obj.type - - if (!type || typeof type !== 'string' || !TOKEN_REGEXP.test(type)) { - throw new TypeError('invalid type') - } - - // start with normalized type - var string = String(type).toLowerCase() - - // append parameters - if (parameters && typeof parameters === 'object') { - var param - var params = Object.keys(parameters).sort() - - for (var i = 0; i < params.length; i++) { - param = params[i] - - var val = param.slice(-1) === '*' - ? ustring(parameters[param]) - : qstring(parameters[param]) - - string += '; ' + param + '=' + val - } - } - - return string -} - -/** - * Decode a RFC 5987 field value (gracefully). - * - * @param {string} str - * @return {string} - * @private - */ - -function decodefield (str) { - const match = EXT_VALUE_REGEXP.exec(str) - - if (!match) { - throw new TypeError('invalid extended field value') - } - - const charset = match[1].toLowerCase() - const encoded = match[2] - - switch (charset) { - case 'iso-8859-1': - { - const binary = decodeHexEscapes(encoded) - return getlatin1(binary) - } - case 'utf-8': - case 'utf8': - { - try { - return decodeURIComponent(encoded) - } catch { - // Failed to decode with decodeURIComponent, fallback to lenient decoding which replaces invalid UTF-8 byte sequences with the Unicode replacement character - // TODO: Consider removing in the next major version to be more strict about invalid percent-encodings - const binary = decodeHexEscapes(encoded) - - const bytes = new Uint8Array(binary.length) - for (let idx = 0; idx < binary.length; idx++) { - bytes[idx] = binary.charCodeAt(idx) - } - - return utf8Decoder.decode(bytes) - } - } - } - throw new TypeError('unsupported charset in extended field') -} - -/** - * Get ISO-8859-1 version of string. - * - * @param {string} val - * @return {string} - * @private - */ - -function getlatin1 (val) { - // simple Unicode -> ISO-8859-1 transformation - return String(val).replace(NON_LATIN1_REGEXP, '?') -} - -/** - * Parse Content-Disposition header string. - * - * @param {string} string - * @return {object} - * @public - */ - -function parse (string) { - if (!string || typeof string !== 'string') { - throw new TypeError('argument string is required') - } - - var match = DISPOSITION_TYPE_REGEXP.exec(string) - - if (!match) { - throw new TypeError('invalid type format') - } - - // normalize type - var index = match[0].length - var type = match[1].toLowerCase() - - var key - var names = [] - var params = {} - var value - - // calculate index to start at - index = PARAM_REGEXP.lastIndex = match[0].slice(-1) === ';' - ? index - 1 - : index - - // match parameters - while ((match = PARAM_REGEXP.exec(string))) { - if (match.index !== index) { - throw new TypeError('invalid parameter format') - } - - index += match[0].length - key = match[1].toLowerCase() - value = match[2] - - if (names.indexOf(key) !== -1) { - throw new TypeError('invalid duplicate parameter') - } - - names.push(key) - - if (key.indexOf('*') + 1 === key.length) { - // decode extended value - key = key.slice(0, -1) - value = decodefield(value) - - // overwrite existing value - params[key] = value - continue - } - - if (typeof params[key] === 'string') { - continue - } - - if (value[0] === '"') { - // remove quotes and escapes - value = value - .slice(1, -1) - .replace(QESC_REGEXP, '$1') - } - - params[key] = value - } - - if (index !== -1 && index !== string.length) { - throw new TypeError('invalid parameter format') - } - - return new ContentDisposition(type, params) -} - -/** - * Percent encode a single character. - * - * @param {string} char - * @return {string} - * @private - */ - -function pencode (char) { - return '%' + String(char) - .charCodeAt(0) - .toString(16) - .toUpperCase() -} - -/** - * Quote a string for HTTP. - * - * @param {string} val - * @return {string} - * @private - */ - -function qstring (val) { - var str = String(val) - - return '"' + str.replace(QUOTE_REGEXP, '\\$1') + '"' -} - -/** - * Encode a Unicode string for HTTP (RFC 5987). - * - * @param {string} val - * @return {string} - * @private - */ - -function ustring (val) { - var str = String(val) - - // percent encode as UTF-8 - var encoded = encodeURIComponent(str) - .replace(ENCODE_URL_ATTR_CHAR_REGEXP, pencode) - - return 'UTF-8\'\'' + encoded -} - -/** - * Class for parsed Content-Disposition header for v8 optimization - * - * @public - * @param {string} type - * @param {object} parameters - * @constructor - */ - -function ContentDisposition (type, parameters) { - this.type = type - this.parameters = parameters -} - -/** - * Return the last portion of a path - * - * @param {string} path - * @returns {string} - */ -function basename (path) { - const normalized = path.replaceAll('\\', '/') - - let end = normalized.length - while (end > 0 && normalized[end - 1] === '/') { - end-- - } - - if (end === 0) { - return '' - } - - let start = end - 1 - while (start >= 0 && normalized[start] !== '/') { - start-- - } - - return normalized.slice(start + 1, end) -} - -/** - * Check if a character is a hex digit [0-9A-Fa-f] - * - * @param {string} char - * @return {boolean} - * @private - */ -function isHexDigit (char) { - const code = char.charCodeAt(0) - return ( - (code >= 48 && code <= 57) || // 0-9 - (code >= 65 && code <= 70) || // A-F - (code >= 97 && code <= 102) // a-f - ) -} - -/** - * Check if a string contains percent encoding escapes. - * - * @param {string} str - * @return {boolean} - * @private - */ -function hasHexEscape (str) { - const maxIndex = str.length - 3 - let lastIndex = -1 - - while ((lastIndex = str.indexOf('%', lastIndex + 1)) !== -1 && lastIndex <= maxIndex) { - if (isHexDigit(str[lastIndex + 1]) && isHexDigit(str[lastIndex + 2])) { - return true - } - } - - return false -} - -/** - * Decode hex escapes in a string (e.g., %20 -> space) - * - * @param {string} str - * @return {string} - * @private - */ -function decodeHexEscapes (str) { - const firstEscape = str.indexOf('%') - if (firstEscape === -1) return str - - let result = str.slice(0, firstEscape) - for (let idx = firstEscape; idx < str.length; idx++) { - if ( - str[idx] === '%' && - idx + 2 < str.length && - isHexDigit(str[idx + 1]) && - isHexDigit(str[idx + 2]) - ) { - result += String.fromCharCode(Number.parseInt(str[idx + 1] + str[idx + 2], 16)) - idx += 2 - } else { - result += str[idx] - } - } - return result -} diff --git a/package.json b/package.json index 4f1d8a3..809b7ff 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,7 @@ { "name": "content-disposition", - "description": "Create and parse Content-Disposition header", "version": "1.0.1", - "author": "Douglas Christopher Wilson ", - "license": "MIT", + "description": "Create and parse Content-Disposition header", "keywords": [ "content-disposition", "http", @@ -15,26 +13,38 @@ "type": "opencollective", "url": "https://opencollective.com/express" }, - "devDependencies": { - "c8": "^10.1.2", - "eslint": "^8.57.1", - "eslint-config-standard": "^14.1.1", - "eslint-plugin-import": "^2.32.0", - "eslint-plugin-markdown": "^3.0.1", - "eslint-plugin-node": "^11.1.0", - "eslint-plugin-promise": "^6.6.0", - "eslint-plugin-standard": "^4.1.0" - }, + "license": "MIT", + "author": "Douglas Christopher Wilson ", + "type": "commonjs", + "exports": "./dist/index.js", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", "files": [ - "index.js" + "dist/" ], + "scripts": { + "build": "ts-scripts build", + "format": "ts-scripts format", + "lint": "ts-scripts lint", + "prepare": "ts-scripts install && npm run build", + "specs": "ts-scripts specs", + "test": "ts-scripts test" + }, + "devDependencies": { + "@borderless/ts-scripts": "^0.15.0", + "@vitest/coverage-v8": "^3.2.4", + "typescript": "^5.9.3", + "vitest": "^3.2.4" + }, "engines": { "node": ">=18" }, - "scripts": { - "lint": "eslint .", - "test": "node --test --test-reporter spec", - "test-ci": "c8 --reporter=lcovonly --reporter=text npm test", - "test-cov": "c8 --reporter=html --reporter=text npm test" + "ts-scripts": { + "dist": [ + "dist" + ], + "project": [ + "tsconfig.build.json" + ] } } diff --git a/src/contentDisposition.spec.ts b/src/contentDisposition.spec.ts new file mode 100644 index 0000000..71544b9 --- /dev/null +++ b/src/contentDisposition.spec.ts @@ -0,0 +1,318 @@ +import { describe, it, assert } from 'vitest'; +import contentDisposition from './index'; + +describe('create()', function () { + it('should create an attachment header', function () { + assert.strictEqual(contentDisposition(), 'attachment'); + }); +}); + +describe('create(filename)', function () { + it('should require a string', function () { + assert.throws(contentDisposition.bind(null, 42 as any), /filename.*string/); + }); + + it('should create a header with file name', function () { + assert.strictEqual( + contentDisposition('plans.pdf'), + 'attachment; filename="plans.pdf"', + ); + }); + + it('should use the basename of a posix path', function () { + assert.strictEqual( + contentDisposition('/path/to/plans.pdf'), + 'attachment; filename="plans.pdf"', + ); + }); + + it('should use the basename of a windows path', function () { + assert.strictEqual( + contentDisposition('\\path\\to\\plans.pdf'), + 'attachment; filename="plans.pdf"', + ); + }); + + it('should use the basename of a windows path with drive letter', function () { + assert.strictEqual( + contentDisposition('C:\\path\\to\\plans.pdf'), + 'attachment; filename="plans.pdf"', + ); + }); + + it('should use the basename of a posix path with trailing slash', function () { + assert.strictEqual( + contentDisposition('/path/to/plans.pdf/'), + 'attachment; filename="plans.pdf"', + ); + }); + + it('should use the basename of a windows path with trailing slash', function () { + assert.strictEqual( + contentDisposition('\\path\\to\\plans.pdf\\'), + 'attachment; filename="plans.pdf"', + ); + }); + + it('should use the basename of a windows path with drive letter and trailing slash', function () { + assert.strictEqual( + contentDisposition('C:\\path\\to\\plans.pdf\\'), + 'attachment; filename="plans.pdf"', + ); + }); + + it('should use the basename of a posix path with trailing slashes', function () { + assert.strictEqual( + contentDisposition('/path/to/plans.pdf///'), + 'attachment; filename="plans.pdf"', + ); + }); + + it('should use the basename of a windows path with trailing slashes', function () { + assert.strictEqual( + contentDisposition('\\path\\to\\plans.pdf\\\\\\'), + 'attachment; filename="plans.pdf"', + ); + }); + + it('should use the basename of a windows path with drive letter and trailing slashes', function () { + assert.strictEqual( + contentDisposition('C:\\path\\to\\plans.pdf\\\\\\'), + 'attachment; filename="plans.pdf"', + ); + }); + + describe('when "filename" is US-ASCII', function () { + it('should only include filename parameter', function () { + assert.strictEqual( + contentDisposition('plans.pdf'), + 'attachment; filename="plans.pdf"', + ); + }); + + it('should escape quotes', function () { + assert.strictEqual( + contentDisposition('the "plans".pdf'), + 'attachment; filename="the \\"plans\\".pdf"', + ); + }); + }); + + describe('when "filename" is ISO-8859-1', function () { + it('should only include filename parameter', function () { + assert.strictEqual( + contentDisposition('«plans».pdf'), + 'attachment; filename="«plans».pdf"', + ); + }); + + it('should escape quotes', function () { + assert.strictEqual( + contentDisposition('the "plans" (1µ).pdf'), + 'attachment; filename="the \\"plans\\" (1µ).pdf"', + ); + }); + }); + + describe('when "filename" is Unicode', function () { + it('should include filename* parameter', function () { + assert.strictEqual( + contentDisposition('планы.pdf'), + 'attachment; filename="?????.pdf"; filename*=UTF-8\'\'%D0%BF%D0%BB%D0%B0%D0%BD%D1%8B.pdf', + ); + }); + + it('should include filename fallback', function () { + assert.strictEqual( + contentDisposition('£ and € rates.pdf'), + 'attachment; filename="£ and ? rates.pdf"; filename*=UTF-8\'\'%C2%A3%20and%20%E2%82%AC%20rates.pdf', + ); + assert.strictEqual( + contentDisposition('€ rates.pdf'), + 'attachment; filename="? rates.pdf"; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf', + ); + }); + + it('should encode special characters', function () { + assert.strictEqual( + contentDisposition("€'*%().pdf"), + "attachment; filename=\"?'*%().pdf\"; filename*=UTF-8''%E2%82%AC%27%2A%25%28%29.pdf", + ); + }); + }); + + describe('when "filename" contains hex escape', function () { + it('should include filename* parameter', function () { + assert.strictEqual( + contentDisposition('the%20plans.pdf'), + 'attachment; filename="the%20plans.pdf"; filename*=UTF-8\'\'the%2520plans.pdf', + ); + }); + + it('should handle Unicode', function () { + assert.strictEqual( + contentDisposition('€%20£.pdf'), + 'attachment; filename="?%20£.pdf"; filename*=UTF-8\'\'%E2%82%AC%2520%C2%A3.pdf', + ); + }); + }); +}); + +describe('create(filename, options)', function () { + describe('with "fallback" option', function () { + it('should require a string or Boolean', function () { + assert.throws( + contentDisposition.bind(null, 'plans.pdf', { fallback: 42 } as any), + /fallback.*string/, + ); + }); + + it('should default to true', function () { + assert.strictEqual( + contentDisposition('€ rates.pdf'), + 'attachment; filename="? rates.pdf"; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf', + ); + }); + + describe('when "false"', function () { + it('should not generate ISO-8859-1 fallback', function () { + assert.strictEqual( + contentDisposition('£ and € rates.pdf', { fallback: false }), + "attachment; filename*=UTF-8''%C2%A3%20and%20%E2%82%AC%20rates.pdf", + ); + }); + + it('should keep ISO-8859-1 filename', function () { + assert.strictEqual( + contentDisposition('£ rates.pdf', { fallback: false }), + 'attachment; filename="£ rates.pdf"', + ); + }); + }); + + describe('when "true"', function () { + it('should generate ISO-8859-1 fallback', function () { + assert.strictEqual( + contentDisposition('£ and € rates.pdf', { fallback: true }), + 'attachment; filename="£ and ? rates.pdf"; filename*=UTF-8\'\'%C2%A3%20and%20%E2%82%AC%20rates.pdf', + ); + }); + + it('should pass through ISO-8859-1 filename', function () { + assert.strictEqual( + contentDisposition('£ rates.pdf', { fallback: true }), + 'attachment; filename="£ rates.pdf"', + ); + }); + }); + + describe('when a string', function () { + it('should require an ISO-8859-1 string', function () { + assert.throws( + contentDisposition.bind(null, '€ rates.pdf', { + fallback: '€ rates.pdf', + }), + /fallback.*iso-8859-1/i, + ); + }); + + it('should use as ISO-8859-1 fallback', function () { + assert.strictEqual( + contentDisposition('£ and € rates.pdf', { + fallback: '£ and EURO rates.pdf', + }), + 'attachment; filename="£ and EURO rates.pdf"; filename*=UTF-8\'\'%C2%A3%20and%20%E2%82%AC%20rates.pdf', + ); + }); + + it('should use as fallback even when filename is ISO-8859-1', function () { + assert.strictEqual( + contentDisposition('"£ rates".pdf', { fallback: '£ rates.pdf' }), + 'attachment; filename="£ rates.pdf"; filename*=UTF-8\'\'%22%C2%A3%20rates%22.pdf', + ); + }); + + it('should do nothing if equal to filename', function () { + assert.strictEqual( + contentDisposition('plans.pdf', { fallback: 'plans.pdf' }), + 'attachment; filename="plans.pdf"', + ); + }); + + it('should use the basename of a posix path', function () { + assert.strictEqual( + contentDisposition('€ rates.pdf', { + fallback: '/path/to/EURO rates.pdf', + }), + 'attachment; filename="EURO rates.pdf"; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf', + ); + }); + + it('should use the basename of a windows path', function () { + assert.strictEqual( + contentDisposition('€ rates.pdf', { + fallback: '\\path\\to\\EURO rates.pdf', + }), + 'attachment; filename="EURO rates.pdf"; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf', + ); + }); + + it('should use the basename of a windows path with drive letter', function () { + assert.strictEqual( + contentDisposition('€ rates.pdf', { + fallback: 'C:\\path\\to\\EURO rates.pdf', + }), + 'attachment; filename="EURO rates.pdf"; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf', + ); + }); + + it('should do nothing without filename option', function () { + assert.strictEqual( + contentDisposition(undefined, { fallback: 'plans.pdf' }), + 'attachment', + ); + }); + }); + }); + + describe('with "type" option', function () { + it('should default to attachment', function () { + assert.strictEqual(contentDisposition(), 'attachment'); + }); + + it('should require a string', function () { + assert.throws( + contentDisposition.bind(null, undefined, { type: 42 } as any), + /invalid type/, + ); + }); + + it('should require a valid type', function () { + assert.throws( + contentDisposition.bind(null, undefined, { type: 'invalid;type' }), + /invalid type/, + ); + }); + + it('should create a header with inline type', function () { + assert.strictEqual( + contentDisposition(undefined, { type: 'inline' }), + 'inline', + ); + }); + + it('should create a header with inline type & filename', function () { + assert.strictEqual( + contentDisposition('plans.pdf', { type: 'inline' }), + 'inline; filename="plans.pdf"', + ); + }); + + it('should normalize type', function () { + assert.strictEqual( + contentDisposition(undefined, { type: 'INLINE' }), + 'inline', + ); + }); + }); +}); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..d533661 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,489 @@ +/*! + * content-disposition + * Copyright(c) 2014-2017 Douglas Christopher Wilson + * MIT Licensed + */ + +export = contentDisposition; + +/** + * Create an attachment Content-Disposition header. + */ +function contentDisposition( + filename?: string, + options?: contentDisposition.Options, +): string { + // get type + const type = options?.type || 'attachment'; + + // get parameters + const params = createparams(filename, options?.fallback); + + // format into string + return format(new ContentDispositionImpl(type, params)); +} + +namespace contentDisposition { + export interface ContentDisposition { + /** + * Content-Disposition type, such as "attachment" or "inline" + */ + type: string; + /** + * Content-Disposition parameters, such as "filename" + */ + parameters: Record; + } + + export interface Options { + /** + * Content-Disposition type, defaults to "attachment" + * @default "attachment" + */ + type?: string; + /** + * Fallback filename for non-ISO-8859-1 strings. If true, a fallback will be generated by replacing non-latin1 characters with "?". If false, no fallback will be generated. + * @default true + */ + fallback?: string | boolean; + } + + /** + * Parse Content-Disposition header string. + */ + export function parse(string: string): ContentDisposition { + if (!string || typeof string !== 'string') { + throw new TypeError('argument string is required'); + } + + let match = DISPOSITION_TYPE_REGEXP.exec(string); + + if (!match) { + throw new TypeError('invalid type format'); + } + + // normalize type + let index = match[0].length; + const type = match[1].toLowerCase(); + + const obj = new ContentDispositionImpl(type); + + let key; + const names = []; + let value; + + // calculate index to start at + index = PARAM_REGEXP.lastIndex = + match[0].slice(-1) === ';' ? index - 1 : index; + + // match parameters + while ((match = PARAM_REGEXP.exec(string))) { + if (match.index !== index) { + throw new TypeError('invalid parameter format'); + } + + index += match[0].length; + key = match[1].toLowerCase(); + value = match[2]; + + if (names.indexOf(key) !== -1) { + throw new TypeError('invalid duplicate parameter'); + } + + names.push(key); + + if (key.indexOf('*') + 1 === key.length) { + // decode extended value + key = key.slice(0, -1); + value = decodefield(value); + + // overwrite existing value + obj.parameters[key] = value; + continue; + } + + if (typeof obj.parameters[key] === 'string') { + continue; + } + + if (value[0] === '"') { + // remove quotes and escapes + value = value.slice(1, -1).replace(QESC_REGEXP, '$1'); + } + + obj.parameters[key] = value; + } + + if (index !== -1 && index !== string.length) { + throw new TypeError('invalid parameter format'); + } + + return obj; + } +} + +interface FormatOptions { + type: string; + parameters?: Record; +} + +/** + * TextDecoder instance for UTF-8 decoding when decodeURIComponent fails due to invalid byte sequences. + */ +const utf8Decoder = new TextDecoder('utf-8'); + +/** + * RegExp to match non attr-char, *after* encodeURIComponent (i.e. not including "%") + */ +const ENCODE_URL_ATTR_CHAR_REGEXP = /[\x00-\x20"'()*,/:;<=>?@[\\\]{}\x7f]/g; // eslint-disable-line no-control-regex + +/** + * RegExp to match non-latin1 characters. + */ +const NON_LATIN1_REGEXP = /[^\x20-\x7e\xa0-\xff]/g; + +/** + * RegExp to match quoted-pair in RFC 2616 + * + * quoted-pair = "\" CHAR + * CHAR = + */ +const QESC_REGEXP = /\\([\u0000-\u007f])/g; // eslint-disable-line no-control-regex + +/** + * RegExp to match chars that must be quoted-pair in RFC 2616 + */ +const QUOTE_REGEXP = /([\\"])/g; + +/** + * RegExp for various RFC 2616 grammar + * + * parameter = token "=" ( token | quoted-string ) + * token = 1* + * separators = "(" | ")" | "<" | ">" | "@" + * | "," | ";" | ":" | "\" | <"> + * | "/" | "[" | "]" | "?" | "=" + * | "{" | "}" | SP | HT + * quoted-string = ( <"> *(qdtext | quoted-pair ) <"> ) + * qdtext = > + * quoted-pair = "\" CHAR + * CHAR = + * TEXT = + * LWS = [CRLF] 1*( SP | HT ) + * CRLF = CR LF + * CR = + * LF = + * SP = + * HT = + * CTL = + * OCTET = + */ +const PARAM_REGEXP = + /;[\x09\x20]*([!#$%&'*+.0-9A-Z^_`a-z|~-]+)[\x09\x20]*=[\x09\x20]*("(?:[\x20!\x23-\x5b\x5d-\x7e\x80-\xff]|\\[\x20-\x7e])*"|[!#$%&'*+.0-9A-Z^_`a-z|~-]+)[\x09\x20]*/g; // eslint-disable-line no-control-regex +const TEXT_REGEXP = /^[\x20-\x7e\x80-\xff]+$/; +const TOKEN_REGEXP = /^[!#$%&'*+.0-9A-Z^_`a-z|~-]+$/; + +/** + * RegExp for various RFC 5987 grammar + * + * ext-value = charset "'" [ language ] "'" value-chars + * charset = "UTF-8" / "ISO-8859-1" / mime-charset + * mime-charset = 1*mime-charsetc + * mime-charsetc = ALPHA / DIGIT + * / "!" / "#" / "$" / "%" / "&" + * / "+" / "-" / "^" / "_" / "`" + * / "{" / "}" / "~" + * language = ( 2*3ALPHA [ extlang ] ) + * / 4ALPHA + * / 5*8ALPHA + * extlang = *3( "-" 3ALPHA ) + * value-chars = *( pct-encoded / attr-char ) + * pct-encoded = "%" HEXDIG HEXDIG + * attr-char = ALPHA / DIGIT + * / "!" / "#" / "$" / "&" / "+" / "-" / "." + * / "^" / "_" / "`" / "|" / "~" + */ +const EXT_VALUE_REGEXP = + /^([A-Za-z0-9!#$%&+\-^_`{}~]+)'(?:[A-Za-z]{2,3}(?:-[A-Za-z]{3}){0,3}|[A-Za-z]{4,8}|)'((?:%[0-9A-Fa-f]{2}|[A-Za-z0-9!#$&+.^_`|~-])+)$/; + +/** + * RegExp for various RFC 6266 grammar + * + * disposition-type = "inline" | "attachment" | disp-ext-type + * disp-ext-type = token + * disposition-parm = filename-parm | disp-ext-parm + * filename-parm = "filename" "=" value + * | "filename*" "=" ext-value + * disp-ext-parm = token "=" value + * | ext-token "=" ext-value + * ext-token = + */ +const DISPOSITION_TYPE_REGEXP = + /^([!#$%&'*+.0-9A-Z^_`a-z|~-]+)[\x09\x20]*(?:$|;)/; // eslint-disable-line no-control-regex + +/** + * Create parameters object from filename and fallback. + */ +function createparams( + filename?: string, + fallback: string | boolean = true, +): Record | undefined { + if (filename === undefined) { + return; + } + + if (typeof filename !== 'string') { + throw new TypeError('filename must be a string'); + } + + if (typeof fallback !== 'string' && typeof fallback !== 'boolean') { + throw new TypeError('fallback must be a string or boolean'); + } + + if (typeof fallback === 'string' && NON_LATIN1_REGEXP.test(fallback)) { + throw new TypeError('fallback must be ISO-8859-1 string'); + } + + const params: Record = {}; + + // restrict to file base name + const name = basename(filename); + + // determine if name is suitable for quoted string + const isQuotedString = TEXT_REGEXP.test(name); + + // generate fallback name + const fallbackName = + typeof fallback !== 'string' + ? fallback && getlatin1(name) + : basename(fallback); + const hasFallback = typeof fallbackName === 'string' && fallbackName !== name; + + // set extended filename parameter + if (hasFallback || !isQuotedString || hasHexEscape(name)) { + params['filename*'] = name; + } + + // set filename parameter + if (isQuotedString || hasFallback) { + params.filename = hasFallback ? fallbackName : name; + } + + return params; +} + +/** + * Decode a RFC 5987 field value (gracefully). + */ +function decodefield(str: string): string { + const match = EXT_VALUE_REGEXP.exec(str); + + if (!match) { + throw new TypeError('invalid extended field value'); + } + + const charset = match[1].toLowerCase(); + const encoded = match[2]; + + switch (charset) { + case 'iso-8859-1': { + const binary = decodeHexEscapes(encoded); + return getlatin1(binary); + } + case 'utf-8': + case 'utf8': { + try { + return decodeURIComponent(encoded); + } catch { + // Failed to decode with decodeURIComponent, fallback to lenient decoding which replaces invalid UTF-8 byte sequences with the Unicode replacement character + // TODO: Consider removing in the next major version to be more strict about invalid percent-encodings + const binary = decodeHexEscapes(encoded); + + const bytes = new Uint8Array(binary.length); + for (let idx = 0; idx < binary.length; idx++) { + bytes[idx] = binary.charCodeAt(idx); + } + + return utf8Decoder.decode(bytes); + } + } + } + + throw new TypeError('unsupported charset in extended field'); +} + +/** + * Format object to Content-Disposition header. + */ +function format(obj: FormatOptions): string { + if (!obj || typeof obj !== 'object') { + throw new TypeError('argument obj is required'); + } + + if ( + !obj.type || + typeof obj.type !== 'string' || + !TOKEN_REGEXP.test(obj.type) + ) { + throw new TypeError('invalid type'); + } + + // start with normalized type + let string = obj.type.toLowerCase(); + + // append parameters + if (obj.parameters && typeof obj.parameters === 'object') { + const params = Object.keys(obj.parameters).sort(); + + for (let i = 0; i < params.length; i++) { + const param = params[i]; + + const val = + param.slice(-1) === '*' + ? ustring(obj.parameters[param]) + : qstring(obj.parameters[param]); + + string += `; ${param}=${val}`; + } + } + + return string; +} + +/** + * Get ISO-8859-1 version of string. + */ +function getlatin1(val: string): string { + // simple Unicode -> ISO-8859-1 transformation + return val.replace(NON_LATIN1_REGEXP, '?'); +} + +/** + * Percent encode a single character. + */ +function pencode(char: string): string { + return '%' + char.charCodeAt(0).toString(16).toUpperCase(); +} + +/** + * Quote a string for HTTP. + */ +function qstring(val: unknown): string { + const str = String(val); + + return '"' + str.replace(QUOTE_REGEXP, '\\$1') + '"'; +} + +/** + * Encode a Unicode string for HTTP (RFC 5987). + */ +function ustring(val: unknown): string { + const str = String(val); + + // percent encode as UTF-8 + const encoded = encodeURIComponent(str).replace( + ENCODE_URL_ATTR_CHAR_REGEXP, + pencode, + ); + + return "UTF-8''" + encoded; +} + +/** + * Class for parsed Content-Disposition header for v8 optimization + */ +class ContentDispositionImpl implements contentDisposition.ContentDisposition { + type: string; + parameters: Record; + + constructor(type: string, parameters?: Record) { + this.type = type; + this.parameters = Object.create(null); + + if (parameters) { + for (const [key, value] of Object.entries(parameters)) { + this.parameters[key] = value; + } + } + } +} + +/** + * Return the last portion of a path + */ +function basename(path: string): string { + const normalized = path.replaceAll('\\', '/'); + + let end = normalized.length; + while (end > 0 && normalized[end - 1] === '/') { + end--; + } + + if (end === 0) { + return ''; + } + + let start = end - 1; + while (start >= 0 && normalized[start] !== '/') { + start--; + } + + return normalized.slice(start + 1, end); +} + +/** + * Check if a character is a hex digit [0-9A-Fa-f] + */ +function isHexDigit(char: string): boolean { + const code = char.charCodeAt(0); + return ( + (code >= 48 && code <= 57) || // 0-9 + (code >= 65 && code <= 70) || // A-F + (code >= 97 && code <= 102) // a-f + ); +} + +/** + * Check if a string contains percent encoding escapes. + */ +function hasHexEscape(str: string): boolean { + const maxIndex = str.length - 3; + let lastIndex = -1; + + while ( + (lastIndex = str.indexOf('%', lastIndex + 1)) !== -1 && + lastIndex <= maxIndex + ) { + if (isHexDigit(str[lastIndex + 1]) && isHexDigit(str[lastIndex + 2])) { + return true; + } + } + + return false; +} + +/** + * Decode hex escapes in a string (e.g., %20 -> space) + */ +function decodeHexEscapes(str: string): string { + const firstEscape = str.indexOf('%'); + if (firstEscape === -1) return str; + + let result = str.slice(0, firstEscape); + for (let idx = firstEscape; idx < str.length; idx++) { + if ( + str[idx] === '%' && + idx + 2 < str.length && + isHexDigit(str[idx + 1]) && + isHexDigit(str[idx + 2]) + ) { + result += String.fromCharCode( + Number.parseInt(str[idx + 1] + str[idx + 2], 16), + ); + idx += 2; + } else { + result += str[idx]; + } + } + return result; +} diff --git a/src/parse.spec.ts b/src/parse.spec.ts new file mode 100644 index 0000000..ccc8197 --- /dev/null +++ b/src/parse.spec.ts @@ -0,0 +1,1005 @@ +import { describe, it, assert } from 'vitest'; +import { parse } from './index'; + +describe('parse(string)', function () { + it('should require string', function () { + assert.throws((parse as any).bind(null), /argument string.*required/); + }); + + it('should reject non-strings', function () { + assert.throws((parse as any).bind(null, 42), /argument string.*required/); + }); + + describe('with only type', function () { + it('should reject quoted value', function () { + assert.throws(parse.bind(null, '"attachment"'), /invalid type format/); + }); + + it('should reject trailing semicolon', function () { + assert.throws(parse.bind(null, 'attachment;'), /invalid.*format/); + }); + + it('should parse "attachment"', function () { + assert.deepEqual(parse('attachment'), { + type: 'attachment', + parameters: {}, + }); + }); + + it('should parse "inline"', function () { + assert.deepEqual(parse('inline'), { + type: 'inline', + parameters: {}, + }); + }); + + it('should parse "form-data"', function () { + assert.deepEqual(parse('form-data'), { + type: 'form-data', + parameters: {}, + }); + }); + + it('should parse with trailing LWS', function () { + assert.deepEqual(parse('attachment \t '), { + type: 'attachment', + parameters: {}, + }); + }); + + it('should normalize to lower-case', function () { + assert.deepEqual(parse('ATTACHMENT'), { + type: 'attachment', + parameters: {}, + }); + }); + }); + + describe('with parameters', function () { + it('should reject trailing semicolon', function () { + assert.throws( + parse.bind(null, 'attachment; filename="rates.pdf";'), + /invalid parameter format/, + ); + }); + + it('should reject invalid parameter name', function () { + assert.throws( + parse.bind(null, 'attachment; filename@="rates.pdf"'), + /invalid parameter format/, + ); + }); + + it('should reject missing parameter value', function () { + assert.throws( + parse.bind(null, 'attachment; filename='), + /invalid parameter format/, + ); + }); + + it('should reject invalid parameter value', function () { + assert.throws( + parse.bind(null, 'attachment; filename=trolly,trains'), + /invalid parameter format/, + ); + }); + + it('should reject invalid parameters', function () { + assert.throws( + parse.bind(null, 'attachment; filename=total/; foo=bar'), + /invalid parameter format/, + ); + }); + + it('should reject duplicate parameters', function () { + assert.throws( + parse.bind(null, 'attachment; filename=foo; filename=bar'), + /invalid duplicate parameter/, + ); + }); + + it('should reject missing type', function () { + assert.throws( + parse.bind(null, 'filename="plans.pdf"'), + /invalid type format/, + ); + assert.throws( + parse.bind(null, '; filename="plans.pdf"'), + /invalid type format/, + ); + }); + + it('should lower-case parameter name', function () { + assert.deepEqual(parse('attachment; FILENAME="plans.pdf"'), { + type: 'attachment', + parameters: { filename: 'plans.pdf' }, + }); + }); + + it('should parse quoted parameter value', function () { + assert.deepEqual(parse('attachment; filename="plans.pdf"'), { + type: 'attachment', + parameters: { filename: 'plans.pdf' }, + }); + }); + + it('should parse & unescape quoted value', function () { + assert.deepEqual(parse('attachment; filename="the \\"plans\\".pdf"'), { + type: 'attachment', + parameters: { filename: 'the "plans".pdf' }, + }); + }); + + it('should include all parameters', function () { + assert.deepEqual(parse('attachment; filename="plans.pdf"; foo=bar'), { + type: 'attachment', + parameters: { filename: 'plans.pdf', foo: 'bar' }, + }); + }); + + it('should parse parameters separated with any LWS', function () { + assert.deepEqual( + parse('attachment;filename="plans.pdf" \t; \t\t foo=bar'), + { + type: 'attachment', + parameters: { filename: 'plans.pdf', foo: 'bar' }, + }, + ); + }); + + it('should parse token filename', function () { + assert.deepEqual(parse('attachment; filename=plans.pdf'), { + type: 'attachment', + parameters: { filename: 'plans.pdf' }, + }); + }); + + it('should parse ISO-8859-1 filename', function () { + assert.deepEqual(parse('attachment; filename="£ rates.pdf"'), { + type: 'attachment', + parameters: { filename: '£ rates.pdf' }, + }); + }); + }); + + describe('with extended parameters', function () { + it('should reject quoted extended parameter value', function () { + assert.throws( + parse.bind( + null, + 'attachment; filename*="UTF-8\'\'%E2%82%AC%20rates.pdf"', + ), + /invalid extended.*value/, + ); + }); + + it('should parse UTF-8 extended parameter value', function () { + assert.deepEqual( + parse("attachment; filename*=UTF-8''%E2%82%AC%20rates.pdf"), + { + type: 'attachment', + parameters: { filename: '€ rates.pdf' }, + }, + ); + }); + + it('should parse UTF8 extended parameter value', function () { + assert.deepEqual( + parse("attachment; filename*=utf8''%E2%82%AC%20rates.pdf"), + { + type: 'attachment', + parameters: { filename: '€ rates.pdf' }, + }, + ); + }); + + it('should parse UTF-8 extended parameter value', function () { + assert.deepEqual( + parse("attachment; filename*=UTF-8''%E2%82%AC%20rates.pdf"), + { + type: 'attachment', + parameters: { filename: '€ rates.pdf' }, + }, + ); + assert.deepEqual(parse("attachment; filename*=UTF-8''%E4%20rates.pdf"), { + type: 'attachment', + parameters: { filename: '\ufffd rates.pdf' }, + }); + }); + + it('should parse ISO-8859-1 extended parameter value', function () { + assert.deepEqual( + parse("attachment; filename*=ISO-8859-1''%A3%20rates.pdf"), + { + type: 'attachment', + parameters: { filename: '£ rates.pdf' }, + }, + ); + assert.deepEqual( + parse("attachment; filename*=ISO-8859-1''%82%20rates.pdf"), + { + type: 'attachment', + parameters: { filename: '? rates.pdf' }, + }, + ); + }); + + it('should not be case-sensitive for charset', function () { + assert.deepEqual( + parse("attachment; filename*=utf-8''%E2%82%AC%20rates.pdf"), + { + type: 'attachment', + parameters: { filename: '€ rates.pdf' }, + }, + ); + }); + + it('should reject unsupported charset', function () { + assert.throws( + parse.bind(null, "attachment; filename*=ISO-8859-2''%A4%20rates.pdf"), + /unsupported charset/, + ); + }); + + it('should parse with embedded language', function () { + assert.deepEqual( + parse("attachment; filename*=UTF-8'en'%E2%82%AC%20rates.pdf"), + { + type: 'attachment', + parameters: { filename: '€ rates.pdf' }, + }, + ); + }); + + it('should prefer extended parameter value', function () { + assert.deepEqual( + parse( + 'attachment; filename="EURO rates.pdf"; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf', + ), + { + type: 'attachment', + parameters: { filename: '€ rates.pdf' }, + }, + ); + assert.deepEqual( + parse( + 'attachment; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf; filename="EURO rates.pdf"', + ), + { + type: 'attachment', + parameters: { filename: '€ rates.pdf' }, + }, + ); + }); + }); + + describe('from TC 2231', function () { + describe('Disposition-Type Inline', function () { + it('should parse "inline"', function () { + assert.deepEqual(parse('inline'), { + type: 'inline', + parameters: {}, + }); + }); + + it('should reject ""inline""', function () { + assert.throws(parse.bind(null, '"inline"'), /invalid type format/); + }); + + it('should parse "inline; filename="foo.html""', function () { + assert.deepEqual(parse('inline; filename="foo.html"'), { + type: 'inline', + parameters: { filename: 'foo.html' }, + }); + }); + + it('should parse "inline; filename="Not an attachment!""', function () { + assert.deepEqual(parse('inline; filename="Not an attachment!"'), { + type: 'inline', + parameters: { filename: 'Not an attachment!' }, + }); + }); + + it('should parse "inline; filename="foo.pdf""', function () { + assert.deepEqual(parse('inline; filename="foo.pdf"'), { + type: 'inline', + parameters: { filename: 'foo.pdf' }, + }); + }); + }); + + describe('Disposition-Type Attachment', function () { + it('should parse "attachment"', function () { + assert.deepEqual(parse('attachment'), { + type: 'attachment', + parameters: {}, + }); + }); + + it('should reject ""attachment""', function () { + assert.throws(parse.bind(null, '"attachment"'), /invalid type format/); + }); + + it('should parse "ATTACHMENT"', function () { + assert.deepEqual(parse('ATTACHMENT'), { + type: 'attachment', + parameters: {}, + }); + }); + + it('should parse "attachment; filename="foo.html""', function () { + assert.deepEqual(parse('attachment; filename="foo.html"'), { + type: 'attachment', + parameters: { filename: 'foo.html' }, + }); + }); + + it('should parse "attachment; filename="0000000000111111111122222""', function () { + assert.deepEqual( + parse('attachment; filename="0000000000111111111122222"'), + { + type: 'attachment', + parameters: { filename: '0000000000111111111122222' }, + }, + ); + }); + + it('should parse "attachment; filename="00000000001111111111222222222233333""', function () { + assert.deepEqual( + parse('attachment; filename="00000000001111111111222222222233333"'), + { + type: 'attachment', + parameters: { filename: '00000000001111111111222222222233333' }, + }, + ); + }); + + it('should parse "attachment; filename="f\\oo.html""', function () { + assert.deepEqual(parse('attachment; filename="f\\oo.html"'), { + type: 'attachment', + parameters: { filename: 'foo.html' }, + }); + }); + + it('should parse "attachment; filename="\\"quoting\\" tested.html""', function () { + assert.deepEqual( + parse('attachment; filename="\\"quoting\\" tested.html"'), + { + type: 'attachment', + parameters: { filename: '"quoting" tested.html' }, + }, + ); + }); + + it('should parse "attachment; filename="Here\'s a semicolon;.html""', function () { + assert.deepEqual( + parse('attachment; filename="Here\'s a semicolon;.html"'), + { + type: 'attachment', + parameters: { filename: "Here's a semicolon;.html" }, + }, + ); + }); + + it('should parse "attachment; foo="bar"; filename="foo.html""', function () { + assert.deepEqual(parse('attachment; foo="bar"; filename="foo.html"'), { + type: 'attachment', + parameters: { filename: 'foo.html', foo: 'bar' }, + }); + }); + + it('should parse "attachment; foo="\\"\\\\";filename="foo.html""', function () { + assert.deepEqual( + parse('attachment; foo="\\"\\\\";filename="foo.html"'), + { + type: 'attachment', + parameters: { filename: 'foo.html', foo: '"\\' }, + }, + ); + }); + + it('should parse "attachment; FILENAME="foo.html""', function () { + assert.deepEqual(parse('attachment; FILENAME="foo.html"'), { + type: 'attachment', + parameters: { filename: 'foo.html' }, + }); + }); + + it('should parse "attachment; filename=foo.html"', function () { + assert.deepEqual(parse('attachment; filename=foo.html'), { + type: 'attachment', + parameters: { filename: 'foo.html' }, + }); + }); + + it('should reject "attachment; filename=foo,bar.html"', function () { + assert.throws( + parse.bind(null, 'attachment; filename=foo,bar.html'), + /invalid parameter format/, + ); + }); + + it('should reject "attachment; filename=foo.html ;"', function () { + assert.throws( + parse.bind(null, 'attachment; filename=foo.html ;'), + /invalid parameter format/, + ); + }); + + it('should reject "attachment; ;filename=foo"', function () { + assert.throws( + parse.bind(null, 'attachment; ;filename=foo'), + /invalid parameter format/, + ); + }); + + it('should reject "attachment; filename=foo bar.html"', function () { + assert.throws( + parse.bind(null, 'attachment; filename=foo bar.html'), + /invalid parameter format/, + ); + }); + + it("should parse \"attachment; filename='foo.bar'", function () { + assert.deepEqual(parse("attachment; filename='foo.bar'"), { + type: 'attachment', + parameters: { filename: "'foo.bar'" }, + }); + }); + + it('should parse "attachment; filename="foo-ä.html""', function () { + assert.deepEqual(parse('attachment; filename="foo-ä.html"'), { + type: 'attachment', + parameters: { filename: 'foo-ä.html' }, + }); + }); + + it('should parse "attachment; filename="foo-ä.html""', function () { + assert.deepEqual(parse('attachment; filename="foo-ä.html"'), { + type: 'attachment', + parameters: { filename: 'foo-ä.html' }, + }); + }); + + it('should parse "attachment; filename="foo-%41.html""', function () { + assert.deepEqual(parse('attachment; filename="foo-%41.html"'), { + type: 'attachment', + parameters: { filename: 'foo-%41.html' }, + }); + }); + + it('should parse "attachment; filename="50%.html""', function () { + assert.deepEqual(parse('attachment; filename="50%.html"'), { + type: 'attachment', + parameters: { filename: '50%.html' }, + }); + }); + + it('should parse "attachment; filename="foo-%\\41.html""', function () { + assert.deepEqual(parse('attachment; filename="foo-%\\41.html"'), { + type: 'attachment', + parameters: { filename: 'foo-%41.html' }, + }); + }); + + it('should parse "attachment; name="foo-%41.html""', function () { + assert.deepEqual(parse('attachment; name="foo-%41.html"'), { + type: 'attachment', + parameters: { name: 'foo-%41.html' }, + }); + }); + + it('should parse "attachment; filename="ä-%41.html""', function () { + assert.deepEqual(parse('attachment; filename="ä-%41.html"'), { + type: 'attachment', + parameters: { filename: 'ä-%41.html' }, + }); + }); + + it('should parse "attachment; filename="foo-%c3%a4-%e2%82%ac.html""', function () { + assert.deepEqual( + parse('attachment; filename="foo-%c3%a4-%e2%82%ac.html"'), + { + type: 'attachment', + parameters: { filename: 'foo-%c3%a4-%e2%82%ac.html' }, + }, + ); + }); + + it('should parse "attachment; filename ="foo.html""', function () { + assert.deepEqual(parse('attachment; filename ="foo.html"'), { + type: 'attachment', + parameters: { filename: 'foo.html' }, + }); + }); + + it('should reject "attachment; filename="foo.html"; filename="bar.html"', function () { + assert.throws( + parse.bind( + null, + 'attachment; filename="foo.html"; filename="bar.html"', + ), + /invalid duplicate parameter/, + ); + }); + + it('should reject "attachment; filename=foo[1](2).html"', function () { + assert.throws( + parse.bind(null, 'attachment; filename=foo[1](2).html'), + /invalid parameter format/, + ); + }); + + it('should reject "attachment; filename=foo-ä.html"', function () { + assert.throws( + parse.bind(null, 'attachment; filename=foo-ä.html'), + /invalid parameter format/, + ); + }); + + it('should reject "attachment; filename=foo-ä.html"', function () { + assert.throws( + parse.bind(null, 'attachment; filename=foo-ä.html'), + /invalid parameter format/, + ); + }); + + it('should reject "filename=foo.html"', function () { + assert.throws( + parse.bind(null, 'filename=foo.html'), + /invalid type format/, + ); + }); + + it('should reject "x=y; filename=foo.html"', function () { + assert.throws( + parse.bind(null, 'x=y; filename=foo.html'), + /invalid type format/, + ); + }); + + it('should reject ""foo; filename=bar;baz"; filename=qux"', function () { + assert.throws( + parse.bind(null, '"foo; filename=bar;baz"; filename=qux'), + /invalid type format/, + ); + }); + + it('should reject "filename=foo.html, filename=bar.html"', function () { + assert.throws( + parse.bind(null, 'filename=foo.html, filename=bar.html'), + /invalid type format/, + ); + }); + + it('should reject "; filename=foo.html"', function () { + assert.throws( + parse.bind(null, '; filename=foo.html'), + /invalid type format/, + ); + }); + + it('should reject ": inline; attachment; filename=foo.html', function () { + assert.throws( + parse.bind(null, ': inline; attachment; filename=foo.html'), + /invalid type format/, + ); + }); + + it('should reject "inline; attachment; filename=foo.html', function () { + assert.throws( + parse.bind(null, 'inline; attachment; filename=foo.html'), + /invalid parameter format/, + ); + }); + + it('should reject "attachment; inline; filename=foo.html', function () { + assert.throws( + parse.bind(null, 'attachment; inline; filename=foo.html'), + /invalid parameter format/, + ); + }); + + it('should reject "attachment; filename="foo.html".txt', function () { + assert.throws( + parse.bind(null, 'attachment; filename="foo.html".txt'), + /invalid parameter format/, + ); + }); + + it('should reject "attachment; filename="bar', function () { + assert.throws( + parse.bind(null, 'attachment; filename="bar'), + /invalid parameter format/, + ); + }); + + it('should reject "attachment; filename=foo"bar;baz"qux', function () { + assert.throws( + parse.bind(null, 'attachment; filename=foo"bar;baz"qux'), + /invalid parameter format/, + ); + }); + + it('should reject "attachment; filename=foo.html, attachment; filename=bar.html', function () { + assert.throws( + parse.bind( + null, + 'attachment; filename=foo.html, attachment; filename=bar.html', + ), + /invalid parameter format/, + ); + }); + + it('should reject "attachment; foo=foo filename=bar', function () { + assert.throws( + parse.bind(null, 'attachment; foo=foo filename=bar'), + /invalid parameter format/, + ); + }); + + it('should reject "attachment; filename=bar foo=foo', function () { + assert.throws( + parse.bind(null, 'attachment; filename=bar foo=foo'), + /invalid parameter format/, + ); + }); + + it('should reject "attachment filename=bar', function () { + assert.throws( + parse.bind(null, 'attachment filename=bar'), + /invalid type format/, + ); + }); + + it('should reject "filename=foo.html; attachment', function () { + assert.throws( + parse.bind(null, 'filename=foo.html; attachment'), + /invalid type format/, + ); + }); + + it('should parse "attachment; xfilename=foo.html"', function () { + assert.deepEqual(parse('attachment; xfilename=foo.html'), { + type: 'attachment', + parameters: { xfilename: 'foo.html' }, + }); + }); + + it('should parse "attachment; filename="/foo.html""', function () { + assert.deepEqual(parse('attachment; filename="/foo.html"'), { + type: 'attachment', + parameters: { filename: '/foo.html' }, + }); + }); + + it('should parse "attachment; filename="\\\\foo.html""', function () { + assert.deepEqual(parse('attachment; filename="\\\\foo.html"'), { + type: 'attachment', + parameters: { filename: '\\foo.html' }, + }); + }); + }); + + describe('Additional Parameters', function () { + it('should parse "attachment; creation-date="Wed, 12 Feb 1997 16:29:51 -0500""', function () { + assert.deepEqual( + parse('attachment; creation-date="Wed, 12 Feb 1997 16:29:51 -0500"'), + { + type: 'attachment', + parameters: { 'creation-date': 'Wed, 12 Feb 1997 16:29:51 -0500' }, + }, + ); + }); + + it('should parse "attachment; modification-date="Wed, 12 Feb 1997 16:29:51 -0500""', function () { + assert.deepEqual( + parse( + 'attachment; modification-date="Wed, 12 Feb 1997 16:29:51 -0500"', + ), + { + type: 'attachment', + parameters: { + 'modification-date': 'Wed, 12 Feb 1997 16:29:51 -0500', + }, + }, + ); + }); + }); + + describe('Disposition-Type Extension', function () { + it('should parse "foobar"', function () { + assert.deepEqual(parse('foobar'), { + type: 'foobar', + parameters: {}, + }); + }); + + it('should parse "attachment; example="filename=example.txt""', function () { + assert.deepEqual(parse('attachment; example="filename=example.txt"'), { + type: 'attachment', + parameters: { example: 'filename=example.txt' }, + }); + }); + }); + + describe('RFC 2231/5987 Encoding: Character Sets', function () { + it('should parse "attachment; filename*=iso-8859-1\'\'foo-%E4.html"', function () { + assert.deepEqual( + parse("attachment; filename*=iso-8859-1''foo-%E4.html"), + { + type: 'attachment', + parameters: { filename: 'foo-ä.html' }, + }, + ); + }); + + it('should parse "attachment; filename*=UTF-8\'\'foo-%c3%a4-%e2%82%ac.html"', function () { + assert.deepEqual( + parse("attachment; filename*=UTF-8''foo-%c3%a4-%e2%82%ac.html"), + { + type: 'attachment', + parameters: { filename: 'foo-ä-€.html' }, + }, + ); + }); + + it('should reject "attachment; filename*=\'\'foo-%c3%a4-%e2%82%ac.html"', function () { + assert.throws( + parse.bind(null, "attachment; filename*=''foo-%c3%a4-%e2%82%ac.html"), + /invalid extended.*value/, + ); + }); + + it('should parse "attachment; filename*=UTF-8\'\'foo-a%cc%88.html"', function () { + assert.deepEqual( + parse("attachment; filename*=UTF-8''foo-a%cc%88.html"), + { + type: 'attachment', + parameters: { filename: 'foo-ä.html' }, + }, + ); + }); + + it('should parse "attachment; filename*=iso-8859-1\'\'foo-%c3%a4-%e2%82%ac.html"', function () { + assert.deepEqual( + parse("attachment; filename*=iso-8859-1''foo-%c3%a4-%e2%82%ac.html"), + { + type: 'attachment', + parameters: { filename: 'foo-ä-â?¬.html' }, + }, + ); + }); + + it('should parse "attachment; filename*=utf-8\'\'foo-%E4.html"', function () { + assert.deepEqual(parse("attachment; filename*=utf-8''foo-%E4.html"), { + type: 'attachment', + parameters: { filename: 'foo-\ufffd.html' }, + }); + }); + + it('should reject "attachment; filename *=UTF-8\'\'foo-%c3%a4.html"', function () { + assert.throws( + parse.bind(null, "attachment; filename *=UTF-8''foo-%c3%a4.html"), + /invalid parameter format/, + ); + }); + + it('should parse "attachment; filename*= UTF-8\'\'foo-%c3%a4.html"', function () { + assert.deepEqual( + parse("attachment; filename*= UTF-8''foo-%c3%a4.html"), + { + type: 'attachment', + parameters: { filename: 'foo-ä.html' }, + }, + ); + }); + + it('should parse "attachment; filename* =UTF-8\'\'foo-%c3%a4.html"', function () { + assert.deepEqual( + parse("attachment; filename* =UTF-8''foo-%c3%a4.html"), + { + type: 'attachment', + parameters: { filename: 'foo-ä.html' }, + }, + ); + }); + + it('should reject "attachment; filename*="UTF-8\'\'foo-%c3%a4.html""', function () { + assert.throws( + parse.bind(null, 'attachment; filename*="UTF-8\'\'foo-%c3%a4.html"'), + /invalid extended field value/, + ); + }); + + it('should reject "attachment; filename*="foo%20bar.html""', function () { + assert.throws( + parse.bind(null, 'attachment; filename*="foo%20bar.html"'), + /invalid extended field value/, + ); + }); + + it('should reject "attachment; filename*=UTF-8\'foo-%c3%a4.html"', function () { + assert.throws( + parse.bind(null, "attachment; filename*=UTF-8'foo-%c3%a4.html"), + /invalid extended field value/, + ); + }); + + it('should reject "attachment; filename*=UTF-8\'\'foo%"', function () { + assert.throws( + parse.bind(null, "attachment; filename*=UTF-8''foo%"), + /invalid extended field value/, + ); + }); + + it('should reject "attachment; filename*=UTF-8\'\'f%oo.html"', function () { + assert.throws( + parse.bind(null, "attachment; filename*=UTF-8''f%oo.html"), + /invalid extended field value/, + ); + }); + + it('should parse "attachment; filename*=UTF-8\'\'A-%2541.html"', function () { + assert.deepEqual(parse("attachment; filename*=UTF-8''A-%2541.html"), { + type: 'attachment', + parameters: { filename: 'A-%41.html' }, + }); + }); + + it('should parse "attachment; filename*=UTF-8\'\'%5cfoo.html"', function () { + assert.deepEqual(parse("attachment; filename*=UTF-8''%5cfoo.html"), { + type: 'attachment', + parameters: { filename: '\\foo.html' }, + }); + }); + }); + + describe('RFC2231 Encoding: Continuations', function () { + it('should parse "attachment; filename*0="foo."; filename*1="html""', function () { + assert.deepEqual( + parse('attachment; filename*0="foo."; filename*1="html"'), + { + type: 'attachment', + parameters: { 'filename*0': 'foo.', 'filename*1': 'html' }, + }, + ); + }); + + it('should parse "attachment; filename*0="foo"; filename*1="\\b\\a\\r.html""', function () { + assert.deepEqual( + parse('attachment; filename*0="foo"; filename*1="\\b\\a\\r.html"'), + { + type: 'attachment', + parameters: { 'filename*0': 'foo', 'filename*1': 'bar.html' }, + }, + ); + }); + + it('should parse "attachment; filename*0*=UTF-8\'\'foo-%c3%a4; filename*1=".html""', function () { + assert.deepEqual( + parse( + 'attachment; filename*0*=UTF-8\'\'foo-%c3%a4; filename*1=".html"', + ), + { + type: 'attachment', + parameters: { + 'filename*0*': "UTF-8''foo-%c3%a4", + 'filename*1': '.html', + }, + }, + ); + }); + + it('should parse "attachment; filename*0="foo"; filename*01="bar""', function () { + assert.deepEqual( + parse('attachment; filename*0="foo"; filename*01="bar"'), + { + type: 'attachment', + parameters: { 'filename*0': 'foo', 'filename*01': 'bar' }, + }, + ); + }); + + it('should parse "attachment; filename*0="foo"; filename*2="bar""', function () { + assert.deepEqual( + parse('attachment; filename*0="foo"; filename*2="bar"'), + { + type: 'attachment', + parameters: { 'filename*0': 'foo', 'filename*2': 'bar' }, + }, + ); + }); + + it('should parse "attachment; filename*1="foo."; filename*2="html""', function () { + assert.deepEqual( + parse('attachment; filename*1="foo."; filename*2="html"'), + { + type: 'attachment', + parameters: { 'filename*1': 'foo.', 'filename*2': 'html' }, + }, + ); + }); + + it('should parse "attachment; filename*1="bar"; filename*0="foo""', function () { + assert.deepEqual( + parse('attachment; filename*1="bar"; filename*0="foo"'), + { + type: 'attachment', + parameters: { 'filename*1': 'bar', 'filename*0': 'foo' }, + }, + ); + }); + }); + + describe('RFC2231 Encoding: Fallback Behaviour', function () { + it('should parse "attachment; filename="foo-ae.html"; filename*=UTF-8\'\'foo-%c3%a4.html"', function () { + assert.deepEqual( + parse( + 'attachment; filename="foo-ae.html"; filename*=UTF-8\'\'foo-%c3%a4.html', + ), + { + type: 'attachment', + parameters: { filename: 'foo-ä.html' }, + }, + ); + }); + + it('should parse "attachment; filename*=UTF-8\'\'foo-%c3%a4.html; filename="foo-ae.html"', function () { + assert.deepEqual( + parse( + 'attachment; filename*=UTF-8\'\'foo-%c3%a4.html; filename="foo-ae.html"', + ), + { + type: 'attachment', + parameters: { filename: 'foo-ä.html' }, + }, + ); + }); + + it("should parse \"attachment; filename*0*=ISO-8859-15''euro-sign%3d%a4; filename*=ISO-8859-1''currency-sign%3d%a4", function () { + assert.deepEqual( + parse( + "attachment; filename*0*=ISO-8859-15''euro-sign%3d%a4; filename*=ISO-8859-1''currency-sign%3d%a4", + ), + { + type: 'attachment', + parameters: { + filename: 'currency-sign=¤', + 'filename*0*': "ISO-8859-15''euro-sign%3d%a4", + }, + }, + ); + }); + + it('should parse "attachment; foobar=x; filename="foo.html"', function () { + assert.deepEqual(parse('attachment; foobar=x; filename="foo.html"'), { + type: 'attachment', + parameters: { filename: 'foo.html', foobar: 'x' }, + }); + }); + }); + + describe('RFC2047 Encoding', function () { + it('should reject "attachment; filename==?ISO-8859-1?Q?foo-=E4.html?="', function () { + assert.throws( + parse.bind( + null, + 'attachment; filename==?ISO-8859-1?Q?foo-=E4.html?=', + ), + /invalid parameter format/, + ); + }); + + it('should parse "attachment; filename="=?ISO-8859-1?Q?foo-=E4.html?=""', function () { + assert.deepEqual( + parse('attachment; filename="=?ISO-8859-1?Q?foo-=E4.html?="'), + { + type: 'attachment', + parameters: { filename: '=?ISO-8859-1?Q?foo-=E4.html?=' }, + }, + ); + }); + }); + }); +}); diff --git a/test/test.js b/test/test.js deleted file mode 100644 index ff5dd26..0000000 --- a/test/test.js +++ /dev/null @@ -1,1017 +0,0 @@ -var assert = require('assert') -var contentDisposition = require('..') -var { describe, it } = require('node:test') - -describe('contentDisposition()', function () { - it('should create an attachment header', function () { - assert.strictEqual(contentDisposition(), 'attachment') - }) -}) - -describe('contentDisposition(filename)', function () { - it('should require a string', function () { - assert.throws(contentDisposition.bind(null, 42), - /filename.*string/) - }) - - it('should create a header with file name', function () { - assert.strictEqual(contentDisposition('plans.pdf'), - 'attachment; filename="plans.pdf"') - }) - - it('should use the basename of a posix path', function () { - assert.strictEqual(contentDisposition('/path/to/plans.pdf'), - 'attachment; filename="plans.pdf"') - }) - - it('should use the basename of a windows path', function () { - assert.strictEqual(contentDisposition('\\path\\to\\plans.pdf'), - 'attachment; filename="plans.pdf"') - }) - - it('should use the basename of a windows path with drive letter', function () { - assert.strictEqual(contentDisposition('C:\\path\\to\\plans.pdf'), - 'attachment; filename="plans.pdf"') - }) - - it('should use the basename of a posix path with trailing slash', function () { - assert.strictEqual(contentDisposition('/path/to/plans.pdf/'), - 'attachment; filename="plans.pdf"') - }) - - it('should use the basename of a windows path with trailing slash', function () { - assert.strictEqual(contentDisposition('\\path\\to\\plans.pdf\\'), - 'attachment; filename="plans.pdf"') - }) - - it('should use the basename of a windows path with drive letter and trailing slash', function () { - assert.strictEqual(contentDisposition('C:\\path\\to\\plans.pdf\\'), - 'attachment; filename="plans.pdf"') - }) - - it('should use the basename of a posix path with trailing slashes', function () { - assert.strictEqual(contentDisposition('/path/to/plans.pdf///'), - 'attachment; filename="plans.pdf"') - }) - - it('should use the basename of a windows path with trailing slashes', function () { - assert.strictEqual(contentDisposition('\\path\\to\\plans.pdf\\\\\\'), - 'attachment; filename="plans.pdf"') - }) - - it('should use the basename of a windows path with drive letter and trailing slashes', function () { - assert.strictEqual(contentDisposition('C:\\path\\to\\plans.pdf\\\\\\'), - 'attachment; filename="plans.pdf"') - }) - - describe('when "filename" is US-ASCII', function () { - it('should only include filename parameter', function () { - assert.strictEqual(contentDisposition('plans.pdf'), - 'attachment; filename="plans.pdf"') - }) - - it('should escape quotes', function () { - assert.strictEqual(contentDisposition('the "plans".pdf'), - 'attachment; filename="the \\"plans\\".pdf"') - }) - }) - - describe('when "filename" is ISO-8859-1', function () { - it('should only include filename parameter', function () { - assert.strictEqual(contentDisposition('«plans».pdf'), - 'attachment; filename="«plans».pdf"') - }) - - it('should escape quotes', function () { - assert.strictEqual(contentDisposition('the "plans" (1µ).pdf'), - 'attachment; filename="the \\"plans\\" (1µ).pdf"') - }) - }) - - describe('when "filename" is Unicode', function () { - it('should include filename* parameter', function () { - assert.strictEqual(contentDisposition('планы.pdf'), - 'attachment; filename="?????.pdf"; filename*=UTF-8\'\'%D0%BF%D0%BB%D0%B0%D0%BD%D1%8B.pdf') - }) - - it('should include filename fallback', function () { - assert.strictEqual(contentDisposition('£ and € rates.pdf'), - 'attachment; filename="£ and ? rates.pdf"; filename*=UTF-8\'\'%C2%A3%20and%20%E2%82%AC%20rates.pdf') - assert.strictEqual(contentDisposition('€ rates.pdf'), - 'attachment; filename="? rates.pdf"; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf') - }) - - it('should encode special characters', function () { - assert.strictEqual(contentDisposition('€\'*%().pdf'), - 'attachment; filename="?\'*%().pdf"; filename*=UTF-8\'\'%E2%82%AC%27%2A%25%28%29.pdf') - }) - }) - - describe('when "filename" contains hex escape', function () { - it('should include filename* parameter', function () { - assert.strictEqual(contentDisposition('the%20plans.pdf'), - 'attachment; filename="the%20plans.pdf"; filename*=UTF-8\'\'the%2520plans.pdf') - }) - - it('should handle Unicode', function () { - assert.strictEqual(contentDisposition('€%20£.pdf'), - 'attachment; filename="?%20£.pdf"; filename*=UTF-8\'\'%E2%82%AC%2520%C2%A3.pdf') - }) - }) -}) - -describe('contentDisposition(filename, options)', function () { - describe('with "fallback" option', function () { - it('should require a string or Boolean', function () { - assert.throws(contentDisposition.bind(null, 'plans.pdf', { fallback: 42 }), - /fallback.*string/) - }) - - it('should default to true', function () { - assert.strictEqual(contentDisposition('€ rates.pdf'), - 'attachment; filename="? rates.pdf"; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf') - }) - - describe('when "false"', function () { - it('should not generate ISO-8859-1 fallback', function () { - assert.strictEqual(contentDisposition('£ and € rates.pdf', { fallback: false }), - 'attachment; filename*=UTF-8\'\'%C2%A3%20and%20%E2%82%AC%20rates.pdf') - }) - - it('should keep ISO-8859-1 filename', function () { - assert.strictEqual(contentDisposition('£ rates.pdf', { fallback: false }), - 'attachment; filename="£ rates.pdf"') - }) - }) - - describe('when "true"', function () { - it('should generate ISO-8859-1 fallback', function () { - assert.strictEqual(contentDisposition('£ and € rates.pdf', { fallback: true }), - 'attachment; filename="£ and ? rates.pdf"; filename*=UTF-8\'\'%C2%A3%20and%20%E2%82%AC%20rates.pdf') - }) - - it('should pass through ISO-8859-1 filename', function () { - assert.strictEqual(contentDisposition('£ rates.pdf', { fallback: true }), - 'attachment; filename="£ rates.pdf"') - }) - }) - - describe('when a string', function () { - it('should require an ISO-8859-1 string', function () { - assert.throws(contentDisposition.bind(null, '€ rates.pdf', { fallback: '€ rates.pdf' }), - /fallback.*iso-8859-1/i) - }) - - it('should use as ISO-8859-1 fallback', function () { - assert.strictEqual(contentDisposition('£ and € rates.pdf', { fallback: '£ and EURO rates.pdf' }), - 'attachment; filename="£ and EURO rates.pdf"; filename*=UTF-8\'\'%C2%A3%20and%20%E2%82%AC%20rates.pdf') - }) - - it('should use as fallback even when filename is ISO-8859-1', function () { - assert.strictEqual(contentDisposition('"£ rates".pdf', { fallback: '£ rates.pdf' }), - 'attachment; filename="£ rates.pdf"; filename*=UTF-8\'\'%22%C2%A3%20rates%22.pdf') - }) - - it('should do nothing if equal to filename', function () { - assert.strictEqual(contentDisposition('plans.pdf', { fallback: 'plans.pdf' }), - 'attachment; filename="plans.pdf"') - }) - - it('should use the basename of a posix path', function () { - assert.strictEqual(contentDisposition('€ rates.pdf', { fallback: '/path/to/EURO rates.pdf' }), - 'attachment; filename="EURO rates.pdf"; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf') - }) - - it('should use the basename of a windows path', function () { - assert.strictEqual(contentDisposition('€ rates.pdf', { fallback: '\\path\\to\\EURO rates.pdf' }), - 'attachment; filename="EURO rates.pdf"; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf') - }) - - it('should use the basename of a windows path with drive letter', function () { - assert.strictEqual(contentDisposition('€ rates.pdf', { fallback: 'C:\\path\\to\\EURO rates.pdf' }), - 'attachment; filename="EURO rates.pdf"; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf') - }) - - it('should do nothing without filename option', function () { - assert.strictEqual(contentDisposition(undefined, { fallback: 'plans.pdf' }), - 'attachment') - }) - }) - }) - - describe('with "type" option', function () { - it('should default to attachment', function () { - assert.strictEqual(contentDisposition(), - 'attachment') - }) - - it('should require a string', function () { - assert.throws(contentDisposition.bind(null, undefined, { type: 42 }), - /invalid type/) - }) - - it('should require a valid type', function () { - assert.throws(contentDisposition.bind(null, undefined, { type: 'invalid;type' }), - /invalid type/) - }) - - it('should create a header with inline type', function () { - assert.strictEqual(contentDisposition(undefined, { type: 'inline' }), - 'inline') - }) - - it('should create a header with inline type & filename', function () { - assert.strictEqual(contentDisposition('plans.pdf', { type: 'inline' }), - 'inline; filename="plans.pdf"') - }) - - it('should normalize type', function () { - assert.strictEqual(contentDisposition(undefined, { type: 'INLINE' }), - 'inline') - }) - }) -}) - -describe('contentDisposition.parse(string)', function () { - it('should require string', function () { - assert.throws(contentDisposition.parse.bind(null), /argument string.*required/) - }) - - it('should reject non-strings', function () { - assert.throws(contentDisposition.parse.bind(null, 42), /argument string.*required/) - }) - - describe('with only type', function () { - it('should reject quoted value', function () { - assert.throws(contentDisposition.parse.bind(null, '"attachment"'), - /invalid type format/) - }) - - it('should reject trailing semicolon', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment;'), - /invalid.*format/) - }) - - it('should parse "attachment"', function () { - assert.deepEqual(contentDisposition.parse('attachment'), { - type: 'attachment', - parameters: {} - }) - }) - - it('should parse "inline"', function () { - assert.deepEqual(contentDisposition.parse('inline'), { - type: 'inline', - parameters: {} - }) - }) - - it('should parse "form-data"', function () { - assert.deepEqual(contentDisposition.parse('form-data'), { - type: 'form-data', - parameters: {} - }) - }) - - it('should parse with trailing LWS', function () { - assert.deepEqual(contentDisposition.parse('attachment \t '), { - type: 'attachment', - parameters: {} - }) - }) - - it('should normalize to lower-case', function () { - assert.deepEqual(contentDisposition.parse('ATTACHMENT'), { - type: 'attachment', - parameters: {} - }) - }) - }) - - describe('with parameters', function () { - it('should reject trailing semicolon', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename="rates.pdf";'), - /invalid parameter format/) - }) - - it('should reject invalid parameter name', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename@="rates.pdf"'), - /invalid parameter format/) - }) - - it('should reject missing parameter value', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename='), - /invalid parameter format/) - }) - - it('should reject invalid parameter value', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename=trolly,trains'), - /invalid parameter format/) - }) - - it('should reject invalid parameters', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename=total/; foo=bar'), - /invalid parameter format/) - }) - - it('should reject duplicate parameters', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename=foo; filename=bar'), - /invalid duplicate parameter/) - }) - - it('should reject missing type', function () { - assert.throws(contentDisposition.parse.bind(null, 'filename="plans.pdf"'), - /invalid type format/) - assert.throws(contentDisposition.parse.bind(null, '; filename="plans.pdf"'), - /invalid type format/) - }) - - it('should lower-case parameter name', function () { - assert.deepEqual(contentDisposition.parse('attachment; FILENAME="plans.pdf"'), { - type: 'attachment', - parameters: { filename: 'plans.pdf' } - }) - }) - - it('should parse quoted parameter value', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="plans.pdf"'), { - type: 'attachment', - parameters: { filename: 'plans.pdf' } - }) - }) - - it('should parse & unescape quoted value', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="the \\"plans\\".pdf"'), { - type: 'attachment', - parameters: { filename: 'the "plans".pdf' } - }) - }) - - it('should include all parameters', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="plans.pdf"; foo=bar'), { - type: 'attachment', - parameters: { filename: 'plans.pdf', foo: 'bar' } - }) - }) - - it('should parse parameters separated with any LWS', function () { - assert.deepEqual(contentDisposition.parse('attachment;filename="plans.pdf" \t; \t\t foo=bar'), { - type: 'attachment', - parameters: { filename: 'plans.pdf', foo: 'bar' } - }) - }) - - it('should parse token filename', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename=plans.pdf'), { - type: 'attachment', - parameters: { filename: 'plans.pdf' } - }) - }) - - it('should parse ISO-8859-1 filename', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="£ rates.pdf"'), { - type: 'attachment', - parameters: { filename: '£ rates.pdf' } - }) - }) - }) - - describe('with extended parameters', function () { - it('should reject quoted extended parameter value', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename*="UTF-8\'\'%E2%82%AC%20rates.pdf"'), - /invalid extended.*value/) - }) - - it('should parse UTF-8 extended parameter value', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf'), { - type: 'attachment', - parameters: { filename: '€ rates.pdf' } - }) - }) - - it('should parse UTF8 extended parameter value', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*=utf8\'\'%E2%82%AC%20rates.pdf'), { - type: 'attachment', - parameters: { filename: '€ rates.pdf' } - }) - }) - - it('should parse UTF-8 extended parameter value', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf'), { - type: 'attachment', - parameters: { filename: '€ rates.pdf' } - }) - assert.deepEqual(contentDisposition.parse('attachment; filename*=UTF-8\'\'%E4%20rates.pdf'), { - type: 'attachment', - parameters: { filename: '\ufffd rates.pdf' } - }) - }) - - it('should parse ISO-8859-1 extended parameter value', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*=ISO-8859-1\'\'%A3%20rates.pdf'), { - type: 'attachment', - parameters: { filename: '£ rates.pdf' } - }) - assert.deepEqual(contentDisposition.parse('attachment; filename*=ISO-8859-1\'\'%82%20rates.pdf'), { - type: 'attachment', - parameters: { filename: '? rates.pdf' } - }) - }) - - it('should not be case-sensitive for charset', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*=utf-8\'\'%E2%82%AC%20rates.pdf'), { - type: 'attachment', - parameters: { filename: '€ rates.pdf' } - }) - }) - - it('should reject unsupported charset', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename*=ISO-8859-2\'\'%A4%20rates.pdf'), - /unsupported charset/) - }) - - it('should parse with embedded language', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*=UTF-8\'en\'%E2%82%AC%20rates.pdf'), { - type: 'attachment', - parameters: { filename: '€ rates.pdf' } - }) - }) - - it('should prefer extended parameter value', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="EURO rates.pdf"; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf'), { - type: 'attachment', - parameters: { filename: '€ rates.pdf' } - }) - assert.deepEqual(contentDisposition.parse('attachment; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf; filename="EURO rates.pdf"'), { - type: 'attachment', - parameters: { filename: '€ rates.pdf' } - }) - }) - }) - - describe('from TC 2231', function () { - describe('Disposition-Type Inline', function () { - it('should parse "inline"', function () { - assert.deepEqual(contentDisposition.parse('inline'), { - type: 'inline', - parameters: {} - }) - }) - - it('should reject ""inline""', function () { - assert.throws(contentDisposition.parse.bind(null, '"inline"'), - /invalid type format/) - }) - - it('should parse "inline; filename="foo.html""', function () { - assert.deepEqual(contentDisposition.parse('inline; filename="foo.html"'), { - type: 'inline', - parameters: { filename: 'foo.html' } - }) - }) - - it('should parse "inline; filename="Not an attachment!""', function () { - assert.deepEqual(contentDisposition.parse('inline; filename="Not an attachment!"'), { - type: 'inline', - parameters: { filename: 'Not an attachment!' } - }) - }) - - it('should parse "inline; filename="foo.pdf""', function () { - assert.deepEqual(contentDisposition.parse('inline; filename="foo.pdf"'), { - type: 'inline', - parameters: { filename: 'foo.pdf' } - }) - }) - }) - - describe('Disposition-Type Attachment', function () { - it('should parse "attachment"', function () { - assert.deepEqual(contentDisposition.parse('attachment'), { - type: 'attachment', - parameters: {} - }) - }) - - it('should reject ""attachment""', function () { - assert.throws(contentDisposition.parse.bind(null, '"attachment"'), - /invalid type format/) - }) - - it('should parse "ATTACHMENT"', function () { - assert.deepEqual(contentDisposition.parse('ATTACHMENT'), { - type: 'attachment', - parameters: {} - }) - }) - - it('should parse "attachment; filename="foo.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="foo.html"'), { - type: 'attachment', - parameters: { filename: 'foo.html' } - }) - }) - - it('should parse "attachment; filename="0000000000111111111122222""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="0000000000111111111122222"'), { - type: 'attachment', - parameters: { filename: '0000000000111111111122222' } - }) - }) - - it('should parse "attachment; filename="00000000001111111111222222222233333""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="00000000001111111111222222222233333"'), { - type: 'attachment', - parameters: { filename: '00000000001111111111222222222233333' } - }) - }) - - it('should parse "attachment; filename="f\\oo.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="f\\oo.html"'), { - type: 'attachment', - parameters: { filename: 'foo.html' } - }) - }) - - it('should parse "attachment; filename="\\"quoting\\" tested.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="\\"quoting\\" tested.html"'), { - type: 'attachment', - parameters: { filename: '"quoting" tested.html' } - }) - }) - - it('should parse "attachment; filename="Here\'s a semicolon;.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="Here\'s a semicolon;.html"'), { - type: 'attachment', - parameters: { filename: 'Here\'s a semicolon;.html' } - }) - }) - - it('should parse "attachment; foo="bar"; filename="foo.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; foo="bar"; filename="foo.html"'), { - type: 'attachment', - parameters: { filename: 'foo.html', foo: 'bar' } - }) - }) - - it('should parse "attachment; foo="\\"\\\\";filename="foo.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; foo="\\"\\\\";filename="foo.html"'), { - type: 'attachment', - parameters: { filename: 'foo.html', foo: '"\\' } - }) - }) - - it('should parse "attachment; FILENAME="foo.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; FILENAME="foo.html"'), { - type: 'attachment', - parameters: { filename: 'foo.html' } - }) - }) - - it('should parse "attachment; filename=foo.html"', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename=foo.html'), { - type: 'attachment', - parameters: { filename: 'foo.html' } - }) - }) - - it('should reject "attachment; filename=foo,bar.html"', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename=foo,bar.html'), - /invalid parameter format/) - }) - - it('should reject "attachment; filename=foo.html ;"', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename=foo.html ;'), - /invalid parameter format/) - }) - - it('should reject "attachment; ;filename=foo"', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; ;filename=foo'), - /invalid parameter format/) - }) - - it('should reject "attachment; filename=foo bar.html"', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename=foo bar.html'), - /invalid parameter format/) - }) - - it('should parse "attachment; filename=\'foo.bar\'', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename=\'foo.bar\''), { - type: 'attachment', - parameters: { filename: '\'foo.bar\'' } - }) - }) - - it('should parse "attachment; filename="foo-ä.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="foo-ä.html"'), { - type: 'attachment', - parameters: { filename: 'foo-ä.html' } - }) - }) - - it('should parse "attachment; filename="foo-ä.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="foo-ä.html"'), { - type: 'attachment', - parameters: { filename: 'foo-ä.html' } - }) - }) - - it('should parse "attachment; filename="foo-%41.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="foo-%41.html"'), { - type: 'attachment', - parameters: { filename: 'foo-%41.html' } - }) - }) - - it('should parse "attachment; filename="50%.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="50%.html"'), { - type: 'attachment', - parameters: { filename: '50%.html' } - }) - }) - - it('should parse "attachment; filename="foo-%\\41.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="foo-%\\41.html"'), { - type: 'attachment', - parameters: { filename: 'foo-%41.html' } - }) - }) - - it('should parse "attachment; name="foo-%41.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; name="foo-%41.html"'), { - type: 'attachment', - parameters: { name: 'foo-%41.html' } - }) - }) - - it('should parse "attachment; filename="ä-%41.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="ä-%41.html"'), { - type: 'attachment', - parameters: { filename: 'ä-%41.html' } - }) - }) - - it('should parse "attachment; filename="foo-%c3%a4-%e2%82%ac.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="foo-%c3%a4-%e2%82%ac.html"'), { - type: 'attachment', - parameters: { filename: 'foo-%c3%a4-%e2%82%ac.html' } - }) - }) - - it('should parse "attachment; filename ="foo.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename ="foo.html"'), { - type: 'attachment', - parameters: { filename: 'foo.html' } - }) - }) - - it('should reject "attachment; filename="foo.html"; filename="bar.html"', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename="foo.html"; filename="bar.html"'), - /invalid duplicate parameter/) - }) - - it('should reject "attachment; filename=foo[1](2).html"', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename=foo[1](2).html'), - /invalid parameter format/) - }) - - it('should reject "attachment; filename=foo-ä.html"', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename=foo-ä.html'), - /invalid parameter format/) - }) - - it('should reject "attachment; filename=foo-ä.html"', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename=foo-ä.html'), - /invalid parameter format/) - }) - - it('should reject "filename=foo.html"', function () { - assert.throws(contentDisposition.parse.bind(null, 'filename=foo.html'), - /invalid type format/) - }) - - it('should reject "x=y; filename=foo.html"', function () { - assert.throws(contentDisposition.parse.bind(null, 'x=y; filename=foo.html'), - /invalid type format/) - }) - - it('should reject ""foo; filename=bar;baz"; filename=qux"', function () { - assert.throws(contentDisposition.parse.bind(null, '"foo; filename=bar;baz"; filename=qux'), - /invalid type format/) - }) - - it('should reject "filename=foo.html, filename=bar.html"', function () { - assert.throws(contentDisposition.parse.bind(null, 'filename=foo.html, filename=bar.html'), - /invalid type format/) - }) - - it('should reject "; filename=foo.html"', function () { - assert.throws(contentDisposition.parse.bind(null, '; filename=foo.html'), - /invalid type format/) - }) - - it('should reject ": inline; attachment; filename=foo.html', function () { - assert.throws(contentDisposition.parse.bind(null, ': inline; attachment; filename=foo.html'), - /invalid type format/) - }) - - it('should reject "inline; attachment; filename=foo.html', function () { - assert.throws(contentDisposition.parse.bind(null, 'inline; attachment; filename=foo.html'), - /invalid parameter format/) - }) - - it('should reject "attachment; inline; filename=foo.html', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; inline; filename=foo.html'), - /invalid parameter format/) - }) - - it('should reject "attachment; filename="foo.html".txt', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename="foo.html".txt'), - /invalid parameter format/) - }) - - it('should reject "attachment; filename="bar', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename="bar'), - /invalid parameter format/) - }) - - it('should reject "attachment; filename=foo"bar;baz"qux', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename=foo"bar;baz"qux'), - /invalid parameter format/) - }) - - it('should reject "attachment; filename=foo.html, attachment; filename=bar.html', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename=foo.html, attachment; filename=bar.html'), - /invalid parameter format/) - }) - - it('should reject "attachment; foo=foo filename=bar', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; foo=foo filename=bar'), - /invalid parameter format/) - }) - - it('should reject "attachment; filename=bar foo=foo', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename=bar foo=foo'), - /invalid parameter format/) - }) - - it('should reject "attachment filename=bar', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment filename=bar'), - /invalid type format/) - }) - - it('should reject "filename=foo.html; attachment', function () { - assert.throws(contentDisposition.parse.bind(null, 'filename=foo.html; attachment'), - /invalid type format/) - }) - - it('should parse "attachment; xfilename=foo.html"', function () { - assert.deepEqual(contentDisposition.parse('attachment; xfilename=foo.html'), { - type: 'attachment', - parameters: { xfilename: 'foo.html' } - }) - }) - - it('should parse "attachment; filename="/foo.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="/foo.html"'), { - type: 'attachment', - parameters: { filename: '/foo.html' } - }) - }) - - it('should parse "attachment; filename="\\\\foo.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="\\\\foo.html"'), { - type: 'attachment', - parameters: { filename: '\\foo.html' } - }) - }) - }) - - describe('Additional Parameters', function () { - it('should parse "attachment; creation-date="Wed, 12 Feb 1997 16:29:51 -0500""', function () { - assert.deepEqual(contentDisposition.parse('attachment; creation-date="Wed, 12 Feb 1997 16:29:51 -0500"'), { - type: 'attachment', - parameters: { 'creation-date': 'Wed, 12 Feb 1997 16:29:51 -0500' } - }) - }) - - it('should parse "attachment; modification-date="Wed, 12 Feb 1997 16:29:51 -0500""', function () { - assert.deepEqual(contentDisposition.parse('attachment; modification-date="Wed, 12 Feb 1997 16:29:51 -0500"'), { - type: 'attachment', - parameters: { 'modification-date': 'Wed, 12 Feb 1997 16:29:51 -0500' } - }) - }) - }) - - describe('Disposition-Type Extension', function () { - it('should parse "foobar"', function () { - assert.deepEqual(contentDisposition.parse('foobar'), { - type: 'foobar', - parameters: {} - }) - }) - - it('should parse "attachment; example="filename=example.txt""', function () { - assert.deepEqual(contentDisposition.parse('attachment; example="filename=example.txt"'), { - type: 'attachment', - parameters: { example: 'filename=example.txt' } - }) - }) - }) - - describe('RFC 2231/5987 Encoding: Character Sets', function () { - it('should parse "attachment; filename*=iso-8859-1\'\'foo-%E4.html"', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*=iso-8859-1\'\'foo-%E4.html'), { - type: 'attachment', - parameters: { filename: 'foo-ä.html' } - }) - }) - - it('should parse "attachment; filename*=UTF-8\'\'foo-%c3%a4-%e2%82%ac.html"', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*=UTF-8\'\'foo-%c3%a4-%e2%82%ac.html'), { - type: 'attachment', - parameters: { filename: 'foo-ä-€.html' } - }) - }) - - it('should reject "attachment; filename*=\'\'foo-%c3%a4-%e2%82%ac.html"', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename*=\'\'foo-%c3%a4-%e2%82%ac.html'), - /invalid extended.*value/) - }) - - it('should parse "attachment; filename*=UTF-8\'\'foo-a%cc%88.html"', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*=UTF-8\'\'foo-a%cc%88.html'), { - type: 'attachment', - parameters: { filename: 'foo-ä.html' } - }) - }) - - it('should parse "attachment; filename*=iso-8859-1\'\'foo-%c3%a4-%e2%82%ac.html"', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*=iso-8859-1\'\'foo-%c3%a4-%e2%82%ac.html'), { - type: 'attachment', - parameters: { filename: 'foo-ä-â?¬.html' } - }) - }) - - it('should parse "attachment; filename*=utf-8\'\'foo-%E4.html"', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*=utf-8\'\'foo-%E4.html'), { - type: 'attachment', - parameters: { filename: 'foo-\ufffd.html' } - }) - }) - - it('should reject "attachment; filename *=UTF-8\'\'foo-%c3%a4.html"', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename *=UTF-8\'\'foo-%c3%a4.html'), - /invalid parameter format/) - }) - - it('should parse "attachment; filename*= UTF-8\'\'foo-%c3%a4.html"', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*= UTF-8\'\'foo-%c3%a4.html'), { - type: 'attachment', - parameters: { filename: 'foo-ä.html' } - }) - }) - - it('should parse "attachment; filename* =UTF-8\'\'foo-%c3%a4.html"', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename* =UTF-8\'\'foo-%c3%a4.html'), { - type: 'attachment', - parameters: { filename: 'foo-ä.html' } - }) - }) - - it('should reject "attachment; filename*="UTF-8\'\'foo-%c3%a4.html""', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename*="UTF-8\'\'foo-%c3%a4.html"'), - /invalid extended field value/) - }) - - it('should reject "attachment; filename*="foo%20bar.html""', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename*="foo%20bar.html"'), - /invalid extended field value/) - }) - - it('should reject "attachment; filename*=UTF-8\'foo-%c3%a4.html"', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename*=UTF-8\'foo-%c3%a4.html'), - /invalid extended field value/) - }) - - it('should reject "attachment; filename*=UTF-8\'\'foo%"', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename*=UTF-8\'\'foo%'), - /invalid extended field value/) - }) - - it('should reject "attachment; filename*=UTF-8\'\'f%oo.html"', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename*=UTF-8\'\'f%oo.html'), - /invalid extended field value/) - }) - - it('should parse "attachment; filename*=UTF-8\'\'A-%2541.html"', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*=UTF-8\'\'A-%2541.html'), { - type: 'attachment', - parameters: { filename: 'A-%41.html' } - }) - }) - - it('should parse "attachment; filename*=UTF-8\'\'%5cfoo.html"', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*=UTF-8\'\'%5cfoo.html'), { - type: 'attachment', - parameters: { filename: '\\foo.html' } - }) - }) - }) - - describe('RFC2231 Encoding: Continuations', function () { - it('should parse "attachment; filename*0="foo."; filename*1="html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*0="foo."; filename*1="html"'), { - type: 'attachment', - parameters: { 'filename*0': 'foo.', 'filename*1': 'html' } - }) - }) - - it('should parse "attachment; filename*0="foo"; filename*1="\\b\\a\\r.html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*0="foo"; filename*1="\\b\\a\\r.html"'), { - type: 'attachment', - parameters: { 'filename*0': 'foo', 'filename*1': 'bar.html' } - }) - }) - - it('should parse "attachment; filename*0*=UTF-8\'\'foo-%c3%a4; filename*1=".html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*0*=UTF-8\'\'foo-%c3%a4; filename*1=".html"'), { - type: 'attachment', - parameters: { 'filename*0*': 'UTF-8\'\'foo-%c3%a4', 'filename*1': '.html' } - }) - }) - - it('should parse "attachment; filename*0="foo"; filename*01="bar""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*0="foo"; filename*01="bar"'), { - type: 'attachment', - parameters: { 'filename*0': 'foo', 'filename*01': 'bar' } - }) - }) - - it('should parse "attachment; filename*0="foo"; filename*2="bar""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*0="foo"; filename*2="bar"'), { - type: 'attachment', - parameters: { 'filename*0': 'foo', 'filename*2': 'bar' } - }) - }) - - it('should parse "attachment; filename*1="foo."; filename*2="html""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*1="foo."; filename*2="html"'), { - type: 'attachment', - parameters: { 'filename*1': 'foo.', 'filename*2': 'html' } - }) - }) - - it('should parse "attachment; filename*1="bar"; filename*0="foo""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*1="bar"; filename*0="foo"'), { - type: 'attachment', - parameters: { 'filename*1': 'bar', 'filename*0': 'foo' } - }) - }) - }) - - describe('RFC2231 Encoding: Fallback Behaviour', function () { - it('should parse "attachment; filename="foo-ae.html"; filename*=UTF-8\'\'foo-%c3%a4.html"', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="foo-ae.html"; filename*=UTF-8\'\'foo-%c3%a4.html'), { - type: 'attachment', - parameters: { filename: 'foo-ä.html' } - }) - }) - - it('should parse "attachment; filename*=UTF-8\'\'foo-%c3%a4.html; filename="foo-ae.html"', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*=UTF-8\'\'foo-%c3%a4.html; filename="foo-ae.html"'), { - type: 'attachment', - parameters: { filename: 'foo-ä.html' } - }) - }) - - it('should parse "attachment; filename*0*=ISO-8859-15\'\'euro-sign%3d%a4; filename*=ISO-8859-1\'\'currency-sign%3d%a4', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename*0*=ISO-8859-15\'\'euro-sign%3d%a4; filename*=ISO-8859-1\'\'currency-sign%3d%a4'), { - type: 'attachment', - parameters: { filename: 'currency-sign=¤', 'filename*0*': 'ISO-8859-15\'\'euro-sign%3d%a4' } - }) - }) - - it('should parse "attachment; foobar=x; filename="foo.html"', function () { - assert.deepEqual(contentDisposition.parse('attachment; foobar=x; filename="foo.html"'), { - type: 'attachment', - parameters: { filename: 'foo.html', foobar: 'x' } - }) - }) - }) - - describe('RFC2047 Encoding', function () { - it('should reject "attachment; filename==?ISO-8859-1?Q?foo-=E4.html?="', function () { - assert.throws(contentDisposition.parse.bind(null, 'attachment; filename==?ISO-8859-1?Q?foo-=E4.html?='), - /invalid parameter format/) - }) - - it('should parse "attachment; filename="=?ISO-8859-1?Q?foo-=E4.html?=""', function () { - assert.deepEqual(contentDisposition.parse('attachment; filename="=?ISO-8859-1?Q?foo-=E4.html?="'), { - type: 'attachment', - parameters: { filename: '=?ISO-8859-1?Q?foo-=E4.html?=' } - }) - }) - }) - }) -}) diff --git a/textdecoder.d.ts b/textdecoder.d.ts new file mode 100644 index 0000000..bd04605 --- /dev/null +++ b/textdecoder.d.ts @@ -0,0 +1,61 @@ +// Adapted from file://./node_modules/typescript/lib/lib.dom.d.ts so we don't have to include the entire DOM lib +// Ref: https://github.com/microsoft/TypeScript/issues/31535, https://github.com/microsoft/TypeScript/issues/41727, https://github.com/microsoft/TypeScript-DOM-lib-generator/issues/1685 + +type AllowSharedBufferSource = + | ArrayBufferLike + | ArrayBufferView; + +interface TextDecodeOptions { + stream?: boolean; +} + +interface TextDecoderOptions { + fatal?: boolean; + ignoreBOM?: boolean; +} + +/** + * The **`TextDecoder`** interface represents a decoder for a specific text encoding, such as `UTF-8`, `ISO-8859-2`, `KOI8-R`, `GBK`, etc. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder) + */ +interface TextDecoder extends TextDecoderCommon { + /** + * The **`TextDecoder.decode()`** method returns a string containing text decoded from the buffer passed as a parameter. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder/decode) + */ + decode(input?: AllowSharedBufferSource, options?: TextDecodeOptions): string; +} + +// eslint-disable-next-line no-var +declare var TextDecoder: { + prototype: TextDecoder; + new (label?: string, options?: TextDecoderOptions): TextDecoder; +}; + +interface TextDecoderCommon { + /** + * Returns encoding's name, lowercased. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder/encoding) + */ + readonly encoding: string; + /** + * Returns true if error mode is "fatal", otherwise false. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder/fatal) + */ + readonly fatal: boolean; + /** + * Returns the value of ignore BOM. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder/ignoreBOM) + */ + readonly ignoreBOM: boolean; +} + +// eslint-disable-next-line no-var +declare var module: { + exports: unknown; +}; diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..fb2211f --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": {}, + "exclude": ["src/**/*.spec.ts", "src/**/*.bench.ts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d3699eb --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@borderless/ts-scripts/configs/tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "rootDir": "src", + "outDir": "dist", + "module": "nodenext", + "moduleResolution": "nodenext", + "types": ["./textdecoder.d.ts"] + }, + "include": ["src/**/*"] +}