fix(mhchem): 修复 find_observe_end 逐字节扫描多字节字符的 panic

find_observe_end 用 `let bytes = input.as_bytes(); let mut i = start; while i < len { ...; i += 1; }`
逐字节步进,对每个位置做 `&input[i..]` 切片与 `bytes[i] as char` 花括号判定。
化学公式若含多字节 UTF-8 字符(如中文「浓」占 3 字节),i 会落在字符内部:

  - `&input[i..]` 触发 `byte index N is not a char boundary; it is inside '浓'` panic;
  - 在 panic=abort 下直接 SIGABRT 杀进程;
  - `bytes[i] as char` 把多字节字符首字节误判为花括号,还静默损坏输出。

触发路径:rebuild_content_html 重建含中文化学式(如 \ce{浓H2SO4})的文章时崩溃。

修复:改用 `input.char_indices().skip_while(...)` 按字符边界步进,i 始终落在
char 边界上,`a` 直接是解出的 char。start 校验对齐到边界后跳过。

新增:
- 回归测试 ce_multibyte_char_in_braces_does_not_panic:覆盖触发输入。
- 保护性测试 ce_arbitrary_multibyte_does_not_panic:穷举 emoji/日韩文/组合字符/
  四字节区,覆盖花括号/方括号/\frac/$...$ 全部走 find_observe_end 的路径。

注:多字节字符的状态机转译输出质量(如 ce("浓H2SO4") 的字符重复)是独立的
既有问题,mhchemParser 本为 ASCII 化学符号设计;本提交只修 panic,不扩大范围。
This commit is contained in:
xfy 2026-07-23 18:00:41 +08:00
parent feaf76bcd9
commit a03fbac88c

View File

@ -296,11 +296,18 @@ struct Span {
}
/// 从 `rest0` 的 `start` 位置扫描,跟踪花括号深度,在深度 0 遇到 `end_chars` 时返回其区间。
///
/// 必须按**字符**而非字节步进:化学公式可含多字节 UTF-8 字符(如中文说明
/// 文字「浓」占 3 字节)。逐字节 `i+=1` 会让 `&input[i..]` 落在字符内部,
/// 触发 `byte index N is not a char boundary` panic在 panic=abort 下直接
/// 杀进程),且 `bytes[i] as char` 会把多字节字符的首字节误判为花括号,
/// 导致输出损坏。`char_indices` 保证每步都在字符边界上。
fn find_observe_end(input: &str, start: usize, end_chars: &Pat) -> Option<Span> {
let bytes = input.as_bytes();
let mut i = start;
let mut braces = 0i32;
while i < input.len() {
// start 是上一步 beg_incl 匹配的长度;该匹配来自 Pat::Lit 或锚定正则,
// 落在字符边界上。校验后用于首次切片,随后只按 char_indices 的边界前进。
let start = input.get(start..).map(|_| start).unwrap_or(input.len());
for (i, a) in input.char_indices().skip_while(|(b, _)| *b < start) {
// 结束定界符匹配(仅在花括号平衡时)
if braces == 0 {
if let Some(matched) = pat_match_head(end_chars, &input[i..]) {
@ -310,7 +317,6 @@ fn find_observe_end(input: &str, start: usize, end_chars: &Pat) -> Option<Span>
});
}
}
let a = bytes[i] as char;
if a == '{' {
braces += 1;
} else if a == '}' {
@ -320,7 +326,6 @@ fn find_observe_end(input: &str, start: usize, end_chars: &Pat) -> Option<Span>
}
braces -= 1;
}
i += 1;
}
None
}
@ -1296,6 +1301,37 @@ mod tests {
}
}
/// 回归测试:`find_observe_end` 曾用逐字节 `i += 1` 步进,遇多字节
/// UTF-8 字符(如中文「浓」占 3 字节)会让 `&input[i..]` 落在字符内部,
/// 触发 `byte index N is not a char boundary` panic。在 panic=abort 下
/// 直接杀进程(曾导致 rebuild_content_html 重建含中文化学式的文章时崩溃)。
/// 修复改为按 `char_indices` 字符边界步进。这些输入必须不 panic。
#[test]
fn ce_multibyte_char_in_braces_does_not_panic() {
for s in ["{浓}", "浓H2SO4", "{中文}", "H{浓}O", "\\frac{浓}{稀}", "[浓]"] {
// catch_unwind 仅 dev/test 护栏release panic=abort 下由本修复保证。
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| ce(s)));
assert!(r.is_ok(), "ce({s:?}) PANICKED (char boundary)");
}
}
/// 保护性任意多字节字符组合emoji/日韩文/组合字符/四字节区)都不应
/// panic。覆盖花括号/方括号/`\frac`/`$...$` 等所有走 find_observe_end 的路径。
#[test]
fn ce_arbitrary_multibyte_does_not_panic() {
let inputs = [
"🔥", "café", "naïve", "Σ", "αβγ", "ΔH", "你好世界", "안녕", "こんにちは",
"{🧪}", "H₂O", "[α]", "\\frac{β}{γ}", "${日本}$", "A·B•C⋅D",
"naïve H2O", "{β-Gal}", "😀😂", "\u{1F9EA}", // test tube emoji
];
for s in inputs {
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| ce(s)));
assert!(r.is_ok(), "ce({s:?}) PANICKED");
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| pu(s)));
assert!(r.is_ok(), "pu({s:?}) PANICKED");
}
}
/// 保护性:强制 `match_pattern` 的每个分支至少运行一次,让每个 `re!`
/// 定义的 regex 都被编译。任一转录错误都会在此暴露,而非等到生产环境
/// 被特定输入触发后 panic=abort 整个进程。