diff --git a/src/rules_core/smartquotes.ts b/src/rules_core/smartquotes.ts index 3b284fb..b5a1435 100644 --- a/src/rules_core/smartquotes.ts +++ b/src/rules_core/smartquotes.ts @@ -8,6 +8,8 @@ import type StateCore from './state_core.ts' const QUOTE_TEST_RE = /['"]/ const QUOTE_RE = /['"]/g const APOSTROPHE = '\u2019' /* ’ */ +// Caps memory on malicious input +const MAX_OPENERS = 1000 interface Replacement { pos: number @@ -17,11 +19,29 @@ interface Replacement { type ReplacementMap = Record interface QuoteOpener { - token: number - pos: number - single: boolean + tokenIdx: number + contentPos: number + isSingleQuote: boolean level: number - prevSame: number + // stack index of the previous opener of the same quote type, -1 if none + prevSameQuoteIdx: number +} + +// stack indexes of topmost openers of each type (-1 if none) +interface QuoteHeads { + single: number + double: number +} + +function truncateStack (stack: QuoteOpener[], heads: QuoteHeads, length: number) { + while (stack.length > length) { + const item = stack.pop()! + if (item.isSingleQuote) { + heads.single = item.prevSameQuoteIdx + } else { + heads.double = item.prevSameQuoteIdx + } + } } function addReplacement ( @@ -57,20 +77,10 @@ function process_inlines (tokens: Token[], state: StateCore) { let j const stack: QuoteOpener[] = [] - const heads = new Map() + const heads: QuoteHeads = { single: -1, double: -1 } // token index -> list of replacements in the original token content const replacements: ReplacementMap = {} - function truncateStack (length: number) { - // Each opener is removed at most once - while (stack.length > length) { - const item = stack.pop()! - const head = heads.get(item.level)! - head[item.single ? 'single' : 'double'] = item.prevSame - if (head.single === -1 && head.double === -1) heads.delete(item.level) - } - } - for (let i = 0; i < tokens.length; i++) { const token = tokens[i] @@ -79,7 +89,7 @@ function process_inlines (tokens: Token[], state: StateCore) { for (j = stack.length - 1; j >= 0; j--) { if (stack[j].level <= thisLevel) { break } } - truncateStack(j + 1) + truncateStack(stack, heads, j + 1) if (token.type !== 'text') { continue } @@ -183,9 +193,10 @@ function process_inlines (tokens: Token[], state: StateCore) { } if (canClose) { - // Index by level and type - j = heads.get(thisLevel)?.[isSingle ? 'single' : 'double'] ?? -1 - if (j >= 0) { + // Stack levels never decrease, so an opener of this type below + // the current level means there is no match + j = isSingle ? heads.single : heads.double + if (j >= 0 && stack[j].level === thisLevel) { const item = stack[j] let openQuote @@ -199,28 +210,27 @@ function process_inlines (tokens: Token[], state: StateCore) { } addReplacement(replacements, i, t.index, closeQuote) - addReplacement(replacements, item.token, item.pos, openQuote) + addReplacement(replacements, item.tokenIdx, item.contentPos, openQuote) - truncateStack(j) + truncateStack(stack, heads, j) continue OUTER } } if (canOpen) { - let head = heads.get(thisLevel) - if (!head) { - head = { single: -1, double: -1 } - heads.set(thisLevel, head) - } - const kind = isSingle ? 'single' : 'double' + if (stack.length >= MAX_OPENERS) { return } stack.push({ - token: i, - pos: t.index, - single: isSingle, + tokenIdx: i, + contentPos: t.index, + isSingleQuote: isSingle, level: thisLevel, - prevSame: head[kind] + prevSameQuoteIdx: isSingle ? heads.single : heads.double }) - head[kind] = stack.length - 1 + if (isSingle) { + heads.single = stack.length - 1 + } else { + heads.double = stack.length - 1 + } } else if (canClose && isSingle) { addReplacement(replacements, i, t.index, APOSTROPHE) } diff --git a/test/markdown-it/pathological.test.mjs b/test/markdown-it/pathological.test.mjs index 2705d2e..10f2d3a 100644 --- a/test/markdown-it/pathological.test.mjs +++ b/test/markdown-it/pathological.test.mjs @@ -167,6 +167,7 @@ describe('Pathological sequences speed', () => { it('smartquotes with mismatched opener and closer types', async () => { await test_pattern('"a '.repeat(100000) + "b' ".repeat(100000), { typographer: true }) + await test_pattern("'a ".repeat(100000) + 'b" '.repeat(100000), { typographer: true }) }) }) })