Skip to content

Commit e264eca

Browse files
author
honor00
committed
fix: Inline footnote parsing does O
Inline footnote parsing does O(n) list membership per reference (quadratic DoS on n footnote references) Defect id: footnote-defs-membership-quadratic Verified against upstream tag v3.3.4 and HEAD (triage: unfixed_at_head). Generated-by: blackhole-agent upstream-publication plane (autonomous stewardship mission)
1 parent 75cab78 commit e264eca

3 files changed

Lines changed: 81 additions & 1 deletion

File tree

src/mistune/plugins/footnotes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def parse_inline_footnote(inline: "InlineParser", m: Match[str], state: "InlineS
3838
if not indexes:
3939
indexes = {note_key: index for index, note_key in enumerate(notes)}
4040
state.env["footnote_indexes"] = indexes
41-
if key not in notes:
41+
if key not in indexes:
4242
notes.append(key)
4343
indexes[key] = len(notes) - 1
4444
state.env["footnotes"] = notes

tests/footnote_defs_quadratic.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Synthesized standalone repro for the footnote_defs defect (complexity).
2+
3+
Discovered autonomously by blackhole_agent.upstream_discovery. Runs a doubling
4+
ladder against the source tree given as argv[1]; exits 1 while the defect is
5+
present, 0 once repaired. Usage: python <this file> <path-to-src-dir>
6+
"""
7+
import json, math, sys, time
8+
9+
sys.path.insert(0, sys.argv[1])
10+
PLUGINS = ['footnotes']
11+
KIND = 'complexity'
12+
EXPONENT_THRESHOLD = 1.75
13+
TIME_FLAG_FLOOR = 0.3
14+
15+
def gen(n):
16+
refs = ''.join('[^%d] ' % i for i in range(n))
17+
defs = '\n'.join('[^%d]: x' % i for i in range(n))
18+
return refs + '\n\n' + defs
19+
20+
import mistune
21+
22+
_RENDERERS = {}
23+
24+
def render(text, plugins):
25+
key = tuple(plugins)
26+
md = _RENDERERS.get(key)
27+
if md is None:
28+
md = mistune.create_markdown(plugins=list(plugins))
29+
_RENDERERS[key] = md
30+
md(text)
31+
32+
render('warmup', PLUGINS)
33+
n = 16000
34+
times = []
35+
crashed = None
36+
limit = max(16000 * 4, 16000 + 1)
37+
while n <= limit:
38+
text = gen(n)
39+
t0 = time.perf_counter()
40+
try:
41+
render(text, PLUGINS)
42+
elapsed = time.perf_counter() - t0
43+
except Exception as e:
44+
crashed = type(e).__name__
45+
break
46+
times.append((n, elapsed))
47+
if elapsed >= 1.0 and len(times) >= 2:
48+
# Always measure at least two sizes: one load-inflated run must not
49+
# end the ladder before any growth pair exists.
50+
break
51+
n *= 2
52+
53+
if crashed is not None:
54+
print(json.dumps({'defect': True, 'kind': KIND, 'crash': crashed}))
55+
sys.exit(1)
56+
worst = 0.0
57+
for (n1, t1), (n2, t2) in zip(times, times[1:]):
58+
if t1 >= 0.02 and n2 > n1:
59+
worst = max(worst, math.log2(max(t2, 1e-9) / t1) / math.log2(n2 / n1))
60+
t_max = max((t for _, t in times), default=0.0)
61+
defect = worst >= EXPONENT_THRESHOLD and t_max >= TIME_FLAG_FLOOR
62+
print(json.dumps({'defect': defect, 'kind': KIND, 'exponent': round(worst, 3), 't_max': round(t_max, 4)}))
63+
sys.exit(1 if defect else 0)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""Regression test synthesized by blackhole_agent.upstream_contribution.
2+
3+
Runs the minimized standalone repro for defect footnote-defs-membership-quadratic against the
4+
patched source tree; the repro exits 0 only once the defect is repaired.
5+
"""
6+
7+
import subprocess
8+
import sys
9+
from pathlib import Path
10+
11+
REPRO = Path(__file__).resolve().parent / 'footnote_defs_quadratic.py'
12+
SRC = Path(__file__).resolve().parents[1] / 'src'
13+
14+
15+
def test_footnote_defs_membership_quadratic_regression() -> None:
16+
proc = subprocess.run([sys.executable, str(REPRO), str(SRC)], capture_output=True, text=True)
17+
assert proc.returncode == 0, proc.stderr[-400:]

0 commit comments

Comments
 (0)