// glob_test.go -- Glob 工具的单元测试(双引擎版). // // 覆盖场景: // - 基本通配符匹配(*.txt) // - ** 递归匹配 // - 不匹配时返回提示 // - 搜索路径不存在报错 // - globMatch 函数各种模式 // - 文件大小信息显示 // - 双引擎切换(git 仓库 / 非 git 仓库) // - Glob 逗号分割(含花括号保护) // - mtime 排序阈值降级 // - include_ignored 强制包含 // - 引擎选择逻辑 package builtin import ( "context" "encoding/json" "os" "os/exec" "path/filepath" "strings" "testing" "git.flytoex.net/yuanwei/flyto-agent/pkg/execenv" ) // TestGlobTool_BasicMatch 测试基本通配符匹配 func TestGlobTool_BasicMatch(t *testing.T) { tool := NewGlobTool(execenv.DefaultExecutor{}) dir := t.TempDir() // 创建测试文件 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("aaa"), 0644) os.WriteFile(filepath.Join(dir, "b.txt"), []byte("bbb"), 0644) os.WriteFile(filepath.Join(dir, "c.go"), []byte("ccc"), 0644) input, _ := json.Marshal(globInput{Pattern: "*.txt", Path: dir}) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("执行失败: %v", err) } if result.IsError { t.Fatalf("不应标记为错误: %s", result.Output) } if !strings.Contains(result.Output, "a.txt") || !strings.Contains(result.Output, "b.txt") { t.Errorf("应匹配到 a.txt 和 b.txt: %s", result.Output) } if strings.Contains(result.Output, "c.go") { t.Errorf("不应匹配到 c.go: %s", result.Output) } } // TestGlobTool_RecursiveMatch 测试 ** 递归匹配 func TestGlobTool_RecursiveMatch(t *testing.T) { tool := NewGlobTool(execenv.DefaultExecutor{}) dir := t.TempDir() // 创建嵌套目录结构 subDir := filepath.Join(dir, "sub", "deep") os.MkdirAll(subDir, 0755) os.WriteFile(filepath.Join(dir, "top.go"), []byte("top"), 0644) os.WriteFile(filepath.Join(dir, "sub", "mid.go"), []byte("mid"), 0644) os.WriteFile(filepath.Join(subDir, "bot.go"), []byte("bot"), 0644) input, _ := json.Marshal(globInput{Pattern: "**/*.go", Path: dir}) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("执行失败: %v", err) } if result.IsError { t.Fatalf("不应标记为错误: %s", result.Output) } // 应匹配到所有 .go 文件 if !strings.Contains(result.Output, "top.go") { t.Errorf("应匹配到 top.go: %s", result.Output) } if !strings.Contains(result.Output, "mid.go") { t.Errorf("应匹配到 mid.go: %s", result.Output) } if !strings.Contains(result.Output, "bot.go") { t.Errorf("应匹配到 bot.go: %s", result.Output) } } // TestGlobTool_NoMatch 测试没有匹配项 func TestGlobTool_NoMatch(t *testing.T) { tool := NewGlobTool(execenv.DefaultExecutor{}) dir := t.TempDir() input, _ := json.Marshal(globInput{Pattern: "*.xyz", Path: dir}) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("执行失败: %v", err) } if !strings.Contains(result.Output, "No files matched") { t.Errorf("无匹配应有提示: %s", result.Output) } } // TestGlobTool_EmptyPattern 测试空模式 func TestGlobTool_EmptyPattern(t *testing.T) { tool := NewGlobTool(execenv.DefaultExecutor{}) input, _ := json.Marshal(globInput{Pattern: ""}) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("不应返回 Go error: %v", err) } if !result.IsError { t.Error("空模式应报错") } } // TestGlobTool_DirNotExist 测试搜索目录不存在 func TestGlobTool_DirNotExist(t *testing.T) { tool := NewGlobTool(execenv.DefaultExecutor{}) input, _ := json.Marshal(globInput{Pattern: "*.go", Path: "/nonexistent/dir"}) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("不应返回 Go error: %v", err) } if !result.IsError { t.Error("不存在的目录应报错") } } // TestGlobMatch 测试 globMatch 函数的各种模式 func TestGlobMatch(t *testing.T) { tests := []struct { name string pattern string path string want bool }{ {"精确匹配", "main.go", "main.go", true}, {"通配符匹配", "*.go", "main.go", true}, {"通配符不匹配", "*.go", "main.ts", false}, {"** 匹配子目录", "**/*.go", "src/main.go", true}, {"** 匹配深层目录", "**/*.go", "a/b/c/main.go", true}, {"** 匹配根目录文件", "**/*.go", "main.go", true}, {"前缀目录匹配", "src/**/*.go", "src/pkg/main.go", true}, {"前缀目录不匹配", "src/**/*.go", "pkg/main.go", false}, {"单纯 **", "**", "anything/at/all.go", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := globMatch(tt.pattern, tt.path) if got != tt.want { t.Errorf("globMatch(%q, %q) = %v, 期望 %v", tt.pattern, tt.path, got, tt.want) } }) } } // TestGlobTool_FileSize 测试输出包含文件大小信息 func TestGlobTool_FileSize(t *testing.T) { tool := NewGlobTool(execenv.DefaultExecutor{}) dir := t.TempDir() os.WriteFile(filepath.Join(dir, "test.txt"), []byte("hello world"), 0644) input, _ := json.Marshal(globInput{Pattern: "*.txt", Path: dir}) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("执行失败: %v", err) } // 输出应包含文件大小信息 if !strings.Contains(result.Output, "bytes") { t.Errorf("输出应包含文件大小: %s", result.Output) } } // TestGlobTool_Metadata 测试工具元数据 func TestGlobTool_Metadata(t *testing.T) { tool := NewGlobTool(execenv.DefaultExecutor{}) meta := tool.Metadata() if !meta.ConcurrencySafe { t.Error("Glob 应标记为 ConcurrencySafe") } if !meta.ReadOnly { t.Error("Glob 应标记为 ReadOnly") } if tool.Name() != "Glob" { t.Errorf("期望名称 'Glob', 实际 %q", tool.Name()) } } // ---------- 双引擎测试 ---------- // TestGlobEngine_WalkEngine 测试 Walk 引擎(非 git 仓库路径) func TestGlobEngine_WalkEngine(t *testing.T) { dir := t.TempDir() // 创建测试文件 os.WriteFile(filepath.Join(dir, "hello.go"), []byte("package main"), 0644) os.WriteFile(filepath.Join(dir, "world.txt"), []byte("hello"), 0644) engine := NewWalkGlobEngine() if engine.Name() != "walk" { t.Errorf("引擎名称应为 'walk', 实际 %q", engine.Name()) } matches, total, err := engine.Search(context.Background(), &GlobParams{ Pattern: "*.go", SearchDir: dir, }) if err != nil { t.Fatalf("搜索失败: %v", err) } if total != 1 { t.Errorf("应找到 1 个文件, 实际 %d", total) } if len(matches) != 1 { t.Fatalf("matches 应有 1 项, 实际 %d", len(matches)) } if !strings.HasSuffix(matches[0].path, "hello.go") { t.Errorf("应匹配 hello.go, 实际 %s", matches[0].path) } } // TestGlobEngine_GitEngine 测试 Git 引擎(git 仓库路径) func TestGlobEngine_GitEngine(t *testing.T) { // 检查 git 是否可用 if _, err := exec.LookPath("git"); err != nil { t.Skip("git 不可用,跳过 Git 引擎测试") } dir := t.TempDir() // 初始化 git 仓库 cmd := exec.Command("git", "init") cmd.Dir = dir if err := cmd.Run(); err != nil { t.Fatalf("git init 失败: %v", err) } // 配置 git(临时目录需要) exec.Command("git", "-C", dir, "config", "user.email", "test@test.com").Run() exec.Command("git", "-C", dir, "config", "user.name", "test").Run() // 创建并添加文件 os.WriteFile(filepath.Join(dir, "tracked.go"), []byte("package main"), 0644) os.WriteFile(filepath.Join(dir, "untracked.go"), []byte("package other"), 0644) exec.Command("git", "-C", dir, "add", "tracked.go").Run() exec.Command("git", "-C", dir, "commit", "-m", "init").Run() // Git 引擎应找到 tracked + untracked 文件 gitPath, _ := exec.LookPath("git") engine := NewGitGlobEngine(gitPath, dir, execenv.DefaultExecutor{}) if engine.Name() != "git" { t.Errorf("引擎名称应为 'git', 实际 %q", engine.Name()) } matches, total, err := engine.Search(context.Background(), &GlobParams{ Pattern: "*.go", SearchDir: dir, }) if err != nil { t.Fatalf("搜索失败: %v", err) } if total < 1 { t.Errorf("应至少找到 1 个文件, 实际 %d", total) } // 验证 tracked 文件 foundTracked := false foundUntracked := false for _, m := range matches { if strings.HasSuffix(m.path, "tracked.go") && !strings.HasSuffix(m.path, "untracked.go") { foundTracked = true } if strings.HasSuffix(m.path, "untracked.go") { foundUntracked = true } } if !foundTracked { t.Error("应找到 tracked.go") } if !foundUntracked { t.Error("应找到 untracked.go(git ls-files --others)") } } // TestGlobEngine_DetectEngine 测试引擎自动检测 func TestGlobEngine_DetectEngine(t *testing.T) { // 非 git 目录应选择 Walk 引擎 dir := t.TempDir() engine := DetectGlobEngine(dir, execenv.DefaultExecutor{}) if engine.Name() != "walk" { t.Errorf("非 git 目录应选择 walk 引擎, 实际 %q", engine.Name()) } // git 目录应选择 Git 引擎(如果 git 可用) if _, err := exec.LookPath("git"); err == nil { gitDir := t.TempDir() exec.Command("git", "-C", gitDir, "init").Run() engine = DetectGlobEngine(gitDir, execenv.DefaultExecutor{}) if engine.Name() != "git" { t.Errorf("git 目录应选择 git 引擎, 实际 %q", engine.Name()) } } } // TestIsGitRepo 测试 git 仓库检测 func TestIsGitRepo(t *testing.T) { // 非 git 目录 dir := t.TempDir() if isGitRepo(dir) { t.Error("临时目录不应被识别为 git 仓库") } // git 目录 if _, err := exec.LookPath("git"); err == nil { gitDir := t.TempDir() exec.Command("git", "-C", gitDir, "init").Run() if !isGitRepo(gitDir) { t.Error("初始化后的目录应被识别为 git 仓库") } // 子目录也应被识别 subDir := filepath.Join(gitDir, "sub", "deep") os.MkdirAll(subDir, 0755) if !isGitRepo(subDir) { t.Error("git 仓库的子目录也应被识别") } } } // ---------- splitGlobPatterns 测试 ---------- // TestSplitGlobPatterns 测试 Glob 模式分割 func TestSplitGlobPatterns(t *testing.T) { tests := []struct { name string input string expected []string }{ {"空字符串", "", nil}, {"单个模式", "*.go", []string{"*.go"}}, {"逗号分割", "*.go,*.ts", []string{"*.go", "*.ts"}}, {"逗号分割带空格", "*.go, *.ts, *.js", []string{"*.go", "*.ts", "*.js"}}, // 精妙之处(CLEVER): 花括号内的逗号不作为分隔符 {"花括号保护", "*.{ts,tsx},*.js", []string{"*.{ts,tsx}", "*.js"}}, {"嵌套花括号", "*.{a,{b,c}},*.d", []string{"*.{a,{b,c}}", "*.d"}}, {"只有花括号", "*.{go,ts}", []string{"*.{go,ts}"}}, {"尾部逗号", "*.go,", []string{"*.go"}}, {"前导逗号", ",*.go", []string{"*.go"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := splitGlobPatterns(tt.input) if len(got) != len(tt.expected) { t.Fatalf("splitGlobPatterns(%q) 返回 %d 项 %v, 期望 %d 项 %v", tt.input, len(got), got, len(tt.expected), tt.expected) } for i, g := range got { if g != tt.expected[i] { t.Errorf("splitGlobPatterns(%q)[%d] = %q, 期望 %q", tt.input, i, g, tt.expected[i]) } } }) } } // ---------- 排序策略测试 ---------- // TestSortMatchesSmart_MtimeSort 测试小集合按 mtime 排序 func TestSortMatchesSmart_MtimeSort(t *testing.T) { matches := []fileWithInfo{ {path: "old.go", modTime: 100}, {path: "new.go", modTime: 300}, {path: "mid.go", modTime: 200}, } usedMtime := sortMatchesSmart(matches) if !usedMtime { t.Error("小集合应使用 mtime 排序") } if matches[0].path != "new.go" || matches[1].path != "mid.go" || matches[2].path != "old.go" { t.Errorf("mtime 排序顺序不对: %v", matches) } } // TestSortMatchesSmart_AlphaSort 测试大集合按字母序排序 func TestSortMatchesSmart_AlphaSort(t *testing.T) { // 生成超过阈值的集合 matches := make([]fileWithInfo, mtimeSortThreshold+1) for i := range matches { matches[i] = fileWithInfo{ path: filepath.Join("dir", strings.Repeat("z", 3-len(string(rune('a'+i%26))))+string(rune('a'+i%26))+".go"), modTime: int64(1000 - i), // 故意倒序 mtime } } usedMtime := sortMatchesSmart(matches) if usedMtime { t.Error("大集合不应使用 mtime 排序") } // 验证是字母序 for i := 1; i < len(matches); i++ { if matches[i].path < matches[i-1].path { t.Errorf("字母序排序不对: [%d]=%q < [%d]=%q", i, matches[i].path, i-1, matches[i-1].path) break } } } // TestSortMatchesSmart_Threshold 测试阈值边界 func TestSortMatchesSmart_Threshold(t *testing.T) { // 恰好等于阈值应用 mtime matches := make([]fileWithInfo, mtimeSortThreshold) for i := range matches { matches[i] = fileWithInfo{path: "f.go", modTime: int64(i)} } if !sortMatchesSmart(matches) { t.Errorf("恰好 %d 个应使用 mtime 排序", mtimeSortThreshold) } // 超过阈值 1 个应用字母序 matches = make([]fileWithInfo, mtimeSortThreshold+1) for i := range matches { matches[i] = fileWithInfo{path: "f.go", modTime: int64(i)} } if sortMatchesSmart(matches) { t.Errorf("超过 %d 个不应使用 mtime 排序", mtimeSortThreshold) } } // TestParallelStatFiles 测试并行 stat func TestParallelStatFiles(t *testing.T) { dir := t.TempDir() var paths []string for i := 0; i < 10; i++ { p := filepath.Join(dir, strings.Repeat("a", i+1)+".txt") os.WriteFile(p, []byte("data"), 0644) paths = append(paths, p) } infos := parallelStatFiles(context.Background(), paths) if len(infos) != 10 { t.Fatalf("应返回 10 个文件信息, 实际 %d", len(infos)) } for i, info := range infos { if info.path != paths[i] { t.Errorf("路径不匹配: %q != %q", info.path, paths[i]) } if info.size != 4 { t.Errorf("文件大小应为 4, 实际 %d", info.size) } } } // ---------- symlink 安全测试 ---------- // TestGlobTool_SymlinkEscape 验证 symlink 路径逃逸漏洞已修复. // // 攻击模型:cwd 内有 `ln -s /etc/passwd symlink_to_passwd`, // Glob 遍历时若不检查 symlink 目标,该路径会出现在结果中, // 进而被 FileRead 读取,造成越权文件访问. func TestGlobTool_SymlinkEscape(t *testing.T) { tool := NewGlobTool(execenv.DefaultExecutor{}) dir := t.TempDir() // 在 dir 内放一个合法文件 os.WriteFile(filepath.Join(dir, "safe.txt"), []byte("safe"), 0644) // 创建一个指向 dir 外的 symlink(模拟攻击) outside := t.TempDir() os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret"), 0644) symlinkPath := filepath.Join(dir, "evil_link.txt") if err := os.Symlink(filepath.Join(outside, "secret.txt"), symlinkPath); err != nil { t.Skipf("无法创建 symlink(可能是无权限),跳过: %v", err) } input, _ := json.Marshal(globInput{Pattern: "*.txt", Path: dir}) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("执行失败: %v", err) } if result.IsError { t.Fatalf("不应标记为错误: %s", result.Output) } // 合法文件应出现 if !strings.Contains(result.Output, "safe.txt") { t.Errorf("应匹配到 safe.txt: %s", result.Output) } // 越界 symlink 不应出现 if strings.Contains(result.Output, "evil_link.txt") { t.Errorf("越界 symlink 不应出现在结果中(symlink 逃逸漏洞): %s", result.Output) } } // TestWalkGlobEngine_SymlinkWithinRoot 验证指向根目录内的 symlink 不被误杀. func TestWalkGlobEngine_SymlinkWithinRoot(t *testing.T) { dir := t.TempDir() // 创建真实文件 os.WriteFile(filepath.Join(dir, "real.go"), []byte("package main"), 0644) // 创建指向根目录内部文件的 symlink(合法,应允许) symlinkPath := filepath.Join(dir, "link.go") if err := os.Symlink(filepath.Join(dir, "real.go"), symlinkPath); err != nil { t.Skipf("无法创建 symlink,跳过: %v", err) } engine := NewWalkGlobEngine() matches, total, err := engine.Search(context.Background(), &GlobParams{ Pattern: "*.go", SearchDir: dir, }) if err != nil { t.Fatalf("搜索失败: %v", err) } // real.go 必须出现;link.go 指向根内,也可以出现(不是漏洞) if total < 1 { t.Errorf("至少应匹配 1 个文件, 实际 %d", total) } _ = matches } // TestGlobTool_IncludeIgnored 测试 include_ignored 参数 func TestGlobTool_IncludeIgnored(t *testing.T) { tool := NewGlobTool(execenv.DefaultExecutor{}) dir := t.TempDir() // 创建 .gitignore 排除 *.log os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.log\n"), 0644) os.WriteFile(filepath.Join(dir, "app.go"), []byte("code"), 0644) os.WriteFile(filepath.Join(dir, "debug.log"), []byte("log"), 0644) // 不 include_ignored - 应排除 .log input, _ := json.Marshal(globInput{Pattern: "*", Path: dir, IncludeIgnored: false}) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("执行失败: %v", err) } if strings.Contains(result.Output, "debug.log") { t.Errorf("不应包含被忽略的 debug.log: %s", result.Output) } // include_ignored - 应包含 .log input, _ = json.Marshal(globInput{Pattern: "*", Path: dir, IncludeIgnored: true}) result, err = tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("执行失败: %v", err) } if !strings.Contains(result.Output, "debug.log") { t.Errorf("include_ignored 应包含 debug.log: %s", result.Output) } }