mirror of
https://github.com/lukaszraczylo/filepuff-mcp.git
synced 2026-10-12 04:54:27 +00:00
file_search runs ripgrep with --follow on by default, so a symlink inside the workspace pointing outside it (e.g. link -> /etc) caused rg to traverse out and return matches from external files — leaking their content to the caller despite the workspace-scoping contract. parseOutput emitted match paths verbatim with no re-validation. Filter every match through IsPathAllowed (resolving rg's workspace-relative path against the root first); IsPathAllowed's EvalSymlinks rejects the escape while still permitting legitimate worktree siblings. Defense in depth that also covers --no-ignore and any other traversal that reaches outside the root. Adds a security regression test (proven to fail without the filter).
447 lines
12 KiB
Go
447 lines
12 KiB
Go
package search
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"unicode/utf8"
|
|
|
|
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
|
|
)
|
|
|
|
// parseOutput must not attach one file's pending before-context to a match in a
|
|
// different file (cross-file leak guard in the JSON stream parser).
|
|
func TestParseOutputCrossFileContextNotLeaked(t *testing.T) {
|
|
s := newTestSearcher(t)
|
|
var buf bytes.Buffer
|
|
// fileA emits a before-context with no following match in fileA; then fileB
|
|
// has a match. fileA's pending context must NOT attach to fileB's match.
|
|
buf.WriteString(`{"type":"context","data":{"path":{"text":"fileA.go"},"lines":{"text":"orphan a ctx\n"},"line_number":1}}` + "\n")
|
|
buf.WriteString(`{"type":"match","data":{"path":{"text":"fileB.go"},"lines":{"text":"hit B\n"},"line_number":5,"submatches":[{"match":{"text":"hit"},"start":0,"end":3}]}}` + "\n")
|
|
|
|
res, err := s.parseOutput(&buf, 0)
|
|
if err != nil {
|
|
t.Fatalf("parseOutput: %v", err)
|
|
}
|
|
if len(res.Results) != 1 {
|
|
t.Fatalf("want 1 result, got %d", len(res.Results))
|
|
}
|
|
if res.Results[0].File != "fileB.go" {
|
|
t.Errorf("result file = %q, want fileB.go", res.Results[0].File)
|
|
}
|
|
if len(res.Results[0].Context.Before) != 0 {
|
|
t.Errorf("fileA context leaked into fileB match: %v", res.Results[0].Context.Before)
|
|
}
|
|
}
|
|
|
|
// parseOutput must attach a same-file before-context to the following match.
|
|
func TestParseOutputSameFileBeforeContext(t *testing.T) {
|
|
s := newTestSearcher(t)
|
|
var buf bytes.Buffer
|
|
buf.WriteString(`{"type":"context","data":{"path":{"text":"f.go"},"lines":{"text":"before line\n"},"line_number":4}}` + "\n")
|
|
buf.WriteString(`{"type":"match","data":{"path":{"text":"f.go"},"lines":{"text":"the match\n"},"line_number":5,"submatches":[{"match":{"text":"match"},"start":4,"end":9}]}}` + "\n")
|
|
|
|
res, err := s.parseOutput(&buf, 0)
|
|
if err != nil {
|
|
t.Fatalf("parseOutput: %v", err)
|
|
}
|
|
if len(res.Results) != 1 {
|
|
t.Fatalf("want 1 result, got %d", len(res.Results))
|
|
}
|
|
got := res.Results[0].Context.Before
|
|
if len(got) != 1 || got[0] != "before line" {
|
|
t.Errorf("same-file before-context not attached: %v", got)
|
|
}
|
|
}
|
|
|
|
// truncateLine must back off to a rune boundary so multibyte characters are
|
|
// never split into invalid UTF-8.
|
|
func TestTruncateLineRuneSafe(t *testing.T) {
|
|
s := strings.Repeat("é", 200) // 2 bytes each → 400 bytes
|
|
out := truncateLine(s, 50)
|
|
if !utf8.ValidString(out) {
|
|
t.Errorf("truncated output is not valid UTF-8: %q", out)
|
|
}
|
|
if !strings.HasSuffix(out, "...") {
|
|
t.Errorf("expected ellipsis suffix, got %q", out)
|
|
}
|
|
if got := truncateLine("hi", 50); got != "hi" {
|
|
t.Errorf("short string should be unchanged, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestNew(t *testing.T) {
|
|
cfg := config.Default()
|
|
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
|
|
|
searcher, err := New(cfg, logger)
|
|
if err != nil {
|
|
// ripgrep might not be installed
|
|
if strings.Contains(err.Error(), "not found") {
|
|
t.Skip("ripgrep not installed, skipping test")
|
|
}
|
|
t.Fatalf("New failed: %v", err)
|
|
}
|
|
|
|
if searcher == nil {
|
|
t.Fatal("expected non-nil searcher")
|
|
}
|
|
}
|
|
|
|
func TestBuildArgs(t *testing.T) {
|
|
cfg := config.Default()
|
|
cfg.WorkspaceRoot = "/test/workspace"
|
|
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
|
|
|
// Create searcher without checking for rg binary
|
|
s := &Searcher{
|
|
cfg: cfg,
|
|
logger: logger,
|
|
rgPath: "/usr/bin/rg",
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
req *Request
|
|
expected []string
|
|
notExpected []string // T-06: verify absence of unexpected flags
|
|
}{
|
|
{
|
|
name: "basic search",
|
|
req: &Request{
|
|
Pattern: "test",
|
|
ContextLines: 2,
|
|
Regex: true,
|
|
},
|
|
expected: []string{"--json", "--context=2", "--", "test", "."},
|
|
notExpected: []string{"--ignore-case", "--fixed-strings", "--max-total-count=0"},
|
|
},
|
|
{
|
|
name: "ignore case",
|
|
req: &Request{
|
|
Pattern: "test",
|
|
IgnoreCase: true,
|
|
Regex: true,
|
|
},
|
|
expected: []string{"--json", "--ignore-case", "--", "test", "."},
|
|
notExpected: []string{"--fixed-strings"},
|
|
},
|
|
{
|
|
name: "fixed strings",
|
|
req: &Request{
|
|
Pattern: "test",
|
|
Regex: false,
|
|
},
|
|
expected: []string{"--json", "--fixed-strings", "--", "test", "."},
|
|
notExpected: []string{"--ignore-case"},
|
|
},
|
|
{
|
|
name: "with file types",
|
|
req: &Request{
|
|
Pattern: "test",
|
|
FileTypes: []string{"go", "ts"},
|
|
Regex: true,
|
|
},
|
|
expected: []string{"--json", "--type", "go", "--type", "ts", "--", "test", "."},
|
|
notExpected: []string{"--ignore-case", "--fixed-strings"},
|
|
},
|
|
{
|
|
name: "with max results",
|
|
req: &Request{
|
|
Pattern: "test",
|
|
MaxResults: 10,
|
|
Regex: true,
|
|
},
|
|
expected: []string{"--json", "--", "test", "."},
|
|
notExpected: []string{"--ignore-case", "--fixed-strings", "--max-total-count=10", "--max-count=10"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
args := s.buildArgs(tt.req)
|
|
|
|
// Check that all expected args are present
|
|
for _, exp := range tt.expected {
|
|
found := false
|
|
for _, arg := range args {
|
|
if arg == exp {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected arg %q not found in %v", exp, args)
|
|
}
|
|
}
|
|
|
|
// T-06: Check that unexpected args are absent
|
|
for _, notExp := range tt.notExpected {
|
|
for _, arg := range args {
|
|
if arg == notExp {
|
|
t.Errorf("unexpected arg %q found in %v", notExp, args)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFormatResults(t *testing.T) {
|
|
cfg := config.Default()
|
|
cfg.WorkspaceRoot = "/test/workspace"
|
|
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
|
|
|
s := &Searcher{
|
|
cfg: cfg,
|
|
logger: logger,
|
|
rgPath: "/usr/bin/rg",
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
results *SearchResults
|
|
contains []string
|
|
}{
|
|
{
|
|
name: "empty results",
|
|
results: &SearchResults{
|
|
Results: []Result{},
|
|
},
|
|
contains: []string{"No matches found"},
|
|
},
|
|
{
|
|
name: "single result",
|
|
results: &SearchResults{
|
|
Results: []Result{
|
|
{
|
|
File: "test.go",
|
|
Line: 10,
|
|
Column: 5,
|
|
MatchText: "func TestSomething()",
|
|
},
|
|
},
|
|
Total: 1,
|
|
},
|
|
contains: []string{"test.go", "L10", "TestSomething"},
|
|
},
|
|
{
|
|
name: "truncated results",
|
|
results: &SearchResults{
|
|
Results: []Result{
|
|
{
|
|
File: "test.go",
|
|
Line: 10,
|
|
MatchText: "match",
|
|
},
|
|
},
|
|
Truncated: true,
|
|
Total: 100,
|
|
},
|
|
contains: []string{"truncated", "100"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
output := s.FormatResults(tt.results)
|
|
|
|
for _, exp := range tt.contains {
|
|
if !strings.Contains(output, exp) {
|
|
t.Errorf("expected output to contain %q, got:\n%s", exp, output)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseOutput(t *testing.T) {
|
|
cfg := config.Default()
|
|
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
|
|
|
s := &Searcher{
|
|
cfg: cfg,
|
|
logger: logger,
|
|
rgPath: "/usr/bin/rg",
|
|
}
|
|
|
|
// Sample ripgrep JSON output
|
|
jsonOutput := `{"type":"begin","data":{"path":{"text":"test.go"}}}
|
|
{"type":"match","data":{"path":{"text":"test.go"},"lines":{"text":"func TestSomething() {\n"},"line_number":10,"absolute_offset":100,"submatches":[{"match":{"text":"Test"},"start":5,"end":9}]}}
|
|
{"type":"end","data":{"path":{"text":"test.go"},"stats":{"bytes_searched":1000}}}
|
|
{"type":"summary","data":{"elapsed_total":{"secs":0,"nanos":1000000},"stats":{"searches":1,"searches_with_match":1,"bytes_searched":1000,"bytes_printed":100,"matched_lines":1,"matches":1}}}
|
|
`
|
|
buf := bytes.NewBufferString(jsonOutput)
|
|
|
|
results, err := s.parseOutput(buf, 0)
|
|
if err != nil {
|
|
t.Fatalf("parseOutput failed: %v", err)
|
|
}
|
|
|
|
if len(results.Results) != 1 {
|
|
t.Errorf("expected 1 result, got %d", len(results.Results))
|
|
}
|
|
|
|
if results.Results[0].File != "test.go" {
|
|
t.Errorf("expected file 'test.go', got %q", results.Results[0].File)
|
|
}
|
|
|
|
if results.Results[0].Line != 10 {
|
|
t.Errorf("expected line 10, got %d", results.Results[0].Line)
|
|
}
|
|
|
|
if results.Results[0].Column != 6 { // 1-indexed
|
|
t.Errorf("expected column 6, got %d", results.Results[0].Column)
|
|
}
|
|
}
|
|
|
|
func TestTruncateLine(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
expected string
|
|
maxLen int
|
|
}{
|
|
{
|
|
input: "short",
|
|
maxLen: 10,
|
|
expected: "short",
|
|
},
|
|
{
|
|
input: "this is a very long line that should be truncated",
|
|
maxLen: 20,
|
|
expected: "this is a very lo...",
|
|
},
|
|
{
|
|
input: "exact",
|
|
maxLen: 5,
|
|
expected: "exact",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
result := truncateLine(tt.input, tt.maxLen)
|
|
if result != tt.expected {
|
|
t.Errorf("truncateLine(%q, %d) = %q, want %q", tt.input, tt.maxLen, result, tt.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSearchIntegration(t *testing.T) {
|
|
// Create a temporary directory with test files
|
|
tmpDir, err := os.MkdirTemp("", "mcp-filepuff-search-test")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp dir: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
|
|
|
|
// Create test files
|
|
testFile := filepath.Join(tmpDir, "test.go")
|
|
content := `package main
|
|
|
|
func main() {
|
|
println("Hello, World!")
|
|
}
|
|
`
|
|
err = os.WriteFile(testFile, []byte(content), 0o600)
|
|
if err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
cfg := config.Default()
|
|
cfg.WorkspaceRoot = tmpDir
|
|
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
|
|
|
searcher, err := New(cfg, logger)
|
|
if err != nil {
|
|
t.Skip("ripgrep not installed, skipping integration test")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
req := &Request{
|
|
Pattern: "Hello",
|
|
ContextLines: 1,
|
|
Regex: false,
|
|
}
|
|
|
|
results, err := searcher.Search(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("Search failed: %v", err)
|
|
}
|
|
|
|
if len(results.Results) != 1 {
|
|
t.Errorf("expected 1 result, got %d", len(results.Results))
|
|
}
|
|
|
|
if len(results.Results) > 0 && !strings.Contains(results.Results[0].MatchText, "Hello") {
|
|
t.Errorf("expected match to contain 'Hello', got %q", results.Results[0].MatchText)
|
|
}
|
|
}
|
|
|
|
func TestSearchEmptyPattern(t *testing.T) {
|
|
cfg := config.Default()
|
|
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
|
|
|
s := &Searcher{
|
|
cfg: cfg,
|
|
logger: logger,
|
|
rgPath: "/usr/bin/rg",
|
|
}
|
|
|
|
ctx := context.Background()
|
|
req := &Request{
|
|
Pattern: "",
|
|
}
|
|
|
|
_, err := s.Search(ctx, req)
|
|
if err == nil {
|
|
t.Error("expected error for empty pattern")
|
|
}
|
|
}
|
|
|
|
// TestSearchSymlinkEscapeNotLeaked is a security regression guard: with
|
|
// FollowSymlinks on (the default), ripgrep can traverse a symlink whose target
|
|
// is outside the workspace root. Such matches must never be returned — search
|
|
// must not leak the content of files outside the workspace.
|
|
func TestSearchSymlinkEscapeNotLeaked(t *testing.T) {
|
|
const secret = "SUPERSECRETLEAKCANARY"
|
|
|
|
outside := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte(secret+"\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
workspace := t.TempDir()
|
|
// A regular in-workspace file that does NOT contain the secret.
|
|
if err := os.WriteFile(filepath.Join(workspace, "ok.txt"), []byte("nothing here\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// A symlink inside the workspace pointing at the outside directory.
|
|
if err := os.Symlink(outside, filepath.Join(workspace, "escape")); err != nil {
|
|
t.Skipf("symlinks unsupported: %v", err)
|
|
}
|
|
|
|
cfg := config.Default()
|
|
cfg.WorkspaceRoot = workspace
|
|
if !cfg.FollowSymlinks {
|
|
t.Fatal("precondition: FollowSymlinks should default true so rg --follow is exercised")
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
|
s, err := New(cfg, logger)
|
|
if err != nil {
|
|
t.Skip("ripgrep not installed")
|
|
}
|
|
|
|
results, err := s.Search(context.Background(), &Request{Pattern: secret})
|
|
if err != nil {
|
|
t.Fatalf("search: %v", err)
|
|
}
|
|
for _, r := range results.Results {
|
|
t.Errorf("symlink escape leaked an out-of-workspace match: file=%q text=%q", r.File, r.MatchText)
|
|
}
|
|
}
|