Files
2026-06-22 08:55:57 +08:00

210 lines
5.1 KiB
Go

package handlers
import (
"bufio"
"context"
"encoding/json"
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
)
// Grep caps. These bound the cost of a single grep request to avoid a DoS via a
// crafted pattern / huge tree. All are overridable on the handler.
const (
DefaultGrepMaxMatches = 200
DefaultGrepMaxFiles = 5000
DefaultGrepMaxFileSize = 1 << 20 // 1 MiB: skip larger files
DefaultGrepMaxLineLen = 1000 // truncate long matched lines
)
// GrepHandler handles fs/grep: a bounded regexp search under the worktree using a
// pure-Go walk (no shell, no git grep interpolation) confined to the pathsafe
// root. Binary files and oversize files are skipped.
type GrepHandler struct {
MaxMatches int
MaxFiles int
MaxFileSize int64
}
// NewGrepHandler constructs a GrepHandler with default caps.
func NewGrepHandler() *GrepHandler {
return &GrepHandler{
MaxMatches: DefaultGrepMaxMatches,
MaxFiles: DefaultGrepMaxFiles,
MaxFileSize: DefaultGrepMaxFileSize,
}
}
func (h *GrepHandler) maxMatches() int {
if h.MaxMatches > 0 {
return h.MaxMatches
}
return DefaultGrepMaxMatches
}
func (h *GrepHandler) maxFiles() int {
if h.MaxFiles > 0 {
return h.MaxFiles
}
return DefaultGrepMaxFiles
}
func (h *GrepHandler) maxFileSize() int64 {
if h.MaxFileSize > 0 {
return h.MaxFileSize
}
return DefaultGrepMaxFileSize
}
type fsGrepParams struct {
SessionID string `json:"sessionId"`
Pattern string `json:"pattern"`
Path string `json:"path,omitempty"` // sub-path to scope the search; defaults to cwd root
CaseSensitive bool `json:"case_sensitive,omitempty"`
}
type grepMatch struct {
File string `json:"file"` // worktree-relative
Line int `json:"line"` // 1-based
Text string `json:"text"`
}
type fsGrepResult struct {
Matches []grepMatch `json:"matches"`
Truncated bool `json:"truncated"`
}
// Handle runs a regexp search. Returns FsError for path escape (-32001), invalid
// pattern (-32602), or read failure. Match/file caps are honored.
func (h *GrepHandler) Handle(ctx context.Context, sess SessionContext, paramsJSON json.RawMessage) FsResult {
var p fsGrepParams
if err := json.Unmarshal(paramsJSON, &p); err != nil {
return FsResult{Err: &FsError{Code: -32602, Message: "invalid params: " + err.Error()}}
}
if strings.TrimSpace(p.Pattern) == "" {
return FsResult{Err: &FsError{Code: -32602, Message: "pattern required"}}
}
expr := p.Pattern
if !p.CaseSensitive {
expr = "(?i)" + expr
}
re, err := regexp.Compile(expr)
if err != nil {
return FsResult{Err: &FsError{Code: -32602, Message: "invalid pattern: " + err.Error()}}
}
searchPath := p.Path
if searchPath == "" {
searchPath = "."
}
root, err := resolveSafePath(sess.CwdPath, searchPath)
if err != nil {
return FsResult{Err: &FsError{Code: -32001, Message: err.Error()}}
}
cwd := filepath.Clean(sess.CwdPath)
out := fsGrepResult{Matches: make([]grepMatch, 0, 16)}
maxMatches := h.maxMatches()
maxFiles := h.maxFiles()
maxSize := h.maxFileSize()
filesScanned := 0
walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, werr error) error {
if werr != nil {
return nil // skip unreadable entries
}
select {
case <-ctx.Done():
return filepath.SkipAll
default:
}
if d.IsDir() {
if isSkippedDir(d.Name()) {
return filepath.SkipDir
}
return nil
}
if filesScanned >= maxFiles {
out.Truncated = true
return filepath.SkipAll
}
info, ierr := d.Info()
if ierr != nil || info.Size() > maxSize {
return nil
}
filesScanned++
rel, rerr := filepath.Rel(cwd, path)
if rerr != nil {
rel = path
}
rel = filepath.ToSlash(rel)
if grepFile(path, rel, re, &out, maxMatches) {
out.Truncated = true
return filepath.SkipAll
}
return nil
})
if walkErr != nil && walkErr != filepath.SkipAll {
return FsResult{Err: &FsError{Code: -32603, Message: "grep walk: " + walkErr.Error()}}
}
res, _ := json.Marshal(out)
return FsResult{OK: res}
}
// grepFile scans one file line-by-line. Returns true when the global match cap
// was reached (caller should stop the walk).
func grepFile(abs, rel string, re *regexp.Regexp, out *fsGrepResult, maxMatches int) bool {
f, err := os.Open(abs)
if err != nil {
return false
}
defer f.Close()
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
lineNo := 0
for sc.Scan() {
lineNo++
line := sc.Bytes()
// Skip binary content (NUL in line).
if hasNUL(line) {
return false
}
if re.Match(line) {
text := string(line)
if len(text) > DefaultGrepMaxLineLen {
text = text[:DefaultGrepMaxLineLen] + "...[truncated]"
}
out.Matches = append(out.Matches, grepMatch{File: rel, Line: lineNo, Text: text})
if len(out.Matches) >= maxMatches {
return true
}
}
}
return false
}
func hasNUL(b []byte) bool {
for _, c := range b {
if c == 0 {
return true
}
}
return false
}
// skippedDirs are never descended into during grep.
var skippedDirs = map[string]struct{}{
".git": {}, "node_modules": {}, "vendor": {}, "dist": {}, "build": {},
".venv": {}, "__pycache__": {}, "third_party": {},
}
func isSkippedDir(name string) bool {
_, ok := skippedDirs[name]
return ok
}