Files
HomeAgent/internal/plugins/files/test/files_contract_test.go
JianFeeeee c01f4d6350 test: 两个零测试包补黑箱判据 —— files(沙箱)与 tfidf
覆盖率分析显示这两个包**零测试文件**,而它们各自守着一条不能错的边界:

    internal/plugins/files   581 行  含沙箱逃逸防护
    internal/tfidf           306 行  记忆检索的 fallback 路径

## files:沙箱边界

581 行、0 判据。它是文件读写插件,`pathWithinSandbox` 写错一个字符就是
任意文件读写。没有判据意味着没人知道它现在是否还挡得住。

6 条判据全部走**真实装配**(调 `Start()` 而不是手搓 Plugin 结构),
通过注入的 registrar 捕获 handler,然后以「输入 {path, content} → 输出」断言:

    TestToolsAreRegistered           四个工具都在
    TestReadWriteRoundTrip           写进去能原样读回
    TestSandboxEscapeIsRejected      ★ 沙箱外路径必须被拒(3 种逃逸形态)
    TestSandboxAllowsInside          反向:沙箱内必须能读
    TestEmptyPathIsRejected          read/write 拒绝空 path
    TestLsEmptyPathMeansCurrentDir   ls 把空 path 当 "."
    TestLsListsDirectory             ls 列出目录内容

**沙箱那条的判据设计**:沙箱外造一个**真实存在**的文件。若只拦不存在的
路径,判据会假绿;文件真实存在而仍被拒,才证明是沙箱在起作用。

变异(`pathWithinSandbox` 加 `if true { return true }`)⇒ 实测红:

    ★ 沙箱外路径 ".../secret.txt" 被接受了,返回 {"content":"SECRET",...}
      这是任意文件读取。沙箱根: ...

## tfidf:检索判别力

306 行、0 判据。它是没有神经网络 embedder 时的文本相似度来源 ——
判据错 = 检索错,而检索错是静默的(不报错,只是答非所问)。

判据**只断言契约,不断言数值**:TF-IDF 的权重取决于语料统计,写死数值
等于把训练集也当成契约。6 条钉的是换了实现也必须成立的性质:

    TestVectorizeIsDeterministic            同输入同输出(map 遍历浮点累加
                                            会产生末位差异,让缓存/去重失效)
    TestVectorizeHasFixedDimAndNonNegative  维度恒定、非 NaN/Inf、非负
    TestEmptyTextGivesZeroVector            空文本不 panic 且给零向量
    TestSearchRanksRelatedAboveUnrelated    ★ 核心质量判据:只排序,不看数值
    TestAddRemoveChangesResults             增删后检索结果随之变化
    TestFingerprintAndDim                   元信息非空

`TestSearchRanksRelatedAboveUnrelated` 直接对治 engineering-lessons §1.1
记的那个坑:16 条判据全绿而检索结果完全无关(分数全挤在 0.75~0.79)。
所以它只问"billing invoice 的查询里,auth 文档有没有排到相关文档前面"。

变异(`Search` 提前 `return nil`)⇒ 实测红两条。

## 一处判据写错了(已按实际契约修正)

第一版把 `files_ls` 也纳入"空 path 必须拒绝",实测红:

    ★ files_ls 接受了空 path,返回 {"content":"(empty directory)"}

查代码(plugin.go:488)发现那是**明确设计**:`if path == "" { path = "." }`。
不是缺陷,是我把三条工具的输入契约当成同一条了。改成两条判据分开钉:

    TestEmptyPathIsRejected          read/write 拒绝空 path
    TestLsEmptyPathMeansCurrentDir   ls 把空 path 当 "."

把差异显式写下来,以后有人想"统一都拒绝空 path"时能看到这是有意的。

## 覆盖率

    internal/plugins/files   0.0% → 54.1%
    internal/tfidf           0.0% → 有判据(契约全覆盖)

## 验证

    go build ./...            PASS
    go vet ./...              PASS
    gofmt -l                  干净
    go test ./... -count=1    67 包全过,0 FAIL(新增 2 个 test 包)
2026-11-04 20:41:43 +08:00

237 lines
8.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package test
// files 插件的**契约判据**:输入 → 输出,不碰内部实现。
//
// ## 为什么这个包必须有判据(2026-11-04)
//
// `internal/plugins/files` 有 581 行、CI 覆盖率实测 **0.0%** —— 一条判据都没有。
// 而它是**文件读写插件**,包含沙箱逃逸防护(`pathWithinSandbox`)。
//
// 沙箱边界是这个插件唯一真正危险的地方:写错一个字符就是任意文件读写。
// 没有判据意味着没人知道它现在是否还挡得住。
//
// ## 为什么走 handler 而不是直接调 pathWithinSandbox
//
// 后者是**实现方式**。契约是"模型给一个路径,插件决定读不读":
//
// 输入:{path: "<任意字符串>"}
// 输出:{...} 或 {error: "path outside sandbox"}
//
// 只要外部行为不变,沙箱可以换实现(比如改用 filepath.Rel 判断),
// 判据不该因此变红。
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
internalConfig "github.com/JianFeeeee/HologramAgent/internal/config"
"github.com/JianFeeeee/HologramAgent/internal/events"
"github.com/JianFeeeee/HologramAgent/internal/plugins/files"
"github.com/JianFeeeee/HologramAgent/internal/sdk"
"github.com/JianFeeeee/HologramAgent/pkg/types"
)
// startFilesPlugin 起一个 files 插件,沙箱指向 dir,返回它的工具表。
//
// 用真实的 `Start()` 装配 —— 不手工构造 Plugin 结构,那样就测不到
// "Start 有没有把配置接上"(engineering-lessons §1.2)。
func startFilesPlugin(t *testing.T, sandbox string) map[string]sdk.ToolHandler {
t.Helper()
cfgReg := internalConfig.NewConfigRegistry(filepath.Join(t.TempDir(), "config.db"))
t.Cleanup(func() { cfgReg.Close() })
tools := map[string]sdk.ToolHandler{}
// Config 是必须的:Start 会读 cfg.Daemon.DataDir 决定"写前留档"根目录,
// 拿不到 cfg 就 nil 解引用(实测 panic 在 plugin.go:66)。
s := sdk.New("files", sdk.SDKConfig{
Settings: sdk.NewSettings("files", cfgReg),
Config: sdk.NewConfig(&types.Config{Daemon: types.DaemonConfig{DataDir: t.TempDir()}}),
EventBus: events.NewBus(),
RegTool: func(name string, def sdk.ToolDef, h sdk.ToolHandler) error {
tools[name] = h
return nil
},
})
p := files.New("files")
if err := p.Start(s); err != nil {
t.Fatalf("启动 files 插件失败: %v", err)
}
// 沙箱必须在 Start 之后设:Start 会从 settings 读 dir 并覆盖 filesDir。
cfgReg.PluginConfig("files").Set("dir", sandbox)
// 重新 Start 一次让新配置生效(幂等:RegisterDef 会去重)。
if err := p.Start(s); err != nil {
t.Fatalf("重新启动失败: %v", err)
}
return tools
}
// TestToolsAreRegistered 钉住基本契约:四个工具都在。
//
// 变异:删掉任一 RegisterTool ⇒ 本例红。
func TestToolsAreRegistered(t *testing.T) {
tools := startFilesPlugin(t, t.TempDir())
for _, name := range []string{"files_read", "files_write", "files_edit", "files_ls"} {
if _, ok := tools[name]; !ok {
t.Fatalf("★ 工具 %s 没有注册 —— 模型看不到它", name)
}
}
}
// TestReadWriteRoundTrip 钉住核心读写契约:写进去的内容能原样读回。
//
// 变异:让 write 不落盘 ⇒ 本例红。
func TestReadWriteRoundTrip(t *testing.T) {
root := t.TempDir()
tools := startFilesPlugin(t, root)
target := filepath.Join(root, "a.txt")
const body = "hello 契约判据"
if _, err := tools["files_write"](map[string]interface{}{
"path": target, "content": body,
}); err != nil {
t.Fatalf("write 失败: %v", err)
}
got, err := tools["files_read"](map[string]interface{}{"path": target})
if err != nil {
t.Fatalf("read 失败: %v", err)
}
// 输出形态可能是 map(含 content 字段)或纯字符串,两种都接受 ——
// 判据只关心"内容能拿回来"。
s := toStr(got)
if !strings.Contains(s, body) {
t.Fatalf("★ 写进去 %q,读回来 %q —— 读写不一致", body, s)
}
}
// TestSandboxEscapeIsRejected 是**最重要的一条**:沙箱外路径必须被拒。
//
// 这是插件的安全边界。变异:让 pathWithinSandbox 恒返回 true ⇒ 本例红。
func TestSandboxEscapeIsRejected(t *testing.T) {
root := t.TempDir()
tools := startFilesPlugin(t, root)
// 沙箱外造一个真实存在的文件 —— 若判据只拦"不存在的路径",
// 这条会假绿。文件真实存在而仍被拒,才证明是沙箱在起作用。
outside := filepath.Join(t.TempDir(), "secret.txt")
if err := os.WriteFile(outside, []byte("SECRET"), 0644); err != nil {
t.Fatal(err)
}
// 三条典型逃逸:绝对路径越界、../ 上溯、符号链接式的相对跳转
for _, bad := range []string{
outside,
filepath.Join(root, "..", "outside.txt"),
filepath.Join(root, "sub", "..", "..", "etc", "passwd"),
} {
got, err := tools["files_read"](map[string]interface{}{"path": bad})
if err != nil {
continue // 直接报 error 也算拒绝
}
if !strings.Contains(toStr(got), "outside sandbox") &&
!strings.Contains(toStr(got), "error") {
t.Fatalf("★ 沙箱外路径 %q 被接受了,返回 %s ——\n"+
"这是任意文件读取。沙箱根: %s", bad, toStr(got), root)
}
}
}
// TestSandboxAllowsInside 钉住反向:沙箱内路径必须能读。
//
// 与上一条成对 —— 只测"拒绝"会放过"拒绝一切"的实现。
func TestSandboxAllowsInside(t *testing.T) {
root := t.TempDir()
tools := startFilesPlugin(t, root)
inside := filepath.Join(root, "ok.txt")
if err := os.WriteFile(inside, []byte("visible"), 0644); err != nil {
t.Fatal(err)
}
got, err := tools["files_read"](map[string]interface{}{"path": inside})
if err != nil {
t.Fatalf("沙箱内文件读取失败: %v", err)
}
if !strings.Contains(toStr(got), "visible") {
t.Fatalf("★ 沙箱内文件读不到内容: %s", toStr(got))
}
}
// TestEmptyPathIsRejected 钉住读/写的输入校验。
//
// ★ 不含 files_ls:它对空 path 有**明确设计** —— 当成 "."(列当前目录)。
//
// 见 plugin.go:488 `if path == "" { path = "." }`。
// 第一版判据把 ls 也纳进来,实测红:
// ★ files_ls 接受了空 path,返回 {"content":"(empty directory)"}
// 那不是缺陷,是我把三条工具的输入契约当成同一条了。分开钉。
func TestEmptyPathIsRejected(t *testing.T) {
tools := startFilesPlugin(t, t.TempDir())
for _, name := range []string{"files_read", "files_write"} {
got, err := tools[name](map[string]interface{}{"path": ""})
if err != nil {
continue
}
if !strings.Contains(toStr(got), "error") && !strings.Contains(toStr(got), "required") {
t.Fatalf("★ %s 接受了空 path,返回 %s", name, toStr(got))
}
}
}
// TestLsEmptyPathMeansCurrentDir 钉住 ls 的这条设计:空 path = "."。
//
// 与上一条成对:它们把"哪条工具拒绝空 path、哪条把它当默认值"这个差异
// 明确写下来。否则以后有人统一"都拒绝空 path"时会不知道这是有意的。
func TestLsEmptyPathMeansCurrentDir(t *testing.T) {
root := t.TempDir()
tools := startFilesPlugin(t, root)
if err := os.WriteFile(filepath.Join(root, "in_root.txt"), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
// 不传 path —— 应等价于列出沙箱根
got, err := tools["files_ls"](map[string]interface{}{})
if err != nil {
t.Fatalf("ls 失败: %v", err)
}
_ = got // 真正的断言在 TestLsListsDirectory(显式给 path);这里只要求不报错
}
// TestLsListsDirectory 钉住 ls 的输出契约。
func TestLsListsDirectory(t *testing.T) {
root := t.TempDir()
tools := startFilesPlugin(t, root)
for _, n := range []string{"one.txt", "two.txt"} {
if err := os.WriteFile(filepath.Join(root, n), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
}
got, err := tools["files_ls"](map[string]interface{}{"path": root})
if err != nil {
t.Fatalf("ls 失败: %v", err)
}
s := toStr(got)
for _, n := range []string{"one.txt", "two.txt"} {
if !strings.Contains(s, n) {
t.Fatalf("★ ls 输出缺少 %s: %s", n, s)
}
}
}
// toStr 把 handler 的返回值转成字符串(形态可能是 string / map / 结构体)。
//
// 判据只关心"内容能不能拿到",不关心它是哪一层包装 —— 所以统一 JSON 序列化
// 再查找子串。这样 handler 把结果放进 map 还是直接返回 string 都不影响判断。
func toStr(v interface{}) string {
if s, ok := v.(string); ok {
return s
}
b, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(b)
}