Browse Source

fix(smartquotes): keep converting quotes after the opener cap is hit

The opener stack is capped at MAX_OPENERS (1000) to bound memory on
malicious input. Reaching the cap returned from process_inlines(),
which abandoned the whole inline token stream: every replacement
collected so far was dropped, and no quote later in the paragraph was
converted.

A document only has to contain 1000 unmatched opening quotes before an
otherwise ordinary quote or apostrophe stops being typographed, and the
paragraph's already-resolved pairs revert to straight quotes too.

Stop pushing new openers past the cap instead of returning. Pairs that
resolved earlier keep their replacements, apostrophes further on are
still converted, and the memory bound is unchanged: the stack still
never exceeds MAX_OPENERS entries.

Timing on the existing pathological patterns is unchanged and stays
linear (200k unmatched openers: ~53ms, 4x input -> ~3.5x time).
pull/1211/head
MFA-G 4 weeks ago
parent
commit
fb67181444
  1. 28
      src/rules_core/smartquotes.ts
  2. 15
      test/markdown-it/misc.test.mjs

28
src/rules_core/smartquotes.ts

@ -218,18 +218,22 @@ function process_inlines (tokens: Token[], state: StateCore) {
}
if (canOpen) {
if (stack.length >= MAX_OPENERS) { return }
stack.push({
tokenIdx: i,
contentPos: t.index,
isSingleQuote: isSingle,
level: thisLevel,
prevSameQuoteIdx: isSingle ? heads.single : heads.double
})
if (isSingle) {
heads.single = stack.length - 1
} else {
heads.double = stack.length - 1
// Stop tracking new openers past the cap, but keep processing the
// rest of the text: replacements already found stay valid, and
// apostrophes further on are still converted.
if (stack.length < MAX_OPENERS) {
stack.push({
tokenIdx: i,
contentPos: t.index,
isSingleQuote: isSingle,
level: thisLevel,
prevSameQuoteIdx: isSingle ? heads.single : heads.double
})
if (isSingle) {
heads.single = stack.length - 1
} else {
heads.double = stack.length - 1
}
}
} else if (canClose && isSingle) {
addReplacement(replacements, i, t.index, APOSTROPHE)

15
test/markdown-it/misc.test.mjs

@ -478,6 +478,21 @@ describe('smartquotes', function () {
'<p>[[[a <em>b (((((c <em>d</em> e)))) f</em> g]]</p>\n'
)
})
it('Should keep converting quotes after the opener limit is reached', function () {
const mdDefaultQuotes = markdownit({ typographer: true })
// The opener stack is capped at 1000 entries to bound memory. Reaching
// that cap must not abandon the rest of the paragraph.
const src = '"paired" ' + '"unmatched '.repeat(1100) + "isn't it"
const rendered = mdDefaultQuotes.render(src)
// A pair resolved before the cap keeps its replacement.
assert.ok(rendered.includes('\u201cpaired\u201d'))
// An apostrophe found after the cap is still converted.
assert.ok(rendered.includes('isn\u2019t'))
})
})
describe('Ordered list info', function () {

Loading…
Cancel
Save