Rust in Everything
基于 pulldown-cmark-escape 0.11.0

转义

主线位置:输出主线的最后一步。HTML 写出器把标签写成固定的字符串,凡是来自文档内容的字符串(文本、链接地址、标题属性、图片描述)都先经过这里的三个函数之一;只有 Html 和 InlineHtml 两种事件跳过转义,原样写出。

flowchart TB
  w["HtmlWriter"] --> body["Text 与 Code"]
  w --> attr["title alt id 等"]
  w --> url["href 与 src"]
  w --> raw["Html 事件"]
  body --> ebt["body_text"]
  attr --> eh["escape_html"]
  url --> ehr["escape_href"]
  raw --> out["原样写出"]
  ebt --> tbl["256 项查表"]
  eh --> tbl
  ehr --> safe["HREF_SAFE 表"]
  tbl --> simd{"x86_64 且 simd"}
  simd -->|是| sse["SSSE3 16 字节"]
  simd -->|否| scalar["逐字节查表"]

转义在单独发布的 crate pulldown-cmark-escape 里,tag v0.13.5 上是 0.11.0,src/lib.rs 一个文件 470 行(含测试)。公开的东西只有三个函数、StrWrite 和两个包装类型(后三者见 HTML 写出器),唯一的 feature 是 simd。pulldown-cmark 的 html feature 依赖它,关掉 html 就不编译它。

实验在 exp-pulldown-cmark/output-probe(行为)和 exp-pulldown-cmark/output-simd(计时)里,平台 M2 Max、macOS 27、rustc 1.99.0,输出原样保存在各自的 out/ 目录。

三个函数各转义什么

函数转义的字节写成写出器用在
escape_html_body_text& < >&amp; &lt; &gt;Text、Code
escape_html上面三个加 " '加 &quot; &#39;链接和图片的 title、图片 alt、标题的 id / class / 属性、代码块语言、脚注名、数学
escape_href不在 HREF_SAFE 里的字节& 写 &amp;,' 写 &#x27;,其余 %XX链接 href、图片 src

同四个字符串分别交给三个函数(output-probe escape):

in        <a href="x">'&'</a>
html      &lt;a href=&quot;x&quot;&gt;&#39;&amp;&#39;&lt;/a&gt;
body_text &lt;a href="x"&gt;'&amp;'&lt;/a&gt;
href      %3Ca%20href=%22x%22%3E&#x27;&amp;&#x27;%3C/a%3E

in        a b"c'd<e>f&g
html      a b&quot;c&#39;d&lt;e&gt;f&amp;g
body_text a b"c'd&lt;e&gt;f&amp;g
href      a%20b%22c&#x27;d%3Ce%3Ef&amp;g

in        /中文 path?q=1&r=[2]#frag
html      /中文 path?q=1&amp;r=[2]#frag
body_text /中文 path?q=1&amp;r=[2]#frag
href      /%E4%B8%AD%E6%96%87%20path?q=1&amp;r=%5B2%5D#frag

in        %E4%B8%AD`{|}\^~
html      %E4%B8%AD`{|}\^~
body_text %E4%B8%AD`{|}\^~
href      %E4%B8%AD%60%7B%7C%7D%5C^~

三个函数都不分配:它们把输入切成「不用转义的一段」和「替换串」交替写进 StrWrite,写出器的输出目标是什么就直接写到哪。

正文为什么不转引号

在 HTML 的文本节点里,能开始一段标记的只有 <(标签)和 &(字符引用);> 单独出现并不危险,转它是惯例。" 和 ' 只在属性值里才有意义:它们会提前结束一个带引号的属性。所以正文用三个字符的表,属性用五个字符的表,两个引号都转,属性用单引号还是双引号都安全。escape_html 不转空格,文档注释里特意写了「unquoted attributes cannot be used」。

同一个 "q" 在正文和属性里:

"q" !["q"](/x "'t'")
<p>"q" <img src="/x" alt="&quot;q&quot;" title="&#39;t&#39;" /></p>

正文里的 "q" 原样,alt 和 title 里的引号都转了。这正是 raw_text 那段注释「Don't use escape_html_body_text here」的原因(见 HTML 写出器)。

CommonMark 的参考实现 commonmark.js 在正文里也把 " 写成 &quot;(用仓库 third_party/commonmark.js/ 里的 commonmark.min.js 渲染上面那行,得到 <p>&quot;q&quot; <img src="/x" alt="&quot;q&quot;" title="'t'" /></p>),上游规范例子的期望输出也是这样写的。为什么是 pull parser 里用 pulldown 自己的测试比较函数跑 comrak 和 markdown-rs,两者各差 21 个规范例子,原因只是正文里的 " 写不写成 &quot;:仓库里的规范和上游规范在这里不一样。

📝NOTE
冷知识:为了不在正文里转引号,仓库改了 CommonMark 规范的期望输出

0.9 和更早的版本做 HTML 转义的只有一个 escape_html,表里是 "、&、<、> 四个字符,正文和属性一律用它,单引号谁都不转。提交 836206c「feat(escape): separate attribute and text HTML」把它拆成两个:「This adds a function to escape attribute text, which will escape both single and double quotes, and another function used only for text nodes that doesn't.」也就是现在的 create_html_escape_table(body) 和两张静态表。正文不再输出 &quot; 之后,测试会和规范例子对不上,于是同一个提交直接改了仓库里的 third_party/CommonMark/spec.txt(22 行期望输出,比如反斜杠转义那个例子的 <p>!&quot;#$%&amp;'()… 改成 <p>!"#$%&amp;'()…)和 smart_punct.txt(2 行)。它经 PR #830 合入,首次发布于 0.10.0。从这以后,「pulldown-cmark 通过全部规范例子」指的是这份改过的规范。

escape_href:百分号编码,不看协议

escape_href 用一张 128 项的 HREF_SAFE 表决定哪些 ASCII 字节原样输出:

#[rustfmt::skip]
static HREF_SAFE: [u8; 128] = [
    0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
    0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
    0, 1, 0, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1,
    1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1,
    1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
    1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1,
    0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
    1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0,
];

每行 16 个字节,第三行从 0x20(空格)开始。按表读出来:控制字符、空格、"、&、'、<、>、[、\、]、反引号、{、|、}、DEL 不安全,所有 >= 0x80 的字节也不安全;字母数字和 !#$%()*+,-./:;=?@^_~ 安全。

pub fn escape_href<W>(mut w: W, s: &str) -> Result<(), W::Error>
where
    W: StrWrite,
{
    let bytes = s.as_bytes();
    let mut mark = 0;
    for i in 0..bytes.len() {
        let c = bytes[i];
        if c >= 0x80 || HREF_SAFE[c as usize] == 0 {
            // character needing escape

            // write partial substring up to mark
            if mark < i {
                w.write_str(&s[mark..i])?;
            }
            match c {
                b'&' => {
                    w.write_str(AMP_ESCAPE)?;
                }
                b'\'' => {
                    w.write_str(SINGLE_QUOTE_ESCAPE)?;
                }
                _ => {
                    let mut buf = [0u8; 3];
                    buf[0] = b'%';
                    buf[1] = HEX_CHARS[((c as usize) >> 4) & 0xF];
                    buf[2] = HEX_CHARS[(c as usize) & 0xF];
                    let escaped = from_utf8(&buf).unwrap();
                    w.write_str(escaped)?;
                }
            }
            mark = i + 1; // all escaped characters are ASCII
        }
    }
    w.write_str(&s[mark..])
}
  • & 和 ' 写成实体,不写成 %26、%27。 它们在 URL 里是合法字符(& 分隔查询参数),改成百分号编码会改变 URL 的含义;它们只是在 HTML 属性里需要转义。单引号在这里写成 &#x27;,在 escape_html 里写成 &#39;,两个常量各写各的,浏览器解码结果相同。
  • % 是安全字节。 已经编码过的 %E4%B8%AD 原样通过,不会编成 %25E4…。代价是一个不成对的 % 也原样通过。
  • 中文逐字节编码。 中 的三个 UTF-8 字节各成一个 %XX。mark = i + 1 那行注释说「all escaped characters are ASCII」,对这个函数并不成立:多字节字符的每个字节都要转义,mark 逐字节推进。切片仍然安全,因为 &s[mark..i] 只在 mark < i 时才切,那时 i 是一个 ASCII 字节或一个多字节字符的首字节,都在字符边界上。
  • 不看协议。 javascript:、data: 里的字母和冒号全在安全表里,原样通过。见本页最后一节。

escape_href 没有 SIMD 版本。

查表

两张 256 项的表在编译期由一个 const fn 生成(create_html_escape_table):

const fn create_html_escape_table(body: bool) -> [u8; 256] {
    let mut table = [0; 256];
    table[b'&' as usize] = 1;
    table[b'<' as usize] = 2;
    table[b'>' as usize] = 3;
    if !body {
        table[b'"' as usize] = 4;
        table[b'\'' as usize] = 5;
    }
    table
}

static HTML_ESCAPE_TABLE: [u8; 256] = create_html_escape_table(false);
static HTML_BODY_TEXT_ESCAPE_TABLE: [u8; 256] = create_html_escape_table(true);

static HTML_ESCAPES: [&str; 6] = ["", "&amp;", "&lt;", "&gt;", "&quot;", "&#39;"];

表里的值是 HTML_ESCAPES 的下标,0 表示不用转。两个公开函数只是选表,再按平台选实现:

pub fn escape_html<W: StrWrite>(w: W, s: &str) -> Result<(), W::Error> {
    #[cfg(all(target_arch = "x86_64", feature = "simd"))]
    {
        simd::escape_html(w, s, &HTML_ESCAPE_TABLE)
    }
    #[cfg(not(all(target_arch = "x86_64", feature = "simd")))]
    {
        escape_html_scalar(w, s, &HTML_ESCAPE_TABLE)
    }
}

标量版 escape_html_scalar:

    let bytes = s.as_bytes();
    let mut mark = 0;
    let mut i = 0;
    while i < s.len() {
        match bytes[i..].iter().position(|&c| table[c as usize] != 0) {
            Some(pos) => {
                i += pos;
            }
            None => break,
        }
        let c = bytes[i];
        let escape = table[c as usize];
        let escape_seq = HTML_ESCAPES[escape as usize];
        w.write_str(&s[mark..i])?;
        w.write_str(escape_seq)?;
        i += 1;
        mark = i; // all escaped characters are ASCII
    }
    w.write_str(&s[mark..])

position 找下一个要转义的字节,中间的一整段一次写出。这里的「all escaped characters are ASCII」是真的:五个特殊字符都小于 0x80,而 UTF-8 多字节字符的每个字节都大于等于 0x80,不可能被表命中,所以 i + 1 总在字符边界上,&s[mark..i] 不会切坏中文。

x86_64 上的 SIMD

simd 模块只在 target_arch = "x86_64" 且开了 simd feature 时编译,在 Apple Silicon 和其他 ARM 机器上开这个 feature 什么都不变。pulldown-cmark 那边的写法是:

[features]
default = ["getopts", "html"]
gen-tests = []
simd = ["pulldown-cmark-escape?/simd"]
html = ["pulldown-cmark-escape"]

? 表示「只有已经启用了这个可选依赖才传下去」。README 给的安装命令 cargo add pulldown-cmark --no-default-features --features=simd 同时关掉了 html,escape crate 根本不参与编译,simd 只剩第一遍扫描那一半(firstpass.rs 末尾的 mod simd,见块解析)。在 tag 上搜 unsafe,两个 crate 里的全部出现都在这两个 mod simd 里;pulldown-cmark 的 lib.rs 还用 #![cfg_attr(not(feature = "simd"), forbid(unsafe_code))] 保证不开 simd 时主 crate 没有 unsafe。

simd::escape_html 先做运行时检查:CPU 支持 SSSE3(is_x86_feature_detected!("ssse3")),并且输入至少 16 字节,否则退回标量版。主体是 compute_mask,一次看 16 个字节:

    const fn create_lookup() -> [u8; 16] {
        let mut table = [0; 16];
        table[(b'<' & 0x0f) as usize] = b'<';
        table[(b'>' & 0x0f) as usize] = b'>';
        table[(b'&' & 0x0f) as usize] = b'&';
        table[(b'"' & 0x0f) as usize] = b'"';
        table[(b'\'' & 0x0f) as usize] = b'\'';
        table[0] = 0b0111_1111;
        table
    }
        let expected = _mm_shuffle_epi8(lookup, vector);
        // ...
        let matches = _mm_cmpeq_epi8(expected, vector);
        // ...
        _mm_movemask_epi8(matches)

五个特殊字符的低 4 位各不相同(" 0x22、& 0x26、' 0x27、< 0x3C、> 0x3E)。pshufb(_mm_shuffle_epi8)用每个字节的低 4 位去 16 项的表里取一个字节,最高位为 1 的字节一律取到第 0 项。取出来的值和原字节相等,当且仅当原字节就是那个特殊字符:低 4 位相同的其他字节取到的是特殊字符本身,不相等;第 0 项是 0x7F,任何字节和它都不会相等(0x7F 自己的低 4 位是 F,取到的是 0)。movemask 把 16 个比较结果压成一个 16 位掩码,foreach_special_simd 逐个取掩码里的 1 回调。最后不足 16 字节的尾巴不单独处理,而是把最后一次读取对齐到输入末尾,再右移掉已经看过的位,这也是输入必须至少 16 字节的原因。

查找表是固定的五个字符,和传进来的是哪张转义表无关。回调里再查一次表:

                foreach_special_simd(bytes, 0, |i| {
                    let escape_ix = *bytes.get_unchecked(i) as usize;
                    let entry = table[escape_ix] as usize;
                    w.write_str(s.get_unchecked(mark..i))?;
                    mark = i + 1; // all escaped characters are ASCII
                    if entry == 0 {
                        w.write_str(s.get_unchecked(i..mark))
                    } else {
                        let replacement = super::HTML_ESCAPES[entry];
                        w.write_str(replacement)
                    }
                })?;

entry == 0 这一支只在正文转义时走到:SIMD 找到了 " 或 ',正文表说不用转,于是先写前面一段,再把这一个字节原样写出。

❗IMPORTANT
动脑筋:x86_64,simd 开与不开,escape_html_body_text 处理 1 MiB 的 say "hi" to 'x' (16 字节一段重复 65536 次),哪个快?

每 16 字节里有两个 "、两个 ',正文转义一个都不用转。答案在文末。

实测:Rosetta 下的相对差

本机是 aarch64。output-simd 用 --target x86_64-apple-darwin 编两份 release(一份 --features simd,转发给 pulldown-cmark/simd),在 Rosetta 2 下运行(/usr/bin/arch -x86_64)。Rosetta 把 SSSE3 指令翻译成 ARM 指令执行,下面的绝对时间不代表真 x86 机器,两列之间的比值也只说明这台机器上的翻译结果。同时附上原生 aarch64(标量路径)一列作参照。

语料是本站内容仓库 docs/ 下全部 Markdown 的一份快照(1137 篇,19,185,592 字节,按本站的 Options 解析)。每项 30 次取最小,运行时系统负载 9 左右(其他实验同时在跑)。原始输出在 output-simd/out/final.txt。

输入函数x86 无 simdx86 simd比arm 原生
整个语料拼成一个串escape_html_body_text8.340 ms6.835 ms0.827.905 ms
整个语料拼成一个串escape_html10.745 ms7.401 ms0.699.833 ms
1 MiB 无特殊字符escape_html_body_text0.340 ms0.145 ms0.430.327 ms
1 MB a<b&c>d 重复escape_html_body_text4.505 ms4.432 ms0.983.111 ms
语料的全部 Text / Code 事件逐个转escape_html_body_text14.943 ms15.508 ms1.0414.001 ms
同上escape_html17.305 ms16.100 ms0.9315.848 ms
解析 + push_html 整个语料80.172 ms83.789 ms1.0564.767 ms
只解析(数事件)61.631 ms67.726 ms1.1046.059 ms
  • 特殊字符少、串长时 SIMD 快。 无特殊字符的 1 MiB 快 2.3 倍;整个语料一个大串快 18%(正文)和 31%(属性,因为标量版的五字符表命中更多)。
  • 特殊字符密时没区别。 a<b&c>d 每 7 字节 3 个特殊字符,两边都花在逐个写替换串上。
  • 真实的调用方式下几乎没有收益。 写出器是逐个事件转义的。语料里 461,769 个 Text / Code 事件,57.7% 不足 16 字节,直接走标量版;按字节算这些短串只占 11.9%,但每次调用的固定开销摊不掉。正文转义总时间差 4%,在噪声范围内。
  • 整条流水线上 simd 版反而慢。 只解析就慢 10%,这一项不调用转义,差别全在第一遍的 SIMD 扫描上。在 Rosetta 下测出来的这个结果不能推到真 x86 机器;能说的只是:在这台机器上,开 simd 没有换来端到端的提速。
📝NOTE
冷知识:escape 拆成独立 crate 后,SIMD 转义有三个版本没开

a917492「feat: separate escape.rs in another crate」把 src/escape.rs 原样搬成 pulldown-cmark-escape/src/lib.rs,经 PR #838 进了 0.10.0。搬过去的代码里还写着 #[cfg(all(target_arch = "x86_64", feature = "simd"))],可新 crate 的 Cargo.toml 没有 [features],pulldown-cmark 那边的 simd = [] 也不往下传:这个 cfg 永远为假,0.10.0 到 0.10.2 开了 simd 的用户,第一遍扫描是 SIMD,转义一直是标量。1cfaf02「fix: simd was not be using after the new crate pulldown-cmark-escape」给 escape crate 加上 simd = []、把 pulldown-cmark 的 simd 改成现在的 ["pulldown-cmark-escape?/simd"],直接在 0.10 的发布线上提交,随 pulldown-cmark 0.10.3(escape 0.10.1)发布,没有对应的 PR。主线上的同一修复是 PR #880(7316da2「(Re)introduce simd feature to escape crate」,0.11.0)。它的 diff 还顺手修了 simd 模块里残留的 io::Result:PR #870 已经把 StrWrite 的错误类型改成泛型,那段代码因为从不参与编译,没人发现它已经编译不过了。

不做净化

转义保证的是「内容不会破坏 HTML 的语法结构」:文本里的 < 不会变成标签,标题里的 " 不会提前结束属性。它不判断内容本身是否安全。output-probe xss 的输出(全部 Options::empty()):

=== html block  src "<script>alert(1)</script>\n"
<script>alert(1)</script>
=== inline html  src "hi <img src=x onerror=alert(1)> there\n"
<p>hi <img src=x onerror=alert(1)> there</p>
=== javascript link  src "[click](javascript:alert(1))\n"
<p><a href="javascript:alert(1)">click</a></p>
=== javascript autolink  src "<javascript:alert(1)>\n"
<p><a href="javascript:alert(1)">javascript:alert(1)</a></p>
=== javascript image  src "![x](javascript:alert(1))\n"
<p><img src="javascript:alert(1)" alt="x" /></p>
=== data url  src "[d](data:text/html;base64,PHNjcmlwdD4=)\n"
<p><a href="data:text/html;base64,PHNjcmlwdD4=">d</a></p>
=== quote breakout dest  src "[a](x\"onmouseover=y)\n"
<p><a href="x%22onmouseover=y">a</a></p>
=== quote breakout title  src "[a](/u \"t\\\" onmouseover=\\\"y\")\n"
<p><a href="/u" title="t&quot; onmouseover=&quot;y">a</a></p>
=== entity in scheme  src "[a](jav&#x61;script:alert(1))\n"
<p><a href="javascript:alert(1)">a</a></p>
=== escaped html  src "\\<script>x\n"
<p>&lt;script&gt;x</p>
  • HTML 块和行内 HTML 原样输出(run 里的 Html(html) | InlineHtml(html) => self.write(&html))。这是 CommonMark 的规定:原始 HTML 是 Markdown 语法的一部分,HTML 块的七种开头见叶子块,行内 HTML 见代码段、HTML、实体。Options 里没有关掉它的开关。
  • javascript: 出现在链接、自动链接、图片里,都原样进了属性。escape_href 只管字节,不管协议。
  • 想从属性里「逃出去」的两种写法都失败了:地址里的 " 变成 %22,标题里的 " 变成 &quot;。转义做到的就是这个。
  • 链接地址里的实体在解析时已经解码(jav&#x61;script: 到事件里就是 javascript:)。要检查协议,检查事件里的 dest_url,不要在源文上做字符串匹配。

渲染不可信的输入时,信任边界在调用方。一种做法是在事件流上过滤,再交给 push_html:

use pulldown_cmark::{Event, Tag, TagEnd};

// Raw HTML becomes text (so the writer escapes it); links with a scheme
// outside the allow list keep their text but lose the <a>.
fn safe_events<'a>(
  iter: impl Iterator<Item = Event<'a>>,
) -> impl Iterator<Item = Event<'a>> {
  let mut dropped: Vec<bool> = Vec::new();
  iter.filter_map(move |ev| match ev {
    Event::Html(s) | Event::InlineHtml(s) => Some(Event::Text(s)),
    Event::Start(Tag::Link { ref dest_url, .. }) => {
      let ok = is_allowed(dest_url);
      dropped.push(!ok);
      if ok { Some(ev) } else { None }
    }
    Event::End(TagEnd::Link) => {
      if dropped.pop() == Some(true) { None } else { Some(ev) }
    }
    _ => Some(ev),
  })
}

fn is_allowed(url: &str) -> bool {
  match url.split_once(':') {
    // a ':' after '/', '?' or '#' is not a scheme separator
    Some((scheme, _)) if !scheme.contains(['/', '?', '#']) => {
      matches!(scheme.to_ascii_lowercase().as_str(), "http" | "https" | "mailto")
    }
    _ => true,
  }
}

End(Link) 不带地址,要知道它对应的 Start 有没有被丢掉,只能自己记一个栈。对这段输入:

<script>alert(1)</script>

hi <img src=x onerror=alert(1)> [ok](/a) [bad](JavaScript:alert(1)) [ent](jav&#x61;script:x)

输出:

&lt;script&gt;alert(1)&lt;/script&gt;
<p>hi &lt;img src=x onerror=alert(1)&gt; <a href="/a">ok</a> bad ent</p>

这段过滤只是示意:它没管图片的 src,把所有原始 HTML 都降成了文本(合法的 <kbd> 也一样)。要保留一部分 HTML,更稳妥的是对 push_html 的输出再过一遍白名单式的 HTML 净化库;本卷的实验环境里没有这类库,没有测。本站不走 push_html,它对 HTML 事件和链接地址的处理见 08 在本站中。上游 README 和 guide 都没有提到净化,guide 的「HTML Safety」一节只讲了转义函数。

动脑筋答案

output-simd 里 quotes 1 MiB 一行(同一次运行,out/final.txt):

## arm
quotes 1 MiB           1048576 B  body_text    0.327 ms (out 1048576)  escape_html    2.325 ms (out 2228224)
## x86-nosimd
quotes 1 MiB           1048576 B  body_text    0.340 ms (out 1048576)  escape_html    3.159 ms (out 2228224)
## x86-simd
quotes 1 MiB           1048576 B  body_text    1.430 ms (out 1048576)  escape_html    2.911 ms (out 2228224)

不开 simd 快,开了慢 4.2 倍(0.340 ms 对 1.430 ms)。

  • 标量版用的是正文表," 和 ' 不在表里,position 一路扫到底,整个 1 MiB 一次写出(输出 1048576 字节,和输入一样长)。
  • SIMD 版的查找表固定含五个字符,每 16 字节命中 4 次,每次回调写两段(前面一段和这一个字节),1 MiB 里是 26 万次回调、52 万次 write_str,什么也没转义。
  • escape_html 两边都要真的转义这些引号(输出 2228224 字节),SIMD 版快 8%。
  • 这个差距来自调用次数,不是指令速度,所以在真 x86 机器上方向应当相同,具体倍数没有测。上游 main 上这段代码在 tag 之后只改了 no_std 相关的导入。

小结

  • 三个函数:正文转 &<>,属性再加 "',href 对不安全字节做百分号编码、& 和 ' 写成实体;都不分配,直接写进 StrWrite。
  • 正文不转引号是 0.10.0(PR #830)开始的,仓库里的 CommonMark 规范为此改了期望输出,和 comrak、markdown-rs 的 21 处差异都来自这里。
  • 标量版用 256 项表加 position;五个特殊字符都是 ASCII,切片总在字符边界上。
  • SIMD 只在 x86_64 加 simd feature 时编译,运行时还要 SSSE3 和至少 16 字节。Rosetta 下实测:长串少特殊字符时快 18% 到 2.3 倍,逐事件转义几乎无差别,含大量引号的正文慢 4.2 倍,端到端没有提速。
  • 转义不是净化:HTML 事件原样输出,javascript: 链接原样通过,信任边界在调用方;要么过滤事件,要么净化输出。

💬 关联讨论

发起讨论
加载中...