Harden auth/vault path sanitization and clean WeKnora docs
This commit is contained in:
@@ -765,8 +765,9 @@ fn upsert_match(
|
||||
}
|
||||
|
||||
fn extract_text_from_mindmap_data(value: &Value, max_chars: usize) -> String {
|
||||
fn walk(node: &Value, output: &mut Vec<String>, max_chars: usize) {
|
||||
if output.join("\n").len() >= max_chars {
|
||||
// 维护累计字节长度,避免每次递归 O(n²) join。
|
||||
fn walk(node: &Value, output: &mut Vec<String>, total_len: &mut usize, max_chars: usize) {
|
||||
if *total_len >= max_chars {
|
||||
return;
|
||||
}
|
||||
match node {
|
||||
@@ -775,26 +776,30 @@ fn extract_text_from_mindmap_data(value: &Value, max_chars: usize) -> String {
|
||||
if let Some(Value::String(text)) = data.get("text") {
|
||||
let normalized = normalize_text(text);
|
||||
if !normalized.is_empty() {
|
||||
if !output.is_empty() {
|
||||
*total_len = total_len.saturating_add(1); // '\n'
|
||||
}
|
||||
*total_len = total_len.saturating_add(normalized.len());
|
||||
output.push(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(Value::Array(children)) = map.get("children") {
|
||||
for child in children {
|
||||
walk(child, output, max_chars);
|
||||
if output.join("\n").len() >= max_chars {
|
||||
walk(child, output, total_len, max_chars);
|
||||
if *total_len >= max_chars {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(root) = map.get("root") {
|
||||
walk(root, output, max_chars);
|
||||
walk(root, output, total_len, max_chars);
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
walk(item, output, max_chars);
|
||||
if output.join("\n").len() >= max_chars {
|
||||
walk(item, output, total_len, max_chars);
|
||||
if *total_len >= max_chars {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -804,10 +809,18 @@ fn extract_text_from_mindmap_data(value: &Value, max_chars: usize) -> String {
|
||||
}
|
||||
|
||||
let mut output = Vec::new();
|
||||
walk(value, &mut output, max_chars);
|
||||
let mut total_len = 0usize;
|
||||
walk(value, &mut output, &mut total_len, max_chars);
|
||||
let joined = output.join("\n").trim().to_string();
|
||||
if joined.len() > max_chars {
|
||||
format!("{}…", &joined[..max_chars])
|
||||
// 按 UTF-8 字符边界截断,避免 panic
|
||||
let end = joined
|
||||
.char_indices()
|
||||
.map(|(i, _)| i)
|
||||
.take_while(|&i| i <= max_chars)
|
||||
.last()
|
||||
.unwrap_or(0);
|
||||
format!("{}…", &joined[..end])
|
||||
} else {
|
||||
joined
|
||||
}
|
||||
@@ -834,31 +847,45 @@ fn build_snippet(text: &str, keyword: &str) -> String {
|
||||
if source.is_empty() {
|
||||
return "暂无正文内容".into();
|
||||
}
|
||||
let escaped = escape_html(source);
|
||||
if keyword.trim().is_empty() {
|
||||
return truncate_snippet(&escaped);
|
||||
return truncate_snippet(&escape_html(source));
|
||||
}
|
||||
|
||||
// 先在原文上匹配,再 escape 匹配片段,避免在 `&` 等实体内部插入 <mark>。
|
||||
let regex = match RegexBuilder::new(®ex::escape(keyword))
|
||||
.case_insensitive(true)
|
||||
.build()
|
||||
{
|
||||
Ok(regex) => regex,
|
||||
Err(_) => return truncate_snippet(&escaped),
|
||||
Err(_) => return truncate_snippet(&escape_html(source)),
|
||||
};
|
||||
|
||||
let Some(found) = regex.find(&escaped) else {
|
||||
return truncate_snippet(&escaped);
|
||||
let Some(found) = regex.find(source) else {
|
||||
return truncate_snippet(&escape_html(source));
|
||||
};
|
||||
|
||||
let start = found.start().saturating_sub(20);
|
||||
let end = (found.end() + 80).min(escaped.len());
|
||||
let segment = escaped[start..end].to_string();
|
||||
regex
|
||||
.replace_all(&segment, |captures: ®ex::Captures<'_>| {
|
||||
format!("<mark>{}</mark>", &captures[0])
|
||||
})
|
||||
.to_string()
|
||||
// 按 UTF-8 字符边界取上下文,避免多字节字符中间切片 panic。
|
||||
let match_start = found.start();
|
||||
let match_end = found.end();
|
||||
let target_start = match_start.saturating_sub(20);
|
||||
let start = source
|
||||
.char_indices()
|
||||
.map(|(i, _)| i)
|
||||
.take_while(|&i| i <= target_start)
|
||||
.last()
|
||||
.unwrap_or(0);
|
||||
let target_end = (match_end + 80).min(source.len());
|
||||
let end = source
|
||||
.char_indices()
|
||||
.map(|(i, _)| i)
|
||||
.find(|&i| i >= target_end)
|
||||
.unwrap_or(source.len());
|
||||
let end = if end < start { source.len() } else { end };
|
||||
|
||||
let prefix = escape_html(&source[start..match_start]);
|
||||
let matched = escape_html(&source[match_start..match_end]);
|
||||
let suffix = escape_html(&source[match_end..end]);
|
||||
format!("{prefix}<mark>{matched}</mark>{suffix}")
|
||||
}
|
||||
|
||||
fn truncate_snippet(value: &str) -> String {
|
||||
@@ -1120,4 +1147,23 @@ mod tests {
|
||||
assert_eq!(result.results[0].evidence.len(), 1);
|
||||
assert_eq!(result.enqueue_asset_ids, vec!["asset_1".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_snippet_does_not_break_html_entities() {
|
||||
// 关键词 "amp" 若在转义后匹配,会破坏 `&` 实体。
|
||||
let snippet = build_snippet("x & y amp-word", "amp");
|
||||
assert!(
|
||||
!snippet.contains("&<mark>"),
|
||||
"不得在 HTML 实体内部插入 mark: {snippet}"
|
||||
);
|
||||
assert!(
|
||||
snippet.contains("<mark>") && snippet.contains("</mark>"),
|
||||
"应高亮匹配词: {snippet}"
|
||||
);
|
||||
// 原文 & 应被 escape 为 &,且实体完整
|
||||
assert!(
|
||||
snippet.contains("&") || !snippet.contains('&'),
|
||||
"原文 & 应被正确转义: {snippet}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user