"use strict"; /* eslint new-cap: 0 */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.TokenParser = void 0; const imap_formal_syntax_js_1 = __importDefault(require("./imap-formal-syntax.js")); const limits_js_1 = require("./limits.js"); const errors_js_1 = require("../errors.js"); const STATE_ATOM = 0x001; const STATE_LITERAL = 0x002; const STATE_NORMAL = 0x003; const STATE_PARTIAL = 0x004; const STATE_SEQUENCE = 0x005; const STATE_STRING = 0x006; const RE_SINGLE_DIGIT = /^\d$/; // Prevents stack overflow from maliciously crafted deeply-nested IMAP input (e.g., (((((...)))))). // The tokenizer itself is iterative; what recurses is the getAttributes() walk and the // consumers of its output (the body structure parser, the compiler used for logging), and // a hundred frames is nothing to any of them. The old bound of 25 was reached by a message // with 22 nested multiparts, which an external sender can produce, and the whole FETCH row // then failed to parse and was dropped silently. const MAX_NODE_DEPTH = 100; // The depth at which the data items of a FETCH response sit: TREE (0) > the item list (1) > // a key or value (2). See createNode() for what happens when nesting overflows below one. const FETCH_DATA_ITEM_DEPTH = 2; /** * Tokenizes an IMAP attribute string into a tree of typed nodes. * Handles all IMAP data types: atoms, quoted strings, literals (including literal8), * sequences, lists (parenthesized groups), sections (bracketed groups), and partial ranges. * Enforces a maximum nesting depth of {@link MAX_NODE_DEPTH} to prevent stack overflow * from malicious input: a FETCH data item that nests deeper is replaced by NIL and listed in * `truncatedAttributes`, any other input that nests deeper fails to parse. */ class TokenParser { /** * Creates a new TokenParser. * * @param parent - The parent ParserInstance that owns this token parser. Used to access the parsed command for context-sensitive parsing. * @param startPos - The starting position offset in the original input, used for error reporting. * @param str - The attribute string to tokenize. * @param options - Parser options. * @param options.literalPlus - Whether the LITERAL+ extension is in use. * @param options.literals - Pre-parsed literal values from the input stream. * @param options.maxLiteralSize - Maximum size (in bytes) of a literal parsed inline * from the input, i.e. when no pre-parsed literal buffers were supplied. Defaults to 1GB. * @param options.maxResponseTokens - Maximum number of parse tree nodes, see MAX_RESPONSE_TOKENS. */ constructor(parent, startPos, str, options) { this.str = (str || '').toString(); this.options = options || {}; this.parent = parent; // Same normalization and default as the streaming parser, so a direct user of this parser // gets the same bound (an explicit 0 means "reject any non-empty inline literal"). this.maxLiteralSize = (0, limits_js_1.normalizeLimit)(this.options.maxLiteralSize, limits_js_1.MAX_LITERAL_SIZE); this.maxResponseTokens = (0, limits_js_1.normalizeLimit)(this.options.maxResponseTokens, limits_js_1.MAX_RESPONSE_TOKENS); this.tokenCount = 0; this.truncatedAttributes = []; this.tree = this.currentNode = this.createNode(); this.pos = startPos || 0; this.currentNode.type = 'TREE'; this.state = STATE_NORMAL; } /** * Builds a numbered parser error that carries a bounded copy of the input and the position * (and character, when there is one) where parsing failed. * * @param message - The error message * @param code - The numbered parser error code * @param pos - Position of the failure in the original input * @param chr - The offending character * @returns The error to throw */ parserError(message, code, pos, chr) { const parserContext = { ...(0, limits_js_1.boundedInput)(this.str), pos }; if (chr !== undefined) { parserContext.chr = chr; } return (0, errors_js_1.createImapError)(message, code, { parserContext }); } /** * Processes the input string and returns the parsed attributes as a flat array of typed objects. * Each attribute is an object with a `type` (e.g., "ATOM", "STRING", "LITERAL", "SEQUENCE") * and a `value` property. Lists are represented as nested arrays. Sections and partials are * attached as properties on the preceding attribute object. * * @returns A promise that resolves to an array of parsed attribute objects and nested arrays. * @throws {Error} If the input contains syntax errors or unclosed nodes. */ async getAttributes() { await this.processString(); const attributes = []; let branch = attributes; // Synchronous on purpose: the recursion is bounded by MAX_NODE_DEPTH, and an async walk // allocated a promise per token, which a line of many small tokens multiplies let walk = (node) => { let curBranch = branch; let elm; let partial; if (!node.isClosed && node.type === 'SEQUENCE' && node.value === '*') { node.isClosed = true; node.type = 'ATOM'; } // If the node was never closed, throw it if (!node.isClosed) { throw this.parserError(`Unexpected end of input at position ${this.pos + this.str.length - 1} [E9]`, 'ParserError9', this.pos + this.str.length - 1); } let type = (node.type || '').toString().toUpperCase(); if (node.truncated) { // The item nested deeper than MAX_NODE_DEPTH, see createNode(). Its subtree is // not walked (that is what keeps this recursion bounded). A list is replaced by // NIL so the data items keep pairing up as key and value; a section is attached // to the item before it, so it is dropped whole. if (type === 'LIST') { branch.push(null); } return; } switch (type) { case 'LITERAL': case 'STRING': case 'SEQUENCE': elm = { type: node.type.toUpperCase(), value: node.value }; branch.push(elm); break; case 'ATOM': if (node.value.toUpperCase() === 'NIL') { branch.push(null); break; } elm = { type: node.type.toUpperCase(), value: node.value }; branch.push(elm); break; case 'SECTION': branch = branch[branch.length - 1].section = []; break; case 'LIST': elm = []; branch.push(elm); branch = elm; break; case 'PARTIAL': partial = node.value.split('.').map(Number); branch[branch.length - 1].partial = partial; break; } // Each child is released once it is converted, so the tree and the attributes built // from it are not both held whole at the peak of a line of many tokens let childNodes = node.childNodes; for (let i = 0; childNodes && i < childNodes.length; i++) { walk(childNodes[i]); childNodes[i] = null; } branch = curBranch; }; walk(this.tree); return attributes; } /** * Creates a new node in the parse tree. Each node represents a token or structural * element (e.g., atom, string, literal, list, section, partial). The node is automatically * appended to the parent's childNodes array if a parent is provided. * * @param parentNode - The parent node to attach this node to. If omitted, creates a root node. * @param startPos - The starting position of this node in the original input string. * @returns The newly created node with childNodes, type, value, and isClosed properties. * @throws {Error} If the nesting depth exceeds MAX_NODE_DEPTH outside a FETCH data item, or * the response has more than maxResponseTokens nodes. */ createNode(parentNode, startPos) { // The root node is not a token of the response if (parentNode && ++this.tokenCount > this.maxResponseTokens) { throw (0, errors_js_1.createImapError)(`Response has more than ${this.maxResponseTokens} tokens`, 'TooManyTokens', { maxSize: this.maxResponseTokens, parserContext: { ...(0, limits_js_1.boundedInput)(this.str), pos: startPos } }); } let node = { // declared up front, so a node keeps one shape when its first child arrives childNodes: undefined, type: false, value: '', isClosed: true, depth: 0 }; if (parentNode) { node.parentNode = parentNode; node.depth = parentNode.depth + 1; } else { node.depth = 0; } if (node.depth > MAX_NODE_DEPTH) { if (!this.isFetchResponse()) { // A bounded prefix only, the error travels whole into logs and rejections throw (0, errors_js_1.createImapError)('Too much nesting in IMAP string', 'MAX_IMAP_NESTING_REACHED', { _imapStr: this.str.slice(0, limits_js_1.ERROR_CONTEXT_LENGTH) }); } // A FETCH row carries the data of one message, and the item nesting this deep (a // BODYSTRUCTURE of a message with a hundred nested multiparts, which the sender of // that message controls) is one of several. Failing the line would drop the whole // row, and with it the message, from the FETCH result. The data item is marked // instead, which getAttributes() turns into NIL, and the tokenizer carries on: it // keeps no stack, so the depth costs it nothing. Marked on the first crossing only, // every node below that one is already inside a marked item. if (parentNode.depth === MAX_NODE_DEPTH) { this.truncateFetchDataItem(parentNode); } } if (typeof startPos === 'number') { node.startPos = startPos; } if (parentNode) { (parentNode.childNodes ||= []).push(node); } return node; } /** * Whether the line being parsed is a message data response (`* 12 FETCH (...)`): a numeric * command with FETCH as its first attribute. Only those get the per-item nesting fallback. * * @returns True for an untagged FETCH response */ isFetchResponse() { let command = this.parent.command; let first = this.tree.childNodes?.[0]; return (typeof command === 'string' && /^\d+$/.test(command) && !!first && first.type === 'ATOM' && typeof first.value === 'string' && first.value.toUpperCase() === 'FETCH'); } /** * Marks the FETCH data item a too deeply nested node belongs to as truncated and records * its key (the item before it in the list, e.g. BODYSTRUCTURE) for the caller to report. * * @param node - The node at MAX_NODE_DEPTH whose child would exceed the limit */ truncateFetchDataItem(node) { let item = node; while (item.depth > FETCH_DATA_ITEM_DEPTH && item.parentNode) { item = item.parentNode; } if (item.truncated) { return; } item.truncated = true; // The item is the last child of the list while it is being parsed, so its key is the // child before it. A key is an atom (BODYSTRUCTURE, ENVELOPE, BODY); anything else means // the row itself is malformed, and the key is reported as unknown. let siblings = item.parentNode?.childNodes || []; let key = siblings[siblings.length - 2]; this.truncatedAttributes.push(key && key.type === 'ATOM' && typeof key.value === 'string' ? key.value.toUpperCase() : '?'); } /** * Processes the entire input string character by character using a state machine. * Transitions between states (NORMAL, ATOM, STRING, LITERAL, SEQUENCE, PARTIAL, TEXT) * based on the current character and builds the parse tree. This is the main parsing * loop that drives the tokenization. * * @throws {Error} If the input contains unexpected characters, unclosed structures, or other syntax errors. */ async processString() { let chr, i, len; const checkSP = () => { // jump to the next non whitespace pos while (this.str.charAt(i + 1) === ' ') { i++; } }; // Whether chr is the closing delimiter of node: ")" ends a LIST, "]" ends a SECTION const closesNode = (c, node) => !!node && ((c === ')' && node.type === 'LIST') || (c === ']' && node.type === 'SECTION')); // Facts about the ATOM, SEQUENCE or PARTIAL token being read, kept up to date as its // characters are appended. Reading them back from the value instead (value.at(-1), // value.includes('*'), a regex test) makes V8 flatten the string built with += on every // character, which is quadratic in the token length, and the server decides that length: // a 600 KB ESEARCH result took seconds of event loop time let tokenLength = 0; // Where the token starts in the input, and whether its value still is that run of the // input. A token is read as a run of characters, so its value is sliced from the input // instead of grown with +=: each += of a long token adds a cons string of some twenty // bytes per character, which the server multiplies with a long token let tokenStart = 0; let tokenSliced = true; // Where the quoted string being read starts, and whether it holds an escape let stringStart = 0; let stringEscaped = false; let tokenLast = ''; let tokenPrev = ''; let tokenDigitsOnly = true; let tokenHasStar = false; let tokenHasDot = false; const appendToToken = (c) => { if (!tokenLength) { tokenStart = i; } tokenSliced = tokenSliced && i === tokenStart + tokenLength && this.str.charAt(i) === c; this.currentNode.value = tokenSliced ? this.str.slice(tokenStart, i + 1) : this.currentNode.value + c; tokenLength++; tokenPrev = tokenLast; tokenLast = c; tokenDigitsOnly = tokenDigitsOnly && RE_SINGLE_DIGIT.test(c); tokenHasStar = tokenHasStar || c === '*'; tokenHasDot = tokenHasDot || c === '.'; }; // A digit-led token is only a guess at a sequence set. ":" and "," are ATOM-CHARs, so a // server sends a mailbox name, keyword or label such as "2024:Q1" or "1,a" unquoted, and // rejecting it would drop the whole response line: the mailbox missing from LIST, the // message missing from FETCH. When the sequence grammar breaks, such a token is read on as // an atom, and the atom rules decide whether the current character is acceptable. A token // holding "*" can not be an atom ("*" is not an ATOM-CHAR), so it keeps the sequence errors const continueAsAtom = () => { this.currentNode.type = 'ATOM'; this.state = STATE_ATOM; // read the current character again, in STATE_ATOM i--; }; // Starts the value of the current node as a new token, with its first character if any const startToken = (first) => { this.currentNode.value = ''; tokenLength = 0; tokenSliced = true; tokenLast = tokenPrev = ''; tokenDigitsOnly = true; tokenHasStar = tokenHasDot = false; if (first) { appendToToken(first); } }; // ImapStream supplies one buffer per literal marker it framed, {0} included. A marker // without a buffer means the line and its literals do not match up, so fail with a parser // error instead of reading a missing buffer or shifting every later literal by one // Read by index: shift() moves every remaining buffer, which made a response of many // literals quadratic to parse let literalIndex = 0; const takeLiteral = (literals) => { if (literalIndex >= literals.length) { throw this.parserError(`Literal without data at position ${this.pos + i} [E35]`, 'ParserError35', this.pos + i, chr); } return literals[literalIndex++]; }; // Any ATOM supported char starts a new Atom sequence, otherwise throw an error // Allow \ as the first char for atom to support system flags // Allow % to support LIST '' % // Allow 8bit characters (presumably unicode) // Shared by the default branch and by a '[' that is not a response code section, which // used to fall through into the default branch const startAtom = () => { if (!imap_formal_syntax_js_1.default['ATOM-CHAR']().includes(chr) && chr !== '\\' && chr !== '%' && chr.charCodeAt(0) < 0x80) { throw this.parserError(`Unexpected char at position ${this.pos + i} [E13: ${JSON.stringify(chr)}]`, 'ParserError13', this.pos + i, chr); } this.currentNode = this.createNode(this.currentNode, this.pos + i); this.currentNode.type = 'ATOM'; startToken(chr); this.state = STATE_ATOM; }; for (i = 0, len = this.str.length; i < len; i++) { chr = this.str.charAt(i); switch (this.state) { case STATE_NORMAL: switch (chr) { // DQUOTE starts a new string case '"': this.currentNode = this.createNode(this.currentNode, this.pos + i); this.currentNode.type = 'string'; this.state = STATE_STRING; stringStart = i + 1; stringEscaped = false; this.currentNode.isClosed = false; break; // ( starts a new list case '(': this.currentNode = this.createNode(this.currentNode, this.pos + i); this.currentNode.type = 'LIST'; this.currentNode.isClosed = false; break; // ) closes a list case ')': if (this.currentNode.type !== 'LIST') { throw this.parserError(`Unexpected list terminator ) at position ${this.pos + i} [E10]`, 'ParserError10', this.pos + i, chr); } this.currentNode.isClosed = true; this.currentNode.endPos = this.pos + i; this.currentNode = this.currentNode.parentNode; checkSP(); break; // ] closes section group case ']': if (this.currentNode.type !== 'SECTION') { throw this.parserError(`Unexpected section terminator ] at position ${this.pos + i} [E11]`, 'ParserError11', this.pos + i, chr); } this.currentNode.isClosed = true; this.currentNode.endPos = this.pos + i; this.currentNode = this.currentNode.parentNode; checkSP(); break; // < starts a new partial byte range (e.g., BODY[]<0.1024>) case '<': // '<' is only a partial range marker when it immediately follows ']', // which occurs in BODY[section] responses. // In all other contexts, '<' is treated as the start of an ATOM. if (this.str.charAt(i - 1) !== ']') { this.currentNode = this.createNode(this.currentNode, this.pos + i); this.currentNode.type = 'ATOM'; startToken(chr); this.state = STATE_ATOM; } else { this.currentNode = this.createNode(this.currentNode, this.pos + i); this.currentNode.type = 'PARTIAL'; startToken(); this.state = STATE_PARTIAL; this.currentNode.isClosed = false; } break; // literal8 (RFC 3516): uses ~{size} prefix instead of {size} // literal8 allows binary data containing NUL bytes, unlike regular literals case '~': { let nextChr = this.str.charAt(i + 1); if (nextChr !== '{') { // '~' is an ATOM-CHAR itself, so when it does not start a literal8 // it starts an atom, including the one-character atom "~" that a // space, the end of the line or a closing delimiter ends if (imap_formal_syntax_js_1.default['ATOM-CHAR']().includes(nextChr) || nextChr === ' ' || nextChr === '' || closesNode(nextChr, this.currentNode)) { startAtom(); break; } throw this.parserError(`Unexpected literal8 marker at position ${this.pos + i} [E12]`, 'ParserError12', this.pos + i, chr); } // Mark the next literal as literal8 type; consumed when '{' is encountered this.expectedLiteralType = 'literal8'; break; } // { starts a new literal (regular {size}\r\n or literal8 ~{size}\r\n) case '{': this.currentNode = this.createNode(this.currentNode, this.pos + i); this.currentNode.type = 'LITERAL'; // Use literal8 type if '~' was seen immediately before, otherwise standard literal this.currentNode.literalType = this.expectedLiteralType || 'literal'; this.expectedLiteralType = false; this.state = STATE_LITERAL; this.currentNode.isClosed = false; break; // * starts a new sequence case '*': this.currentNode = this.createNode(this.currentNode, this.pos + i); this.currentNode.type = 'SEQUENCE'; startToken(chr); this.currentNode.isClosed = false; this.state = STATE_SEQUENCE; break; // normally a space should never occur case ' ': // just ignore break; // [ starts section case '[': // If it is the *first* element after response command, then process as a response argument list // Status responses (OK/NO/BAD/BYE/PREAUTH) use [code] for response codes if (['OK', 'NO', 'BAD', 'BYE', 'PREAUTH'].includes(this.parent.command.toUpperCase()) && this.currentNode === this.tree) { this.currentNode.endPos = this.pos + i; this.currentNode = this.createNode(this.currentNode, this.pos + i); this.currentNode.type = 'ATOM'; this.currentNode = this.createNode(this.currentNode, this.pos + i); this.currentNode.type = 'SECTION'; this.currentNode.isClosed = false; this.state = STATE_NORMAL; // RFC 2221 REFERRAL special case: the payload is an RFC 2192/RFC 5092 // IMAP URL (e.g., imap://user@host/mailbox) which contains characters // that would break normal ATOM parsing (colons, slashes, etc.). // We handle this by consuming everything up to ']' as a single ATOM value. if (this.str.substring(i + 1, i + 10).toUpperCase() === 'REFERRAL ') { // create the REFERRAL atom this.currentNode = this.createNode(this.currentNode, this.pos + i + 1); this.currentNode.type = 'ATOM'; this.currentNode.endPos = this.pos + i + 8; this.currentNode.value = 'REFERRAL'; this.currentNode = this.currentNode.parentNode; // eat all the way through the ] to be the IMAPURL token. this.currentNode = this.createNode(this.currentNode, this.pos + i + 10); // just call this an ATOM, even though IMAPURL might be more correct this.currentNode.type = 'ATOM'; // jump i to the ']' that closes the section. The URL itself can // hold a bracketed IPv6 host (imap://[::1]/INBOX), so brackets // opened inside the URL are matched before the closing one. let depth = 0; for (i = i + 10; i < this.str.length; i++) { let urlChr = this.str.charAt(i); if (urlChr === '[') { depth++; } else if (urlChr === ']' && depth-- === 0) { break; } } // A malformed REFERRAL with no closing ']' leaves i at the end of // the string, so the URL takes the rest of it. this.currentNode.endPos = this.pos + i - 1; this.currentNode.value = this.str.substring(this.currentNode.startPos - this.pos, this.currentNode.endPos - this.pos + 1); this.currentNode = this.currentNode.parentNode; // close out the SECTION this.currentNode.isClosed = true; this.currentNode = this.currentNode.parentNode; checkSP(); } break; } // Not a response code section: handled like any other atom start startAtom(); break; default: startAtom(); break; } break; case STATE_ATOM: // An atom is terminated by: space, closing delimiter of parent node, // a backslash starting the next flag of a list (broken servers only), // or encountering a '[' that starts a section for BODY/BINARY commands. // space finishes an atom if (chr === ' ') { this.currentNode.endPos = this.pos + i - 1; this.currentNode = this.currentNode.parentNode; this.state = STATE_NORMAL; break; } // ')' or ']' terminates the atom AND closes the enclosing LIST or SECTION if (closesNode(chr, this.currentNode.parentNode)) { this.currentNode.endPos = this.pos + i - 1; this.currentNode = this.currentNode.parentNode; this.currentNode.isClosed = true; this.currentNode.endPos = this.pos + i; this.currentNode = this.currentNode.parentNode; this.state = STATE_NORMAL; checkSP(); break; } // A backslash can not occur inside an atom (it is a quoted-special), so one // arriving inside a flag of a parenthesized list can only be a server that // wrote two flags without the separating space, as home.pl does in its LIST // responses ("\\Sent\\HasNoChildren"). Read it as the end of this flag and the // start of the next one rather than rejecting the line, which would drop the // whole mailbox. Only an atom that already is a flag is split, so an unquoted // name is never cut in two, and only when a flag name follows, so a trailing // backslash still fails as before instead of becoming a one-character flag if (chr === '\\') { const parent = this.currentNode.parentNode; const value = this.currentNode.value; const next = this.str.charAt(i + 1); if (parent && parent.type === 'LIST' && value.length > 1 && value.startsWith('\\') && next && (imap_formal_syntax_js_1.default['ATOM-CHAR']().includes(next) || next === '*')) { this.currentNode.endPos = this.pos + i - 1; this.currentNode = parent; startAtom(); break; } } // If the atom so far is all digits and we see ',' or ':', it is actually // a sequence set (e.g., "1:5" or "1,3,5"), so reclassify and switch state if ((chr === ',' || chr === ':') && tokenLength && tokenDigitsOnly) { this.currentNode.type = 'SEQUENCE'; this.currentNode.isClosed = true; this.state = STATE_SEQUENCE; } // [ starts a section group for this element // Allowed only for selected elements, otherwise falls through to regular ATOM processing if (chr === '[' && tokenLength <= 11 && ['BODY', 'BODY.PEEK', 'BINARY', 'BINARY.PEEK'].includes(this.currentNode.value.toUpperCase())) { this.currentNode.endPos = this.pos + i; this.currentNode = this.createNode(this.currentNode.parentNode, this.pos + i); this.currentNode.type = 'SECTION'; this.currentNode.isClosed = false; this.state = STATE_NORMAL; break; } // if the char is not ATOM compatible, throw. Allow \* as an exception if (!imap_formal_syntax_js_1.default['ATOM-CHAR']().includes(chr) && chr.charCodeAt(0) < 0x80 && // allow 8bit (presumably unicode) bytes chr !== ']' && !(chr === '*' && this.currentNode.value === '\\') && (!this.parent || !this.parent.command || !['NO', 'BAD', 'OK'].includes(this.parent.command.toUpperCase()))) { throw this.parserError(`Unexpected char at position ${this.pos + i} [E16: ${JSON.stringify(chr)}]`, 'ParserError16', this.pos + i, chr); } else if (this.currentNode.value === '\\*') { throw this.parserError(`Unexpected char at position ${this.pos + i} [E17: ${JSON.stringify(chr)}]`, 'ParserError17', this.pos + i, chr); } appendToToken(chr); break; case STATE_STRING: // DQUOTE ends the string sequence. Its value is taken from the input in one // piece, for the same reason tokens are sliced, see appendToToken() if (chr === '"') { let value = this.str.slice(stringStart, i); this.currentNode.value = stringEscaped ? value.replace(/\\([\s\S])/g, '$1') : value; this.currentNode.endPos = this.pos + i; this.currentNode.isClosed = true; this.currentNode = this.currentNode.parentNode; this.state = STATE_NORMAL; checkSP(); break; } // \ Escapes the following char, which is skipped here so it can not end the string if (chr === '\\') { i++; if (i >= len) { throw this.parserError(`Unexpected end of input at position ${this.pos + i} [E18]`, 'ParserError18', this.pos + i); } stringEscaped = true; } break; case STATE_PARTIAL: if (chr === '>') { if (tokenLast === '.') { throw this.parserError(`Unexpected end of partial at position ${this.pos + i} [E19]`, 'ParserError19', this.pos + i, chr); } this.currentNode.endPos = this.pos + i; this.currentNode.isClosed = true; this.currentNode = this.currentNode.parentNode; this.state = STATE_NORMAL; checkSP(); break; } if (chr === '.' && (!tokenLength || tokenHasDot)) { throw this.parserError(`Unexpected partial separator . at position ${this.pos + i} [E20]`, 'ParserError20', this.pos + i, chr); } if (!imap_formal_syntax_js_1.default.DIGIT().includes(chr) && chr !== '.') { throw this.parserError(`Unexpected char at position ${this.pos + i} [E21: ${JSON.stringify(chr)}]`, 'ParserError21', this.pos + i, chr); } if (tokenLast === '0' && (tokenLength === 1 || tokenPrev === '.') && chr !== '.') { throw this.parserError(`Invalid partial at position ${this.pos + i} [E22: ${JSON.stringify(chr)}]`, 'ParserError22', this.pos + i, chr); } appendToToken(chr); break; case STATE_LITERAL: if (this.currentNode.started) { // only relevant if literals are not already parsed out from input // Disabled NULL byte check // See https://github.com/emailjs/emailjs-imap-handler/commit/f11b2822bedabe492236e8263afc630134a3c41c /* if (chr === '\u0000') { throw new Error('Unexpected \\x00 at position ' + (this.pos + i)); } */ this.currentNode.chBuffer[this.currentNode.chPos++] = chr.charCodeAt(0); if (this.currentNode.chPos >= this.currentNode.literalLength) { this.currentNode.endPos = this.pos + i; this.currentNode.isClosed = true; this.currentNode.value = this.currentNode.chBuffer.toString('binary'); this.currentNode.chBuffer = Buffer.alloc(0); this.currentNode = this.currentNode.parentNode; this.state = STATE_NORMAL; checkSP(); } break; } if (chr === '+' && this.options.literalPlus) { this.currentNode.literalPlus = true; break; } if (chr === '}') { if (!('literalLength' in this.currentNode)) { throw this.parserError(`Unexpected literal prefix end char } at position ${this.pos + i} [E23]`, 'ParserError23', this.pos + i, chr); } if (this.str.charAt(i + 1) === '\n') { i++; } else if (this.str.charAt(i + 1) === '\r' && this.str.charAt(i + 2) === '\n') { i += 2; } else { throw this.parserError(`Unexpected char at position ${this.pos + i} [E24: ${JSON.stringify(chr)}]`, 'ParserError24', this.pos + i, chr); } this.currentNode.literalLength = Number(this.currentNode.literalLength); if (!this.currentNode.literalLength) { // special case where literal content length is 0 // close the node right away, do not wait for additional input if (this.options.literals) { // consume the queue entry of the {0} marker too, so later // literals in the same response stay aligned with their markers this.currentNode.value = takeLiteral(this.options.literals); } this.currentNode.endPos = this.pos + i; this.currentNode.isClosed = true; this.currentNode = this.currentNode.parentNode; this.state = STATE_NORMAL; checkSP(); } else if (this.options.literals) { // use the next precached literal values this.currentNode.value = takeLiteral(this.options.literals); // only APPEND arguments are kept as Buffers /* if ((this.parent.command || '').toString().toUpperCase() !== 'APPEND') { this.currentNode.value = this.currentNode.value.toString('binary'); } */ this.currentNode.endPos = this.pos + i + this.currentNode.value.length; this.currentNode.started = false; this.currentNode.isClosed = true; this.currentNode = this.currentNode.parentNode; this.state = STATE_NORMAL; checkSP(); } else { // No pre-parsed literal buffers were supplied, so the literal is read // inline from this input and its declared length decides an // allocation. ImapStream always supplies buffers (and has already // enforced its own cap), so this branch means the parser is being used // directly and the declared length is untrusted: bound it before // allocating anything. // Two bounds apply: the configured maximum, and the bytes actually // available here - an inline literal has to be present in the input // being parsed, so a longer declaration can never be satisfied and // must not reserve memory for itself. let available = this.str.length - i - 1; let literalLength = this.currentNode.literalLength; if (literalLength > this.maxLiteralSize || literalLength > available) { let overMax = literalLength > this.maxLiteralSize; let error = (0, limits_js_1.createLiteralTooLargeError)(literalLength, overMax ? this.maxLiteralSize : available, overMax ? null : `the ${available} bytes available in the input`); error.parserContext = { ...(0, limits_js_1.boundedInput)(this.str), pos: this.pos + i, chr }; throw error; } this.currentNode.started = true; // Allocate expected size buffer. // Maybe should use allocUnsafe instead? this.currentNode.chBuffer = Buffer.alloc(this.currentNode.literalLength); this.currentNode.chPos = 0; } break; } if (!imap_formal_syntax_js_1.default.DIGIT().includes(chr)) { throw this.parserError(`Unexpected char at position ${this.pos + i} [E25: ${JSON.stringify(chr)}]`, 'ParserError25', this.pos + i, chr); } if (this.currentNode.literalLength === '0') { throw this.parserError(`Invalid literal at position ${this.pos + i} [E26]`, 'ParserError26', this.pos + i, chr); } this.currentNode.literalLength = (this.currentNode.literalLength || '') + chr; break; case STATE_SEQUENCE: { // A space ends the sequence set, and so does the closing delimiter of the // enclosing list or section ("PARTIAL (1:100 5,7,9)", "[COPYUID 1 1:3 4,5]") let closesParent = closesNode(chr, this.currentNode.parentNode); if (chr === ' ' || closesParent) { if (!RE_SINGLE_DIGIT.test(tokenLast) && tokenLast !== '*') { if (!tokenHasStar) { // a dangling separator, "10:" or "1," continueAsAtom(); break; } throw this.parserError(`Unexpected end of sequence at position ${this.pos + i} [E27: ${JSON.stringify(chr)}]`, 'ParserError27', this.pos + i, chr); } if (this.currentNode.value !== '*' && tokenLast === '*' && tokenPrev !== ':') { throw this.parserError(`Unexpected end of sequence at position ${this.pos + i} [E28: ${JSON.stringify(chr)}]`, 'ParserError28', this.pos + i, chr); } this.currentNode.isClosed = true; this.currentNode.endPos = this.pos + i - 1; this.currentNode = this.currentNode.parentNode; this.state = STATE_NORMAL; if (closesParent) { this.currentNode.isClosed = true; this.currentNode.endPos = this.pos + i; this.currentNode = this.currentNode.parentNode; checkSP(); } break; } if (chr === ':') { if (!RE_SINGLE_DIGIT.test(tokenLast) && tokenLast !== '*') { if (!tokenHasStar) { continueAsAtom(); break; } throw this.parserError(`Unexpected range separator : at position ${this.pos + i} [E29]`, 'ParserError29', this.pos + i, chr); } } else if (chr === '*') { if (![',', ':'].includes(tokenLast)) { throw this.parserError(`Unexpected range wildcard at position ${this.pos + i} [E30]`, 'ParserError30', this.pos + i, chr); } } else if (chr === ',') { if (!RE_SINGLE_DIGIT.test(tokenLast) && tokenLast !== '*') { if (!tokenHasStar) { continueAsAtom(); break; } throw this.parserError(`Unexpected sequence separator , at position ${this.pos + i} [E31]`, 'ParserError31', this.pos + i, chr); } if (tokenLast === '*' && tokenPrev !== ':') { throw this.parserError(`Unexpected sequence separator , at position ${this.pos + i} [E32]`, 'ParserError32', this.pos + i, chr); } } else if (!RE_SINGLE_DIGIT.test(chr)) { if (!tokenHasStar) { continueAsAtom(); break; } throw this.parserError(`Unexpected char at position ${this.pos + i} [E33: ${JSON.stringify(chr)}]`, 'ParserError33', this.pos + i, chr); } if (RE_SINGLE_DIGIT.test(chr) && tokenLast === '*') { throw this.parserError(`Unexpected number at position ${this.pos + i} [E34: ${JSON.stringify(chr)}]`, 'ParserError34', this.pos + i, chr); } appendToToken(chr); break; } } } // the same applies to a digit-led token that ends the input on a dangling separator ("10:") if (this.state === STATE_SEQUENCE && (tokenLast === ':' || tokenLast === ',') && !tokenHasStar) { this.currentNode.type = 'ATOM'; } // A star-led sequence set ("*:4") is only closed by a space or a closing delimiter, so one // that ends the input is closed here, under the same rules a space applies to it. A bare "*" // is left to getAttributes(), which reads it as an atom if (this.state === STATE_SEQUENCE && !this.currentNode.isClosed && (RE_SINGLE_DIGIT.test(tokenLast) || (tokenLast === '*' && tokenPrev === ':'))) { this.currentNode.isClosed = true; this.currentNode.endPos = this.pos + this.str.length - 1; } } } exports.TokenParser = TokenParser;