You've already forked agentic-coding-workflow
210 lines
5.1 KiB
Go
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
|
|
}
|