mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 04:43:11 +00:00
一次知识库子系统的集中加固,四类缺陷各有实测复现:
1. 名称与路径(数据安全,最严重)
- sanitize 不过滤 .. ⇒ Remove("..") 直接 RemoveAll 掉整个数据目录
(实测把 <data> 整棵删掉,含 memory/documents/media),且返回 nil,
工具层回报"已删除";Add("../../x") 写到知识根外,重启扫不回来
⇒ 幽灵条目。
- Add 整串 sanitize 而建目录逐段 sanitize,内存键与盘上目录从**第一次
落盘起**就不一致;重启后 name 漂移,knowledge_delete 静默删不掉
(RemoveAll 删空目录返 nil)。同一个根因。
- 修法:新增 normalizeName 作为唯一入口(逐段 + 拒绝空段/点段/隐藏段);
Remove 改为取条目自记的 Path(不再用名字重拼)+ 返回 ErrNotFound。
- resolve 三层退让(原样 → 规范名 → 叶名大小写不敏感,唯一命中才接受):
scanDir 按盘上目录原样建键,遗留大写目录若只查规范名会变成
"List 看得到、Remove 报不存在"。删的路径仍取自 Path,退让无风险。
2. IDF 与索引不同步(功能缺陷,非优化)
- TFIDF Vectorize 跳过 df<=0 的特征,而 Add 只往只增不减的 summaries
追文本、从不更新 DF ⇒ 库满(≥3篇) + 新词时,新知识**当场搜不到**,
重启才恢复(实测 Search("量子纠缠") == [])。
- 修法:vector.Store 新增 AddDoc/RemoveDoc(文档级去重口径与 Train 一致,
totalDocs 下界守卫,零频 DF 删除防表膨胀);knowledge 删掉 summaries,
改 index/unindex/retrain 三件套,覆盖写先 RemoveDoc 旧文本。
3. 多模态稠密路(此前知识库端到端纯文本)
- 新增 SetDenseSpace/SetMediaGetter/ReindexDense/DenseStats 与
AddWithMedia/AttachMedia,媒体成为一等节点参与跨模态召回。
- 维度与指纹双守卫:维度不符的向量会被 FuseVectors 按最大维度拼成错维度
结果且被当成"已对齐"永久错下去(docStore 踩过);模态不支持(音频)时
静默跳过该媒体、退化为纯文本向量,绝不拿别的模型的向量顶替。
- 未注入多模态空间时行为与此前逐字一致(退化为 0.5/0.5 两路融合)。
4. 派生数据落盘 + 分层参与召回
- 媒体引用是**作者数据**(丢失即丢信息)→ 条目目录内 .media.json;
稠密向量是**派生数据**(可重算)→ 全局 .dense.json,tmp+rename 原子。
混存会让派生数据损坏连带作者数据一起丢。
- 新增 SearchIn(query, category, topK):分类前缀匹配子树,让分层真正
参与召回(此前检索全库平铺,分层只是存储布局)。归一化取作用域内
最大值,否则范围外的强命中会把域内分数压没。
- 删除 SearchTree/SearchCategories 死代码(零调用方,且停留在 Search
修复前的单路口径:无词法融合、0.05 阈值)。
顺带修掉 Add 的 O(N)/写:buildTreeLocked 逐条重算向量(s.vec 里已有)
改为一次建表复用;.index.json 改为标脏 + Flush/Stop 收口。实测单条 Add
2.1ms@50 → 13.7ms@400 压平到 ~400µs(34×)。
存量目录改名迁移落在 migrate_names.go,只报告不改名(os.Rename 不可逆),
冲突整批拒绝以免半迁移。
300 lines
9.3 KiB
Go
300 lines
9.3 KiB
Go
package knowledge
|
||
|
||
import (
|
||
"errors"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// 本轮加固的回归测试。每条都对应一次实测复现,注释里留了复现现象,
|
||
// 免得后来者以为这些分支是过度防御而删掉。
|
||
|
||
// 知识名必须与盘上目录逐字一致,且重启前后不变。
|
||
//
|
||
// 复现(修复前):Add("tech/ Go /Note") 得到 id="tech/_go_/note",
|
||
// 而建目录时逐段 sanitize 得到 "tech/_go/note" —— 内存键与盘上目录从
|
||
// 第一次落盘起就不一致。重启后 scanDir 读回 "tech/_go/note",
|
||
// knowledge_list / knowledge_delete 的 key 全部对不上。
|
||
func TestNameStableAcrossRestart(t *testing.T) {
|
||
dir := t.TempDir()
|
||
s := NewStore(dir)
|
||
if err := s.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
inputs := []string{"tech/ Go /Note", "Tech/Go/并发", "coffee"}
|
||
for _, in := range inputs {
|
||
if err := s.Add(in, "正文 "+in); err != nil {
|
||
t.Fatalf("Add(%q): %v", in, err)
|
||
}
|
||
}
|
||
before := s.List()
|
||
s.Stop()
|
||
|
||
s2 := NewStore(dir)
|
||
if err := s2.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer s2.Stop()
|
||
after := s2.List()
|
||
|
||
if strings.Join(before, "\x00") != strings.Join(after, "\x00") {
|
||
t.Fatalf("重启前后知识名不一致:\n 前 %v\n 后 %v", before, after)
|
||
}
|
||
|
||
// 内存键必须能在盘上找到同名目录
|
||
for _, id := range after {
|
||
p := filepath.Join(dir, filepath.FromSlash(id), "content.md")
|
||
if _, err := os.Stat(p); err != nil {
|
||
t.Errorf("知识 %q 的盘上路径不存在: %v", id, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 知识名不得逃出知识根。
|
||
//
|
||
// 复现(修复前):Add("../../escaped") 无报错写到根外,重启 scanAll 扫不到
|
||
// => 幽灵条目;Remove("..") 直接 RemoveAll 掉整个数据目录(实测返回 nil)。
|
||
func TestNameRejectsEscape(t *testing.T) {
|
||
base := t.TempDir()
|
||
root := filepath.Join(base, "data", "knowledge")
|
||
s := NewStore(root)
|
||
if err := s.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer s.Stop()
|
||
|
||
bad := []string{"", " ", "..", "../..", "../../escaped", "a/../../..", ".hidden", "a//b", "a/./b"}
|
||
for _, name := range bad {
|
||
if err := s.Add(name, "不该被写入"); !errors.Is(err, ErrInvalidName) {
|
||
t.Errorf("Add(%q) 应返回 ErrInvalidName,实为 %v", name, err)
|
||
}
|
||
}
|
||
if n := len(s.List()); n != 0 {
|
||
t.Fatalf("非法名不应产生条目,实有 %d 条: %v", n, s.List())
|
||
}
|
||
|
||
// 根外不得出现任何东西
|
||
if _, err := os.Stat(filepath.Join(base, "data", "escaped")); err == nil {
|
||
t.Error("发生了根外写入")
|
||
}
|
||
|
||
// Remove 同样不得越界,且知识根必须还在
|
||
if err := s.Remove("../.."); !errors.Is(err, ErrInvalidName) {
|
||
t.Errorf("Remove(\"../..\") 应返回 ErrInvalidName,实为 %v", err)
|
||
}
|
||
if _, err := os.Stat(root); err != nil {
|
||
t.Fatalf("知识根被删掉了: %v", err)
|
||
}
|
||
}
|
||
|
||
// 分类条目必须能用全名删掉,且文件真的消失。
|
||
//
|
||
// 复现(修复前):List() 给 "tech/go/并发",Remove 却拼出根下 "tech/go/并发"
|
||
// 之外的路径,os.RemoveAll 删空目录返 nil => 工具回报"已删除",
|
||
// content.md 与索引条目都还在。
|
||
func TestRemoveCategorizedByFullName(t *testing.T) {
|
||
dir := t.TempDir()
|
||
s := NewStore(dir)
|
||
if err := s.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer s.Stop()
|
||
|
||
if err := s.Add("tech/go/并发", "goroutine 调度 GMP 抢占"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := s.Add("coffee", "手冲 烘焙 水温"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
if err := s.Remove("tech/go/并发"); err != nil {
|
||
t.Fatalf("Remove 全名失败: %v", err)
|
||
}
|
||
if _, err := os.Stat(filepath.Join(dir, "tech", "go", "并发", "content.md")); err == nil {
|
||
t.Error("报成功但 content.md 仍在盘上")
|
||
}
|
||
if got := s.List(); len(got) != 1 || got[0] != "coffee" {
|
||
t.Errorf("删除后 List 应为 [coffee],实为 %v", got)
|
||
}
|
||
// 空掉的分类目录应被清掉,不留一片空壳
|
||
if _, err := os.Stat(filepath.Join(dir, "tech")); err == nil {
|
||
t.Error("分类空目录 tech/ 未清理")
|
||
}
|
||
// 知识根绝不能被一起删掉
|
||
if _, err := os.Stat(dir); err != nil {
|
||
t.Fatalf("知识根被删掉了: %v", err)
|
||
}
|
||
}
|
||
|
||
// 叶名寻址:唯一命中时接受(界面历史数据兼容),多条同名时拒绝而不是猜。
|
||
func TestRemoveByLeafName(t *testing.T) {
|
||
dir := t.TempDir()
|
||
s := NewStore(dir)
|
||
if err := s.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer s.Stop()
|
||
|
||
if err := s.Add("tech/go/并发", "A"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := s.Remove("并发"); err != nil {
|
||
t.Fatalf("唯一叶名应可删除,实为 %v", err)
|
||
}
|
||
if n := len(s.List()); n != 0 {
|
||
t.Fatalf("叶名删除后应为空,实为 %v", s.List())
|
||
}
|
||
|
||
// 两条同叶名 => 拒绝,且都不能被误删
|
||
if err := s.Add("a/dup", "A"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := s.Add("b/dup", "B"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := s.Remove("dup"); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("歧义叶名应拒绝,实为 %v", err)
|
||
}
|
||
if n := len(s.List()); n != 2 {
|
||
t.Errorf("歧义叶名不得误删,实剩 %v", s.List())
|
||
}
|
||
}
|
||
|
||
// 删除不存在的条目必须报错(webui 的 DELETE 把 error 映射成 404)。
|
||
func TestRemoveNotFound(t *testing.T) {
|
||
dir := t.TempDir()
|
||
s := NewStore(dir)
|
||
if err := s.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer s.Stop()
|
||
|
||
if err := s.Remove("nonexistent"); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("删除不存在条目应返回 ErrNotFound,实为 %v", err)
|
||
}
|
||
// 幂等:再删一次仍是同一个错,不 panic 不误删
|
||
if err := s.Remove("nonexistent"); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("重复删除应仍是 ErrNotFound,实为 %v", err)
|
||
}
|
||
}
|
||
|
||
// 遗留盘上布局(大写、空格)必须仍可寻址与删除。
|
||
//
|
||
// 这是一次真实回归:scanDir 按盘上目录**原样**建键,所以修复前 Add 留下的
|
||
// "Tech/Upper" 在 items 里的键就是 "Tech/Upper",而 normalizeName 会
|
||
// 小写化+替换空格。若 resolve 只查规范名,这些老条目就会"删不掉、除不尽"
|
||
// ——List() 看得到、Remove() 报不存在。
|
||
func TestLegacyLayoutIsResolvable(t *testing.T) {
|
||
dir := t.TempDir()
|
||
legacy := []string{"Tech/Upper", "a/b with space", "tech/_go_/note"}
|
||
for _, d := range legacy {
|
||
p := filepath.Join(dir, filepath.FromSlash(d), "content.md")
|
||
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(p, []byte("遗留正文 "+d), 0644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
s := NewStore(dir)
|
||
if err := s.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer s.Stop()
|
||
|
||
names := s.List()
|
||
if len(names) != len(legacy) {
|
||
t.Fatalf("应载入 %d 条遗留条目,实为 %v", len(legacy), names)
|
||
}
|
||
for _, id := range names {
|
||
if err := s.Remove(id); err != nil {
|
||
t.Errorf("遗留条目 %q 删不掉: %v", id, err)
|
||
}
|
||
}
|
||
if rest := s.List(); len(rest) != 0 {
|
||
t.Errorf("遗留条目未清空,实剩 %v", rest)
|
||
}
|
||
}
|
||
|
||
// 空目录清理绝不能往上走到知识根:root 被删 = 整个知识库连同索引一起没了。
|
||
// 单段名(直接挂在 root 下)是最容易越界的一种——filepath.Dir 恰好等于 root,
|
||
// 若前缀判断写松一格就会命中。
|
||
func TestRemoveNeverDeletesRoot(t *testing.T) {
|
||
for _, name := range []string{"single", "a/b", "a/b/c/d"} {
|
||
dir := t.TempDir()
|
||
s := NewStore(dir)
|
||
if err := s.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := s.Add(name, "正文"); err != nil {
|
||
t.Fatalf("Add(%q): %v", name, err)
|
||
}
|
||
if err := s.Remove(name); err != nil {
|
||
t.Fatalf("Remove(%q): %v", name, err)
|
||
}
|
||
if fi, err := os.Stat(dir); err != nil || !fi.IsDir() {
|
||
t.Fatalf("name=%q 删除后知识根没了: err=%v", name, err)
|
||
}
|
||
// 哨兵:删除后**必须还能写入知识根**。不能用 .index.json 作哨兵——
|
||
// 索引写入已改为标脏延迟到 Flush/Stop,删除后它本就不该立刻存在
|
||
// (那样反而会把「延迟」这个行为给测漏)。
|
||
probe := filepath.Join(dir, "sentinel", "content.md")
|
||
if err := os.MkdirAll(filepath.Dir(probe), 0755); err != nil {
|
||
t.Fatalf("name=%q 删除后知识根不可写(已被破坏): %v", name, err)
|
||
}
|
||
if err := os.WriteFile(probe, []byte("x"), 0644); err != nil {
|
||
t.Fatalf("name=%q 删除后无法在根内建目录: %v", name, err)
|
||
}
|
||
s.Stop()
|
||
}
|
||
}
|
||
|
||
// 分类是 Add 从名字里声明的,不是检索时现推的;重启后必须一致。
|
||
func TestCategorySurvivesRestart(t *testing.T) {
|
||
dir := t.TempDir()
|
||
s := NewStore(dir)
|
||
if err := s.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := s.Add("tech/go/并发", "正文"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
k := s.items["tech/go/并发"]
|
||
if k == nil {
|
||
t.Fatalf("未按规范名建条目,实有 %v", s.List())
|
||
}
|
||
if k.Category != "tech/go" {
|
||
t.Errorf("Add 后 Category 应为 tech/go,实为 %q", k.Category)
|
||
}
|
||
s.Stop()
|
||
|
||
s2 := NewStore(dir)
|
||
if err := s2.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer s2.Stop()
|
||
k2 := s2.items["tech/go/并发"]
|
||
if k2 == nil {
|
||
t.Fatalf("重启后条目名漂移,实有 %v", s2.List())
|
||
}
|
||
if k2.Category != k.Category {
|
||
t.Errorf("重启后 Category 变了:%q -> %q", k.Category, k2.Category)
|
||
}
|
||
// 树里的挂载点应与 Category 一致
|
||
tr := s2.BuildTree()
|
||
node := tr
|
||
for _, part := range strings.Split(k.Category, "/") {
|
||
node = node.Children[part]
|
||
if node == nil {
|
||
t.Fatalf("树里缺少分类节点 %q", part)
|
||
}
|
||
}
|
||
if len(node.Items) != 1 || node.Items[0].Name != "tech/go/并发" {
|
||
t.Errorf("分类节点下应有该条目,实为 %+v", node.Items)
|
||
}
|
||
}
|