Browse Source

Polish #1209 and limit smartquotes stack to 1000 openers

master
Vitaly Puzrin 4 weeks ago
parent
commit
cf27366035
  1. 74
      src/rules_core/smartquotes.ts
  2. 1
      test/markdown-it/pathological.test.mjs

74
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<string, Replacement[]>
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<number, { single: number, double: number }>()
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)
}

1
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 })
})
})
})

Loading…
Cancel
Save