"use strict"; /* eslint no-console: 0, new-cap: 0 */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const imap_formal_syntax_js_1 = __importDefault(require("./imap-formal-syntax.js")); const errors_js_1 = require("../errors.js"); // A single element of a sequence-set as defined by the RFC 9051 grammar: a number // or a range, where "*" stands for the largest number in use. Digit strings are not // range-checked here (a server rejects "0" or an overlong number on its own); the // point of the check is that nothing outside this alphabet can reach the wire. const SEQ_RANGE = /^(\d+|\*)(:(\d+|\*))?$/; // Validates a full sequence-set: comma-separated SEQ_RANGE elements, or "$" // (RFC 5182 SEARCHRES), which references the previous SEARCH result and is only // valid as the entire set. Split into per-element tests on purpose - a whole-set // regex with an unbounded repeat group overflows the regex engine's backtrack // stack with an uncoded RangeError on valid sets in the million-element range, // while the per-element regex is bounded. const isValidSequenceSet = (value) => value === '$' || value.split(',').every(part => SEQ_RANGE.test(part)); // Numeric tokens may only put the digit alphabet on the wire. Anything that does // not round to a bounded non-negative integer (NaN, Infinity, negatives, unsafe // magnitudes) degrades to 0 - the fallback the NaN coercion has always used. const safeNumber = (value) => { let num = Math.round(Number(value)); return Number.isSafeInteger(num) && num >= 0 ? num : 0; }; // Log output stands in a placeholder for any value longer than this, so a log entry never // carries a copy of a large token (a message body, a long unquoted server token) const LOG_VALUE_LIMIT = 100; const logPlaceholder = (length, kind) => `"(* ${length}B ${kind} *)"`; // Characters that cannot appear in an IMAP quoted string: CR and LF terminate a // command line, and NUL is outside the CHAR production entirely. A value carrying // any of them has to be sent as a literal, so quoting it is never correct. const NOT_QUOTABLE = /[\r\n\0]/; // A line terminator ends an IMAP command, so no token may carry one to the wire. const CRLF = /[\r\n]/; /** * Quotes a value as an IMAP quoted string. Only DQUOTE and backslash are escaped - * the IMAP grammar defines no other escape sequence, so JSON-style escaping (which * turns a tab into a literal backslash-t and a control character into \\uXXXX) would * silently change the value the server receives. * * @param value - The value to quote. * @returns The quoted string, ready to be written to the wire. * @throws {Error} If the value contains CR, LF or NUL, which a quoted string cannot carry. */ const quoteString = (value) => { if (NOT_QUOTABLE.test(value)) { throw (0, errors_js_1.createImapError)('Unquotable character in IMAP string value', 'InvalidStringValue'); } return '"' + value.replace(/["\\]/g, char => '\\' + char) + '"'; }; function compiler(response, options) { let { asArray, isLogging, literalPlus, literalMinus } = options || {}; const respParts = []; // Formats an entry (string, number or Buffer) into the Buffer that is written to // the wire, and is the choke point every emission passes through: a line // terminator ends an IMAP command, so no token - the tag and command name // included - may put one on the wire. The literal size marker and literal data // are the only emissions where CRLF is legitimate; those call sites opt out with // `raw`. Never enforced when logging: re-encoding an incoming server response for // the log or for error text must not throw, whatever the server sent. With // `returnEmpty`, an unrecognized entry type yields null instead of an empty // Buffer. const emitEntry = (entry, opts) => { let { returnEmpty, raw } = opts || {}; if (!raw && !isLogging && (typeof entry === 'string' || Buffer.isBuffer(entry)) && CRLF.test(entry.toString('latin1'))) { throw (0, errors_js_1.createImapError)('Line terminator in IMAP token', 'InvalidTokenValue'); } if (typeof entry === 'string') { return Buffer.from(entry); } if (typeof entry === 'number') { return Buffer.from(entry.toString()); } if (Buffer.isBuffer(entry)) { return entry; } if (returnEmpty) { return null; } return Buffer.alloc(0); }; let resp = [] .concat(emitEntry(response.tag, { returnEmpty: true }) || []) .concat(response.command ? emitEntry(' ' + response.command) : []); let val; let lastType; // Set right after the compiler writes "(" or "[" itself, so the first element inside gets no // leading space let afterOpener = false; let walk = (node, options) => { options = options || {}; // Add a space separator when: // - The previous token was a LITERAL. Literal data ends exactly at its declared length, so // a following token always needs an explicit separator, even though the last written byte // is arbitrary literal content. // - Otherwise: there is something written already (resp is not empty) and the compiler did // not just open a list or a section. This is tracked rather than read back from the last // written byte: a token value can itself end in "(", "[" or "<" (an atom such as "X["), // and suppressing the space after it would fuse it with the next argument. // A sub-array element in a consecutive-list context never gets one (no space between // adjacent lists). if (lastType === 'LITERAL' || (!afterOpener && resp.length)) { if (!options.subArray) { resp.push(emitEntry(' ')); } } afterOpener = false; if (node && node.buffer && !Buffer.isBuffer(node)) { // mongodb binary node = node.buffer; } if (Array.isArray(node)) { lastType = 'LIST'; resp.push(emitEntry('(')); afterOpener = true; // check if we need to skip separator WS between two arrays let subArray = node.length > 1 && Array.isArray(node[0]); for (let child of node) { if (subArray && !Array.isArray(child)) { subArray = false; } walk(child, { subArray }); } resp.push(emitEntry(')')); afterOpener = false; return; } if (!node && typeof node !== 'string' && typeof node !== 'number' && !Buffer.isBuffer(node)) { resp.push(emitEntry('NIL')); return; } if (typeof node === 'string' || Buffer.isBuffer(node)) { if (isLogging && node.length > LOG_VALUE_LIMIT) { resp.push(emitEntry(logPlaceholder(node.length, 'string'))); } else { resp.push(emitEntry(isLogging ? JSON.stringify(node.toString()) : quoteString(node.toString()))); } return; } if (typeof node === 'number') { resp.push(emitEntry(safeNumber(node))); // Only bounded non-negative integers allowed return; } lastType = node.type; if (isLogging && node.sensitive) { resp.push(emitEntry('"(* value hidden *)"')); return; } switch (node.type.toUpperCase()) { case 'LITERAL': if (isLogging) { resp.push(emitEntry(logPlaceholder(node.value.length, 'literal'))); } else { // The size marker must match the bytes emitEntry() writes, and it writes // nothing for a value that is not a Buffer or a string if (node.value && !Buffer.isBuffer(node.value) && typeof node.value !== 'string') { throw (0, errors_js_1.createImapError)('Literal value must be a Buffer or a string', 'InvalidTokenValue'); } // The literal size marker counts octets - string values are written as // UTF-8, so their UTF-16 .length would undercount multi-byte characters let literalLength = !node.value ? 0 : Buffer.isBuffer(node.value) ? node.value.length : Buffer.byteLength(node.value.toString()); // Append '+' to the size marker only when the extension actually permits a // non-synchronizing literal of this size (RFC 7888): LITERAL+ always, // LITERAL- only up to 4096 bytes let usePlus = literalPlus || (literalMinus && literalLength <= 4096); // canAppend: whether the literal data can be sent in the same buffer segment - // non-synchronizing literals always, and everything in single-buffer mode // (asArray false), which has no continuation flow let canAppend = !asArray || usePlus; // Emit the literal header: optional '~' prefix for literal8, then {size[+]}\r\n resp.push(emitEntry(`${node.isLiteral8 ? '~' : ''}{${literalLength}${usePlus ? '+' : ''}}\r\n`, { raw: true })); if (canAppend) { // Literal data follows immediately in the same buffer segment if (node.value && node.value.length) { resp.push(emitEntry(node.value, { raw: true })); } } else { // For synchronizing literals in asArray mode, split output into separate // parts. The caller must send each part and wait for a continuation // response from the server before sending the next. respParts.push(resp); resp = [].concat(emitEntry(node.value, { returnEmpty: true, raw: true }) || []); } } break; case 'STRING': if (isLogging && node.value.length > LOG_VALUE_LIMIT) { resp.push(emitEntry(logPlaceholder(node.value.length, 'string'))); } else { val = (node.value || '').toString(); resp.push(emitEntry(isLogging ? JSON.stringify(val) : quoteString(val))); } break; case 'SEQUENCE': // Sequence sets are written verbatim - they are the one token type with // no quoting to fall back on. Callers build them from user-supplied // ranges, so validate here, at the single point every outgoing sequence // set passes through, rather than trusting each command module. Skipped // when logging: the incoming token parser accepts sequence-shaped tokens // this strict grammar rejects (an ESEARCH set like "1:2:3", a folder // name like "12:30:00"), and re-compiling a server response for the log // or for error text must never throw. An empty or missing set is refused // too: it would put nothing on the wire and leave the next argument in its // place. // Emitted raw: the validated alphabet cannot contain a line terminator, and // re-scanning a potentially multi-megabyte set in the choke point would // double the cost of exactly the sets this branch exists for if (!isLogging) { val = node.value === null || node.value === undefined ? '' : node.value.toString(); if (!isValidSequenceSet(val)) { throw (0, errors_js_1.createImapError)('Invalid sequence set value', 'InvalidSequenceSet'); } resp.push(emitEntry(val, { raw: true })); } else if (node.value) { resp.push(emitEntry(node.value, { raw: true })); } break; case 'TEXT': // Response text is written verbatim. Only the parser produces it today, for // incoming lines, so this is a re-encoding path rather than a command-building // one. The emitEntry choke point would refuse a line terminator here too; // this check runs first only to raise the more specific InvalidTextValue code. if (node.value) { if (!isLogging && CRLF.test(node.value.toString())) { throw (0, errors_js_1.createImapError)('Line terminator in IMAP text value', 'InvalidTextValue'); } resp.push(emitEntry(node.value)); } break; case 'NUMBER': // Coerced rather than written through: formatRespEntry passes a string or // Buffer straight to the wire, so a numeric token carrying a string value // would be another verbatim channel resp.push(emitEntry(safeNumber(node.value))); break; case 'ATOM': case 'SECTION': val = (node.value || '').toString(); if (!node.section || val) { if (isLogging && val.length > LOG_VALUE_LIMIT) { // A server can put a line's worth of bytes in one unquoted token val = logPlaceholder(val.length, 'atom'); } else if (node.value === '' || imap_formal_syntax_js_1.default.verify(val.charAt(0) === '\\' ? val.slice(1) : val, imap_formal_syntax_js_1.default['ATOM-CHAR']()) >= 0) { // An empty value, or one with a character outside ATOM-CHAR (checked // past a leading backslash, as system flags like \Seen carry one), goes // out as an IMAP quoted string. JSON.stringify is used only for log // output, where values are display-escaped. val = isLogging ? JSON.stringify(val) : quoteString(val); } resp.push(emitEntry(val)); } // Section bracket handling: emit [section-contents] after the ATOM value // e.g., BODY[HEADER.FIELDS (Subject)] or BODY[1.MIME] if (node.section) { resp.push(emitEntry('[')); afterOpener = true; for (let child of node.section) { walk(child); } resp.push(emitEntry(']')); afterOpener = false; } // Partial range: emit after the section brackets. Coerced // rather than joined as-is: this is the last token component written // verbatim, and the choke point is only worth relying on if it holds for // all of them. Every producer already passes numbers, so nothing changes // for them. if (node.partial) { resp.push(emitEntry(`<${node.partial.map(safeNumber).join('.')}>`)); } break; } }; if (response.attributes) { let attributes = Array.isArray(response.attributes) ? response.attributes : [].concat(response.attributes); for (let child of attributes) { walk(child); } } if (response.numbers) { for (let value of response.numbers) { walk(value); } } if (resp.length) { respParts.push(resp); } // A part with a single entry (the data of a synchronizing literal) is used as is: copying a // large message only to send it doubles its memory const compiled = respParts.map(part => (part.length === 1 ? part[0] : Buffer.concat(part))); // without asArray there is a single part, returned as is instead of copied return asArray ? compiled : compiled.length === 1 ? compiled[0] : Buffer.concat(compiled); } exports.default = compiler; module.exports = exports.default; Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });