diff --git a/apps/backend/internal/agentctl/server/process/workspace_files.go b/apps/backend/internal/agentctl/server/process/workspace_files.go index fde4edb96a..006e3f5f23 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_files.go +++ b/apps/backend/internal/agentctl/server/process/workspace_files.go @@ -876,6 +876,19 @@ type fileSearchCandidate struct { repositoryName string } +const ( + // fileSearchDefaultLimit applies when the caller passes a non-positive limit. + fileSearchDefaultLimit = 20 + // fileSearchMaxLimit caps the caller-supplied limit before it is used to + // pre-allocate the result slice. The limit reaches this code straight from + // the `limit` query parameter of GET /api/v1/workspace/search, so without a + // ceiling a request like `?limit=2000000000` makes the process reserve + // gigabytes for a result set that can never exceed the number of tracked + // files. Mirrors the clamp validateContentSearch already applies to + // content search. + fileSearchMaxLimit = 200 +) + // GetFileContentAtRef returns the content of a file at a specific git ref (branch, commit, HEAD, etc). // If the file is not valid UTF-8, it is base64-encoded and isBinary is true. func (wt *WorkspaceTracker) GetFileContentAtRef(ctx context.Context, reqPath string, ref string) (string, int64, bool, error) { @@ -1007,7 +1020,10 @@ func searchFileCandidates( return []types.FileSearchResult{} } if limit <= 0 { - limit = 20 + limit = fileSearchDefaultLimit + } + if limit > fileSearchMaxLimit { + limit = fileSearchMaxLimit } query = strings.ToLower(query) diff --git a/apps/backend/internal/agentctl/server/process/workspace_files_test.go b/apps/backend/internal/agentctl/server/process/workspace_files_test.go index 9111b12ff0..cf37ff9706 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_files_test.go +++ b/apps/backend/internal/agentctl/server/process/workspace_files_test.go @@ -2,6 +2,8 @@ package process import ( "context" + "fmt" + "math" "os" "path/filepath" "runtime" @@ -517,6 +519,47 @@ func TestSearchFileCandidatesBreaksTiesByRepositoryRelativePathLength(t *testing } } +// A caller-supplied limit reaches searchFileCandidates straight from the +// `limit` query parameter, so an absurd value must not drive the result +// slice's pre-allocation. +func TestSearchFileCandidatesClampsCallerSuppliedLimit(t *testing.T) { + candidates := make([]fileSearchCandidate, 0, fileSearchMaxLimit+50) + for i := range fileSearchMaxLimit + 50 { + candidates = append(candidates, fileSearchCandidate{ + path: fmt.Sprintf("src/query-%d.go", i), + matchPath: fmt.Sprintf("src/query-%d.go", i), + }) + } + + results := searchFileCandidates(candidates, "query", math.MaxInt32) + + if len(results) != fileSearchMaxLimit { + t.Fatalf("len(results) = %d, want the clamped %d", len(results), fileSearchMaxLimit) + } + if cap(results) > fileSearchMaxLimit { + t.Fatalf("cap(results) = %d, want no more than the clamped %d", cap(results), fileSearchMaxLimit) + } +} + +func TestSearchFileCandidatesDefaultsNonPositiveLimit(t *testing.T) { + candidates := make([]fileSearchCandidate, 0, fileSearchDefaultLimit+5) + for i := range fileSearchDefaultLimit + 5 { + candidates = append(candidates, fileSearchCandidate{ + path: fmt.Sprintf("src/query-%d.go", i), + matchPath: fmt.Sprintf("src/query-%d.go", i), + }) + } + + for _, limit := range []int{0, -1} { + results := searchFileCandidates(candidates, "query", limit) + + if len(results) != fileSearchDefaultLimit { + t.Fatalf("limit %d: len(results) = %d, want the default %d", + limit, len(results), fileSearchDefaultLimit) + } + } +} + func TestGetFileTree_Symlinks(t *testing.T) { tmpDir := t.TempDir() wt := &WorkspaceTracker{workDir: tmpDir}