Harden auth/vault path sanitization and clean WeKnora docs

This commit is contained in:
Agent Board
2026-07-28 17:04:27 +08:00
parent 2deaf59f7b
commit 26ff1a9c9a
190 changed files with 13454 additions and 4987 deletions
+68 -22
View File
@@ -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 匹配片段,避免在 `&amp;` 等实体内部插入 <mark>。
let regex = match RegexBuilder::new(&regex::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: &regex::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" 若在转义后匹配,会破坏 `&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 为 &amp;,且实体完整
assert!(
snippet.contains("&amp;") || !snippet.contains('&'),
"原文 & 应被正确转义: {snippet}"
);
}
}