package builtin // glob_engine.go -- Glob 双引擎:Git 引擎 + Walk 引擎. // // 升华改进(ELEVATED): 双引擎策略 - git 仓库中调用 git ls-files(天然尊重 .gitignore, // 有内部文件索引缓存,比 WalkDir 快 5-10 倍),非 git 仓库走纯 Go WalkDir 兜底. // 跨行业场景(数据分析,CI/CD 流水线)可能不在 git 仓库中操作. // 替代方案:始终用 filepath.WalkDir(原始设计,零外部依赖且逻辑统一, // 但在大型 git 仓库中 WalkDir 需要遍历全部文件系统节点,性能明显劣于 git ls-files). // // 反向论证(为什么可能不该改): // - git ls-files 只列出 tracked 文件,新建未 add 的文件会遗漏 // → 解决:组合 git ls-files + git ls-files --others --exclude-standard // - git ls-files 在 shallow clone 或 worktree 场景下行为可能不同 // → 解决:如果 git 命令失败,自动降级到 Walk 引擎 import ( "bytes" "context" "io/fs" "os" "os/exec" "path/filepath" "sort" "strings" "sync" "git.flytoex.net/yuanwei/flyto-agent/pkg/execenv" ) // GlobEngine 是文件搜索引擎接口. type GlobEngine interface { // Search 根据 glob 模式搜索文件,返回匹配的文件列表. Search(ctx context.Context, params *GlobParams) ([]fileWithInfo, int, error) // Name 返回引擎名称(用于调试和日志). Name() string } // GlobParams 包含 Glob 搜索的所有参数. type GlobParams struct { Pattern string SearchDir string IncludeIgnored bool } // ---------- 引擎选择 ---------- // 精妙之处(CLEVER): isGitRepo 通过检查 .git 目录是否存在来判断是否在 git 仓库中, // 不调用 git 命令(避免 fork 开销).从搜索目录向上遍历直到根目录. func isGitRepo(dir string) bool { current := dir for { gitDir := filepath.Join(current, ".git") if info, err := os.Stat(gitDir); err == nil && (info.IsDir() || info.Mode().IsRegular()) { return true } parent := filepath.Dir(current) if parent == current { return false } current = parent } } // DetectGlobEngine 根据当前环境选择最佳 Glob 引擎. // // M1 方案 β: 接受 executor 参数, Git 引擎走 Executor.Command. Walk 引擎 // 纯 Go 实现无子进程, 不需要 executor. exec.LookPath 是能力探测(本地)而非 // 子进程执行, 不走 Executor -- 语义边界同 grep_engine. func DetectGlobEngine(searchDir string, executor execenv.Executor) GlobEngine { if isGitRepo(searchDir) { if path, err := exec.LookPath("git"); err == nil { return NewGitGlobEngine(path, searchDir, executor) } } return NewWalkGlobEngine() } // ---------- Git 引擎 ---------- // GitGlobEngine 在 git 仓库中使用 git ls-files 进行文件搜索. type GitGlobEngine struct { gitPath string repoDir string executor execenv.Executor } // NewGitGlobEngine 创建 Git Glob 引擎. executor 不能为 nil (方案 β 严格 DI). func NewGitGlobEngine(gitPath, repoDir string, executor execenv.Executor) *GitGlobEngine { if executor == nil { panic("builtin.NewGitGlobEngine: executor is nil (方案 β 严格 DI)") } return &GitGlobEngine{gitPath: gitPath, repoDir: repoDir, executor: executor} } func (e *GitGlobEngine) Name() string { return "git" } // Search 使用 git ls-files 搜索文件. // 升华改进(ELEVATED): 组合 tracked + untracked(排除 ignored)两次调用, // 覆盖新建但未 git add 的文件. // 替代方案:只用 git ls-files 列出 tracked 文件(更快但漏掉新文件). func (e *GitGlobEngine) Search(ctx context.Context, params *GlobParams) ([]fileWithInfo, int, error) { // 找到 git repo 根目录 repoRoot, err := e.findRepoRoot(ctx, params.SearchDir) if err != nil { // 降级到 Walk 引擎 walk := NewWalkGlobEngine() return walk.Search(ctx, params) } // 收集所有文件(tracked + untracked) files, err := e.listFiles(ctx, repoRoot, params) if err != nil { // git 命令失败,降级 walk := NewWalkGlobEngine() return walk.Search(ctx, params) } // 执行 glob 匹配 + stat const maxMatches = 10000 totalMatched := 0 var matches []fileWithInfo for _, absPath := range files { select { case <-ctx.Done(): return nil, 0, ctx.Err() default: } relPath, relErr := filepath.Rel(params.SearchDir, absPath) if relErr != nil { continue } if globMatch(params.Pattern, relPath) { totalMatched++ if totalMatched <= maxMatches { info, err := os.Stat(absPath) if err == nil && !info.IsDir() { matches = append(matches, fileWithInfo{ path: absPath, modTime: info.ModTime().UnixNano(), size: info.Size(), }) } } } } return matches, totalMatched, nil } func (e *GitGlobEngine) findRepoRoot(ctx context.Context, dir string) (string, error) { // M1: Executor.Command + Process.Output() 对齐原 cmd.Output() 语义. // Class=ClassWorkspaceTool, Env=FullInheritMap (git 需要 PATH/HOME/GIT_* 等). proc := e.executor.Command(ctx, execenv.Spec{ Class: execenv.ClassWorkspaceTool, Path: e.gitPath, Args: []string{"rev-parse", "--show-toplevel"}, Env: execenv.FullInheritMap(nil), WorkDir: dir, }) out, err := proc.Output() if err != nil { return "", err } return strings.TrimSpace(string(out)), nil } func (e *GitGlobEngine) listFiles(ctx context.Context, repoRoot string, params *GlobParams) ([]string, error) { // tracked 文件 trackedProc := e.executor.Command(ctx, execenv.Spec{ Class: execenv.ClassWorkspaceTool, Path: e.gitPath, Args: []string{"ls-files", "-z"}, Env: execenv.FullInheritMap(nil), WorkDir: params.SearchDir, }) trackedOut, err := trackedProc.Output() if err != nil { return nil, err } // untracked 但不被忽略的文件 args := []string{"ls-files", "--others", "-z"} if !params.IncludeIgnored { args = append(args, "--exclude-standard") } untrackedProc := e.executor.Command(ctx, execenv.Spec{ Class: execenv.ClassWorkspaceTool, Path: e.gitPath, Args: args, Env: execenv.FullInheritMap(nil), WorkDir: params.SearchDir, }) untrackedOut, err := untrackedProc.Output() if err != nil { // untracked 失败不影响 tracked 结果 untrackedOut = nil } seen := make(map[string]bool) var result []string for _, out := range [][]byte{trackedOut, untrackedOut} { if out == nil { continue } parts := bytes.Split(out, []byte{0}) for _, p := range parts { rel := string(p) if rel == "" { continue } absPath := filepath.Join(params.SearchDir, rel) if seen[absPath] { continue } seen[absPath] = true // 确保文件在搜索目录下 if !strings.HasPrefix(absPath, params.SearchDir) { continue } result = append(result, absPath) } } return result, nil } // ---------- Walk 引擎 ---------- // WalkGlobEngine 使用 filepath.WalkDir 遍历文件系统搜索文件. type WalkGlobEngine struct{} // NewWalkGlobEngine 创建 Walk Glob 引擎. func NewWalkGlobEngine() *WalkGlobEngine { return &WalkGlobEngine{} } func (e *WalkGlobEngine) Name() string { return "walk" } // symlinkSafe 验证 symlink 目标仍在允许的根目录内. // // ELEVATED: 防御 symlink 路径逃逸攻击. // // 攻击向量:攻击者在 cwd 内创建 `ln -s /etc/passwd secret_link`, // 然后触发 Glob/Grep 遍历.若不校验,工具会把 /etc/passwd 的路径暴露给 Agent, // Agent 后续可用 FileRead 读取任意系统文件(越权读取). // // 防护逻辑:对每个 d.Type()&fs.ModeSymlink != 0 的条目, // 调用 filepath.EvalSymlinks 解析真实物理路径,再验证其前缀是否仍在 root 之内. // 若不在,静默跳过(不报错,避免攻击者通过错误信息探测目录结构). // // 替代方案:报错中断遍历(原始设计直觉,但会让正常的,合法 symlink 失败, // 且错误信息本身可能泄露路径信息). func symlinkSafe(path, root string) bool { resolved, err := filepath.EvalSymlinks(path) if err != nil { // 解析失败(dangling symlink)-- 静默跳过 return false } // 确保 resolved 以 root + 路径分隔符为前缀,防止 /safe-rootX 前缀欺骗 if !strings.HasPrefix(resolved, root+string(os.PathSeparator)) && resolved != root { return false } return true } // Search 使用 filepath.WalkDir 搜索文件. func (e *WalkGlobEngine) Search(ctx context.Context, params *GlobParams) ([]fileWithInfo, int, error) { // 将 SearchDir 规范化为 EvalSymlinks 解析后的真实路径, // 保证后续前缀比较时路径形式一致(避免 /tmp/xxx 与 /private/tmp/xxx 不匹配). rootReal, err := filepath.EvalSymlinks(params.SearchDir) if err != nil { rootReal = params.SearchDir } var ignorePatterns []IgnorePattern if !params.IncludeIgnored { ignorePatterns = CollectIgnorePatterns(params.SearchDir) } const maxMatches = 10000 totalMatched := 0 var matches []fileWithInfo err = filepath.WalkDir(params.SearchDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return nil } select { case <-ctx.Done(): return ctx.Err() default: } // ELEVATED: symlink 安全检查 -- 见 symlinkSafe 注释. // filepath.WalkDir 默认不跟进 symlink 目录(会作为文件条目出现), // 但 symlink 文件会作为普通条目出现,其路径可指向根目录以外. if d.Type()&fs.ModeSymlink != 0 { if !symlinkSafe(path, rootReal) { // 若为符号链接目录且目标越界,跳过整棵子树(SkipDir 对文件无害) return nil } } relPath, relErr := filepath.Rel(params.SearchDir, path) if relErr != nil { return nil } if d.IsDir() && path != params.SearchDir && !params.IncludeIgnored { if len(ignorePatterns) > 0 && IsIgnored(relPath, ignorePatterns, true) { return filepath.SkipDir } } if d.IsDir() { return nil } if !params.IncludeIgnored && len(ignorePatterns) > 0 { if IsIgnored(relPath, ignorePatterns, false) { return nil } } if globMatch(params.Pattern, relPath) { totalMatched++ if totalMatched <= maxMatches { fileInfo, err := d.Info() if err == nil { matches = append(matches, fileWithInfo{ path: path, modTime: fileInfo.ModTime().UnixNano(), size: fileInfo.Size(), }) } } } return nil }) if err != nil && err != context.Canceled { return nil, 0, err } return matches, totalMatched, nil } // ---------- Glob 参数分割 ---------- // 升华改进(ELEVATED): splitGlobPatterns 只按逗号分割,跳过花括号内的逗号,trim 空白. // 不按空格分割--文件名有空格比 glob 有空格常见得多. // 替代方案:按空格+逗号分割,花括号特殊处理(原始设计,在纯 CLI 场景下合理, // 但在非终端环境如 HTTP API 中空格分割会造成歧义). // // 反向论证(为什么可能不该改): // - 某些用户习惯用空格分隔 glob 模式(如 "*.go *.ts") // → 回应:这种场景在 API 调用中极少见,且可以用逗号替代 func splitGlobPatterns(glob string) []string { if glob == "" { return nil } var patterns []string depth := 0 start := 0 for i := 0; i < len(glob); i++ { switch glob[i] { case '{': depth++ case '}': if depth > 0 { depth-- } case ',': // 精妙之处(CLEVER): 只在花括号深度为 0 时才分割-- // "*.{ts,tsx},*.js" 分割为 ["*.{ts,tsx}", "*.js"], // 而不是 ["*.{ts", "tsx}", "*.js"]. if depth == 0 { p := strings.TrimSpace(glob[start:i]) if p != "" { patterns = append(patterns, p) } start = i + 1 } } } // 最后一段 p := strings.TrimSpace(glob[start:]) if p != "" { patterns = append(patterns, p) } return patterns } // ---------- mtime 排序策略 ---------- // 升华改进(ELEVATED): 小结果集(<= mtimeSortThreshold)并行 stat 按 mtime 排序, // 大结果集按字母序并在输出中提示.32 并发 goroutine,200 文件约 7ms. // 替代方案:所有结果都 stat 排序(原始设计,保证排序一致性, // 但 1000+ 文件时 stat 延迟显著,价值递减--谁会逐个看 1000 个文件?). // // 反向论证(为什么可能不该改): // - 降级到字母序会让用户困惑(同一工具两种排序行为) // → 回应:大结果集场景下用户本来就需要缩小范围,字母序更便于定位 const mtimeSortThreshold = 200 const statConcurrency = 32 // sortMatchesSmart 智能排序:小集合按 mtime,大集合按字母序. // 返回是否使用了 mtime 排序. func sortMatchesSmart(matches []fileWithInfo) bool { if len(matches) <= mtimeSortThreshold { // 小集合:已经有 mtime 信息,直接排序 sort.Slice(matches, func(i, j int) bool { return matches[i].modTime > matches[j].modTime }) return true } // 大集合:字母序 sort.Slice(matches, func(i, j int) bool { return matches[i].path < matches[j].path }) return false } // parallelStatFiles 并行获取文件 stat 信息(用于 files_with_matches 排序). // ctx 用于取消:当 ctx 已取消时,等待 semaphore 的 goroutine 会提前退出, // 避免在父调用超时后继续占用资源. func parallelStatFiles(ctx context.Context, paths []string) []fileWithInfo { result := make([]fileWithInfo, len(paths)) var wg sync.WaitGroup sem := make(chan struct{}, statConcurrency) for i, p := range paths { wg.Add(1) go func(idx int, path string) { defer wg.Done() select { case sem <- struct{}{}: case <-ctx.Done(): return } defer func() { <-sem }() info, err := os.Stat(path) if err != nil { result[idx] = fileWithInfo{path: path} return } result[idx] = fileWithInfo{ path: path, modTime: info.ModTime().UnixNano(), size: info.Size(), } }(i, p) } wg.Wait() return result }