fix(parser): strip UTF-8 BOM before JSON parsing

serde_json rejects BOM (U+FEFF) prefixed input with 'expected value'
error, causing BOM-prefixed JSON log lines to be silently dropped.

Add strip_bom() helper that strips exactly one leading BOM character,
apply it in detect_json_log() and parse_line().

Closes #20
This commit is contained in:
dailz
2026-06-10 11:37:00 +08:00
parent 8e9600dda2
commit eedab3ac96

View File

@@ -34,6 +34,19 @@ use serde_json::Value;
// types 模块中定义了 LogEntry一条日志记录和 LogLevel日志级别如 INFO/ERROR
use crate::types::{LogEntry, LogLevel};
// ─── strip_bom 辅助函数 ──────────────────────────────────────────────────
// 剥离行首的 UTF-8 BOMByte Order Mark, U+FEFF
//
// Windows 环境和某些导出工具生成的文件会在行首插入 BOM
// 而 serde_json 不接受 BOM 前缀的 JSON 文本(会报 "expected value" 错误)。
// 只剥离一个前导 BOM不处理多个 BOM 或行内 BOM那些是畸形输入
//
// 参数: line: &str — 输入字符串切片。
// 返回: &str — 去掉 BOM 后的字符串切片(借用原始字符串,零分配)。
fn strip_bom(line: &str) -> &str {
line.strip_prefix('\u{FEFF}').unwrap_or(line)
}
// ─── detect_json_log 函数 ──────────────────────────────────────────────────
// 检测一行文本是否是一个 JSON 对象。
//
@@ -63,7 +76,7 @@ pub fn detect_json_log(line: &str) -> bool {
// 则匹配成功。_ 是通配符,表示"不关心对象里面的具体内容"。
//
// 如果匹配到 Ok(Value::Object(_)) 返回 true否则返回 false。
matches!(serde_json::from_str::<Value>(line), Ok(Value::Object(_)))
matches!(serde_json::from_str::<Value>(strip_bom(line)), Ok(Value::Object(_)))
}
// ─── take_string_field 辅助函数 ──────────────────────────────────────────────
@@ -105,6 +118,8 @@ fn take_string_field(fields: &mut HashMap<String, Value>, keys: &[&str]) -> Opti
// Option 是 Rust 的可选类型Some(值) 表示有值None 表示没有值。
pub fn parse_line(line: &str) -> Option<LogEntry> {
// ─── 跳过空行 ──────────────────────────────────────────────────────────
let line = strip_bom(line);
// line.trim() 去除首尾空白字符(空格、制表符、换行符等)。
// .is_empty() 检查是否为空字符串。
// 如果去除空白后是空的,说明是空行,不需要解析,直接返回 None。
@@ -403,4 +418,42 @@ mod tests {
// "time" 已被提取并从 fields 中移除。
assert!(entry.fields.get("time").is_none());
}
#[test]
fn test_bom_prefixed_json() {
let line = "\u{FEFF}{\"level\":\"INFO\",\"message\":\"hello\"}";
let entry = parse_line(line).unwrap();
assert_eq!(entry.level, Some(LogLevel::Info));
assert_eq!(
entry.fields.get("message"),
Some(&Value::String("hello".into()))
);
}
#[test]
fn test_bom_prefixed_detect() {
assert!(detect_json_log("\u{FEFF}{\"level\":\"INFO\"}"));
}
#[test]
fn test_bom_only_whitespace() {
assert!(parse_line("\u{FEFF} ").is_none());
}
#[test]
fn test_bom_stripped_from_raw_line() {
let line = "\u{FEFF}{\"level\":\"INFO\",\"message\":\"hello\"}";
let entry = parse_line(line).unwrap();
assert_eq!(entry.raw_line, "{\"level\":\"INFO\",\"message\":\"hello\"}");
}
#[test]
fn test_internal_bom_not_stripped() {
let line = "{\"message\":\"\u{FEFF}hello\"}";
let entry = parse_line(line).unwrap();
assert_eq!(
entry.fields.get("message"),
Some(&Value::String("\u{FEFF}hello".into()))
);
}
}