Rust in Everything
基于 pulldown-cmark 0.13.5

旧版本上几百 KB 的输入要解析好几秒

主线位置:三处都在解析器里,两处在块主线的第一遍(scan_containers 和段落里的脚注探测),一处在行内主线的分隔符栈。pulldown-cmark 怎么系统地防超线性(下界、上限、dos-fuzzer)见设计取舍;本页只讲 0.13.5 补上的三处,以及用旧版本时该怎么办。

flowchart TB
  in["不可信的 Markdown"] --> v{"版本"}
  v -->|0.13.4 及更早| q["三类输入平方增长"]
  v -->|0.13.5| l["线性"]
  q --> s["1 MB 要几秒到几十秒"]
  s --> up["cargo update 到 0.13.5"]
  up --> l
  l --> lim["仍然限制输入大小"]

实验 crate:exp-pulldown-cmark/pitb-quad(依赖 =0.13.5)和 pitb-quad134(依赖 =0.13.4),同一份 main.rs,只计 Parser::new_ext(..).count(),不生成 HTML。M2 Max、macOS 27、rustc 1.99.0,release。0.13.5 每项 5 次取最小;0.13.4 每项 3 次取最小,1 MB 的两项只跑 1 次。运行前后 uptime 的负载是 18.3 和 14.4(同机有其他实验),绝对时间偏慢,两个版本之间的倍数不受影响。原始输出在 pitb-quad/out.txt,脚本是 pitb-quad/run.sh。

现象

0.13.5 是从 branch_0.13 发的补丁版本,摘进了 main 上的 8 个 PR,其中 3 个修平方复杂度(怎么读这一卷)。三类输入都不需要什么特殊构造,只是某种结构重复很多次:

  • 嵌套列表后接空行(PR #1148):一行里很多层列表标记,后面跟同样多的空行;放在引用块里(空行写成 >)也一样。不需要任何扩展。
  • 只能开的 * 和只能闭的 _ 交替(PR #1155):一长行里反复出现这对分隔符。不需要任何扩展。
  • 开了脚注时,很多行以 [^ 开头(PR #1136):要 ENABLE_FOOTNOTES。

同一批输入在两个版本上的解析时间:

输入字节0.13.40.13.5倍数
嵌套列表 + 空行32,768295.7 ms0.78 ms378
65,5371,196.1 ms1.38 ms870
131,0724,932.9 ms3.09 ms1,599
引用块里的同上32,772174.5 ms0.54 ms323
65,540695.1 ms1.06 ms655
131,0762,758.1 ms2.32 ms1,189
* 与 _ 交替65,53598.5 ms1.62 ms61
262,1431,430.1 ms6.66 ms215
1,048,57525,807.5 ms27.2 ms947
行首 [^,开脚注65,53516.5 ms1.65 ms10
262,140235.1 ms6.43 ms37
1,048,5756,795.9 ms37.1 ms183

原始输出的前几行(每对里先是 0.13.4、后是 0.13.5):

 2:02  up 3 days,  7:26, 8 users, load averages: 18.33 18.25 12.13
lists      bytes=   32768 events=  43689 min_of_3=   295.743 ms
lists      bytes=   32768 events=  43689 min_of_5=     0.783 ms
lists      bytes=   65537 events=  87381 min_of_3=  1196.095 ms
lists      bytes=   65537 events=  87381 min_of_5=     1.375 ms
lists      bytes=  131072 events= 174761 min_of_3=  4932.905 ms
lists      bytes=  131072 events= 174761 min_of_5=     3.085 ms
...
underscore bytes= 1048575 events=1048576 min_of_1= 25807.482 ms
underscore bytes= 1048575 events=1048576 min_of_5=    27.243 ms
...
  • 0.13.4 上,长度翻倍,前两类时间约乘 4;长度乘 4,后两类时间乘 14 到 29。都是平方的样子。
  • 0.13.5 上,长度翻倍,时间约翻倍。
  • 两个版本的事件数完全相同,修复没有改变解析结果。

一个 128 KB 的嵌套列表在 0.13.4 上要 5 秒,1 MB 的 * / _ 行要 26 秒。解析是同步的 CPU 计算,期间这个线程什么也做不了。

原因

三处的机制分别在前面讲过,这里只列出是哪一步重复做了:

PR哪里0.13.4 每次重复做什么0.13.5 怎么改详见
#1148块主线 scan_containers每个空行都沿 spine 把所有打开的列表走一遍,N 层 × N 行列表上记 LAST_LINE_EMPTY(ListFlags),连续第二个空行直接返回容器块
#1155行内主线 InlineStack::set_lowerbound只能闭的 _ 没找到开分隔符时只抬高一个下界,下一个 _ 取到的仍是 0,整栈重扫两个下界一起抬高,和 * 一致强调与分隔符栈
#1136块主线 scan_paragraph_interrupt_no_table段落里每个 [^ 行都对剩余的整份输入做一次 UTF-8 校验参数从 &[u8] 换成 &str,直接用文档本身,不再校验链接与图片

三个 PR 都先合进 main,再由 a126502、6da8248、cf4ee5f 三个「cherry-pick pull request #NNNN commits into 0.13 branch」提交摘进 0.13 分支,首次发布于 0.13.5。每个 PR 还把对应的输入加进了 dos-fuzzer 的回归列表:用 0.13.4 跑那份列表,*_ 一项被判为超线性,退出码 1(设计取舍)。

更早的版本本卷没有测(离线缓存里只有 0.13.4 和 0.13.5)。从代码看:#1136 修的那段探测来自 GFM 风格脚注(PR #654),#1155 修的那个按长度分桶的下界来自 PR #748,两者都首次发布在 0.10.0;#1148 修的空行处理在 0.13.4 里就是逐层走 spine,引入时间本卷没有考证。

修法

升级到 0.13.5。 它和 0.13.4 同属 0.13,Cargo.toml 写的是 "0.13" 或 "0.13.4"(默认的 ^ 要求)时,cargo update 就会选到它:

$ cargo update --offline -p pulldown-cmark
     Locking 1 package to highest compatible version
    Updating pulldown-cmark v0.13.4 -> v0.13.5

(pitb-upgrade 里先用 --precise 0.13.4 锁回旧版再更新;--offline 只看本机缓存,联网时会选当时最新的 0.13.x。)两个版本之间 src/ 只改了 firstpass.rs 和 parse.rs,没有新增或删除公开项;另外 5 个 PR 修了两处 panic 和两处解析结果(怎么读这一卷列出了 8 个 PR 各改了什么),升级前可以对照一下自己用到的扩展。如果依赖写的是 =0.13.4 这样的精确版本,要先改 Cargo.toml。

不要只靠升级。 这三个是已知的、已修的;dos-fuzzer 只能找到它能拼出来的模式。解析不可信输入时:

  • 限制输入大小。上面最坏的一项,0.13.4 在 32 KB 时是 0.3 秒、128 KB 时是 5 秒;平方增长下,上限压低一半,最坏时间降到四分之一。
  • 解析放在不会挡住别人的地方。在异步服务里,大文档的解析放进专门的阻塞线程池,而不是直接在处理请求的任务里跑。

怎么检查

  • 看锁定的版本: cargo tree -i pulldown-cmark --depth 0,或在 Cargo.lock 里找 name = "pulldown-cmark"。低于 0.13.5 并且会解析外来 Markdown(评论、上传的文档、第三方 README),就该升级。间接依赖也算:cargo tree -i 会列出是谁把它带进来的。
  • 在 CI 里放一道门: 照本页的做法,为这三类结构各生成 64 KB 和 256 KB 两个输入,断言两者的时间比不超过某个倍数。本页的数据里,从 64 KB 到 256 KB,0.13.4 的两类输入是 14.5 倍和 14.2 倍,0.13.5 是 4.1 倍和 3.9 倍,阈值取 8 能分开两者。门要反向验证一次:把依赖临时换回 0.13.4,确认它会红。上游的 dos-fuzzer --regressions 做的就是这件事,覆盖 31 个已知模式,跑一遍不到一秒(设计取舍)。

小结

  • 0.13.5 摘进了三个超线性修复:深嵌套列表后的空行(#1148)、只能闭的 _ 的下界(#1155)、开脚注时每行 [^ 的 UTF-8 校验(#1136)。前两个不需要任何扩展。
  • 实测同一批输入,0.13.4 平方增长:128 KB 的嵌套列表 4.9 秒,1 MB 的 * / _ 行 25.8 秒;0.13.5 线性,都在 40 毫秒以内,事件数相同。
  • 解析不可信 Markdown 的程序升级到 0.13.5(cargo update -p pulldown-cmark),同时限制输入大小、别在请求处理的任务里直接解析大文档。
  • 用 cargo tree -i pulldown-cmark 看版本;CI 里用两种长度的时间比做门,并用旧版本验证它会红。

💬 关联讨论

发起讨论
加载中...