Rust in Everything
基于 pulldown-cmark 0.13.5

引用链接静默变成了文字

主线位置:行内主线,链接那一趟。每个 [文字][标签]、![描述][标签] 都经 fetch_link_type_url_title 查定义,每查到一次就从展开预算里扣掉地址和标题的长度;预算为什么存在、三个库在这件事上怎么不同,见链接与图片和设计取舍。

flowchart TB
  ref["引用链接"] --> zero{"预算为 0"}
  zero -->|否| look["查定义"]
  look --> sub["扣掉地址长度"]
  sub --> link["成为链接"]
  zero -->|是| none["返回 None"]
  none --> text["方括号留作文字"]
  none --> nocb["回调不调"]

实验在 exp-pulldown-cmark/pita-lab,bin p11_budget(release),输出存在 out-p11.txt。M2 Max、macOS 27、rustc 1.99.0,pulldown-cmark 0.13.5,Options::empty()。

现象

一篇文档在五个小节里用同一个图标,图标是一个 30 000 字节的 data:image/png;base64,...,写成引用式图片,定义放在文末;最后还有一个普通的引用链接:

第 1 节 ![图标][icon]

(第 2 到第 5 节相同)

详见[文档][docs]。

[icon]: data:image/png;base64,AAAA...(共 30 000 字节)
[docs]: https://example.com/docs

数一下事件里的图片和链接,同时挂一个 BrokenLinkCallback 数它被调了几次:

input 30202 B, budget 100000 B: images 4/5, links 0/1, callback calls 0

push_html 输出里不含 <img 的几行:

<p>第 5 节 ![图标][icon]</p>
<p>详见[文档][docs]。</p>

第五个图标成了文字 ![图标][icon];和图标毫无关系的 [文档][docs],地址只有 25 字节,也成了文字。没有报错,回调一次也没被调用。

原因

预算在 Parser::new_with_broken_link_callback 里设成 text.len().max(100_000),这份文档 30 202 字节,预算是 100 000。fetch_link_type_url_title 一开头先看预算:

        if self.link_ref_expansion_limit == 0 {
            return None;
        }
        // ...
        self.link_ref_expansion_limit = self
            .link_ref_expansion_limit
            .saturating_sub(url.len() + title.len());

按顺序算:100 000、70 000、40 000、10 000,前四个图标都查到了定义;第四个扣完饱和成 0(检查在扣之前,所以越过 0 的那一个仍然成立)。第五个图标和后面的 [docs] 一进函数就返回 None:定义不查,BrokenLinkCallback 也不调,因为调回调的 or_else 在这个 return 之后。调用方(parse.rs 第 821 行起)拿到 None,方括号按普通文字处理。

几个由此推出的性质:

  • 预算是整份文档共用的。 一个长地址耗尽了预算,后面所有引用链接一起失效,不管它们的地址多短。
  • 只有引用式的才扣。 内联的 ![图标](data:...) 不经过这个函数;地址本来就写在源文里,再长也只是线性的。
  • 没有开关。 link_ref_expansion_limit 是 Parser 的私有字段,Options 里没有对应的位,调不大也关不掉。tag 之后 main 把它挪进了内部结构 ParserInner,初值和检查都没变。

修法

长地址写成内联的。 同一份内容,五个图标都写成 ![图标](data:...),删掉 [icon] 定义:

input 150173 B, budget 150173 B: images 5/5, links 1/1, callback calls 0

输入大了五倍(地址重复了五次),但五个图标和 [docs] 都成立了;内联图片一个字节的预算都不用。

更好的办法是不把大文件塞进地址:图标放成文件,引用一个短地址,引用多少次都远用不完 100 000 字节。对用户输入,这个预算是保护:它挡住的正是「定义一个超长地址、引用几千次」把输出撑到平方级大小的攻击(设计取舍里有三个库的对照)。这时要做的不是绕过,而是知道它发生了。

怎么检查

回调不会被调,只能在事件流上找:合并文字之后,凡是 [标签] 的标签在 reference_definitions() 里查得到,却仍然是文字的,就是本该成立而没成立的引用:

fn leftover_refs(doc: &str) {
  let parser = Parser::new(doc);
  let defs = parser.reference_definitions();
  let mut hits = Vec::new();
  for (ev, r) in TextMergeWithOffset::new(Parser::new(doc).into_offset_iter()) {
    if let Event::Text(t) = ev {
      let mut rest: &str = &t;
      while let Some(open) = rest.find('[') {
        let Some(len) = rest[open..].find(']') else { break };
        let label = &rest[open + 1..open + len];
        if defs.get(label).is_some() {
          hits.push(format!("{r:?} [{label}]"));
        }
        rest = &rest[open + len..];
      }
    }
  }
  println!("leftover references: {hits:?}");
}
leftover references: ["108..133 [icon]", "135..158 [docs]"]

RefDefs::get 按大小写折叠查,和解析器的规则一致。这个检查会误报写成 \[docs\] 的转义文字(转义后的文本里也是 [docs]),报出来再看一眼源文即可。也可以粗算:把每个引用式链接、图片对应定义的地址加标题长度加起来,总和超过 max(文档长度, 100000),后面就会有引用丢失。

小结

  • 引用链接和引用式图片共用一份展开预算 max(文档长度, 100000) 字节;用完之后,后面所有引用都留作方括号文字,BrokenLinkCallback 也不调。
  • 一个长 data URI 引用几次就能耗尽预算,连带后面毫不相干的短链接一起失效;预算没有开关。
  • 修法:长地址写成内联链接或换成短地址;用 reference_definitions() 扫描合并后文本里残留的引用,确认没有链接被静默丢掉。

💬 关联讨论

发起讨论
加载中...