Rust in Everything
基于 pulldown-cmark 0.13.5

</SCRIPT> 没有结束 HTML 块

主线位置:块主线的叶子块。CommonMark 把 HTML 块分成七类,第 1 类是以 <pre、<script、<style、<textarea 开头的块,七类的开始和结束条件见叶子块的「HTML 块:七类」,那一页已经指出了这处与规范的出入。本页讲它在文档里的样子、怎么绕开、怎么查。

flowchart TB
  start["script 开头"] --> tag["get_html_end_tag"]
  tag --> want["只找小写 /script"]
  want --> line{"这一行含它"}
  line -->|是| close["块结束"]
  line -->|否| more["下一行也是 HTML"]
  more --> line
  more --> eof["文档末尾才结束"]
  eof --> raw["标题 强调都原样输出"]

实验 crate:exp-pulldown-cmark/pitb-probe 的 html-block-end(0.13.5),pitb-probe134 配 0.13.4,输出逐字节相同。M2 Max、macOS 27、rustc 1.99.0,输出在 out/html-block-end.txt。

现象

从老网页里复制了一段脚本,结束标签是大写:

<script>
let a = 1;
</SCRIPT>

# 标题

正文 *强调*。

事件流:

0..60 Start(HtmlBlock)
  0..9 Html(Borrowed("<script>\n"))
  9..20 Html(Borrowed("let a = 1;\n"))
  20..30 Html(Borrowed("</SCRIPT>\n"))
  30..31 Html(Borrowed("\n"))
  31..40 Html(Borrowed("# 标题\n"))
  40..41 Html(Borrowed("\n"))
  41..60 Html(Borrowed("正文 *强调*。\n"))
0..60 End(HtmlBlock)

整份文档是一个 HTML 块。push_html 把它原样写出:

<script>
let a = 1;
</SCRIPT>

# 标题

正文 *强调*。

浏览器里 HTML 标签不分大小写,</SCRIPT> 照样结束了脚本,于是页面上出现一行字面的 # 标题 和 正文 *强调*。,标题和强调都没了。空行也挡不住:第 1 类 HTML 块不在空行处结束。

结束标签和开始标签不同名时也一样。<pre> 开头、</script> 那一行之后的内容同样全进了 HTML 块:

=== other-end: "<pre>\nconsole.log(1)\n</script>\n\n# 标题\n"
--- html
<pre>
console.log(1)
</script>

# 标题

原因

CommonMark 0.31.2 第 4.6 节对第 1 类的结束条件是「line contains an end tag </pre>, </script>, </style>, or </textarea> (case-insensitive; it need not match the start tag)」:四个结束标签任一个都行,不分大小写。0.13.5 的实现窄得多。

块开头时,get_html_end_tag 不分大小写地认开始标签,但只返回与它同名的那一个小写结束串:

    static BEGIN_TAGS: &[&[u8]; 4] = &[b"pre", b"style", b"script", b"textarea"];
    // ...
    for (beg_tag, end_tag) in BEGIN_TAGS
        .iter()
        .zip(["</pre>", "</style>", "</script>", "</textarea>"].iter())
    {
        // ...
        if !text_bytes[..tag_len].eq_ignore_ascii_case(beg_tag) {
            continue;
        }

之后 parse_html_block_type_1_to_5 每读一行,用 str::contains 找这个串:

            if self.text[line_start_ix..ix].contains(html_end_tag) {
                end_ix = ix;
                break;
            }

contains 区分大小写,也只找一个串。所以:

写法0.13.5规范
<script> … </script>结束结束
<SCRIPT> … </script>结束(开始标签不分大小写)结束
<script> … </SCRIPT>不结束结束
<pre> … </script>不结束结束

不结束时,循环只在两种情况下停:容器不再匹配(比如块在引用块里,引用块结束了),或者到了文档末尾。第 2 到 5 类(注释、处理指令、声明、CDATA)的结束串是 -->、?>、>、]]>,没有字母,不受影响。

main 上已修,没有发布。 PR #1163 有两个修正提交:0b7c9f2「Match HTML block end tags case-insensitively」和 a8fd0bd「End an HTML block at any of the four type 1 end tags」,后一个的说明举的就是 <script> 配 </TEXTAREA> 的例子,「the block went on to the end of the document; cmark and commonmark.js end it at </TEXTAREA>」。同一个 PR 还改成用 memchr 在每个 < 处一次试四个结束标签。这些提交不在任何 tag 里,0.13.5 没有摘这个 PR;0.13.4 的行为与 0.13.5 相同。

修法

结束标签写成小写,并与开始标签同名。 开始标签的大小写无所谓:

<SCRIPT>
let a = 1;
</script>

# 标题

正文 *强调*。
<SCRIPT>
let a = 1;
</script>
<h1>标题</h1>
<p>正文 <em>强调</em>。</p>

块在 </script> 那一行结束,后面的标题和强调恢复正常。

处理不受你控制的 Markdown: 在解析前把这四种结束标签规范成小写(例如对 </SCRIPT>、</Pre> 这类写法做不分大小写的替换),并检查结束标签是否与开始标签同名。这只是绕过 0.13.5 的出入;main 上的修复发布后可以去掉。这一处不涉及安全边界:pulldown-cmark 本来就原样输出 HTML(见原始 HTML 原样输出那一页),这里的后果是排版坏掉。

怎么检查

遍历 Start(HtmlBlock):块的源文以这四种开始标签之一开头,块里却找不到对应的小写结束标签,它就是一路吞到了容器或文档的末尾。pitb-probe 里的 check_html_blocks:

    if let Event::Start(Tag::HtmlBlock) = ev {
      let block = &src[r.clone()];
      let head = block.trim_start().to_ascii_lowercase();
      for tag in ["pre", "script", "style", "textarea"] {
        let open = format!("<{tag}");
        let next = head.as_bytes().get(open.len()).copied();
        let starts = head.starts_with(&open) && matches!(next, None | Some(b'>' | b' ' | b'\t' | b'\n'));
        if starts && !block.contains(&format!("</{tag}>")) {
          // report line, tag, how many lines were swallowed
        }
      }
    }
check: line 1: <script> block never closed by </script>, swallowed 7 lines up to byte 60
check: 1 problem(s)

改好的文档报 check: 0 problem(s)。

小结

  • 第 1 类 HTML 块(<pre、<script、<style、<textarea 开头)在 0.13.5 里只认与开始标签同名的小写结束标签;</SCRIPT> 或另外三种结束标签都不结束块,空行也不行。
  • 块不结束就一直延续到容器或文档末尾,后面的 Markdown 全部原样输出成 HTML,没有报错。
  • 修法:结束标签写小写、与开始标签同名;外来内容先规范化。main 上的 PR #1163 已按规范修好,尚未进入任何发布版本。
  • 检查:找以这四种标签开头、却不含对应小写结束标签的 HtmlBlock。

💬 关联讨论

发起讨论
加载中...