From a03fbac88c5f0649fcf1c66c7912bec118db39f3 Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 23 Jul 2026 18:00:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(mhchem):=20=E4=BF=AE=E5=A4=8D=20find=5Fobse?= =?UTF-8?q?rve=5Fend=20=E9=80=90=E5=AD=97=E8=8A=82=E6=89=AB=E6=8F=8F?= =?UTF-8?q?=E5=A4=9A=E5=AD=97=E8=8A=82=E5=AD=97=E7=AC=A6=E7=9A=84=20panic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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,不扩大范围。 --- src/api/mhchem.rs | 46 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/src/api/mhchem.rs b/src/api/mhchem.rs index 07b46ea..e49fa07 100755 --- a/src/api/mhchem.rs +++ b/src/api/mhchem.rs @@ -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 { - 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 }); } } - 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 } 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 整个进程。