package io import ( "context" "os" "path/filepath" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) // fileToolForTest builds a tools set over cfg with opts and returns the file tool. func fileToolForTest(t *testing.T, cfg *config.Config, opts ...Option) *fileTool { t.Helper() return findTool(t, buildTools(t, cfg, opts...), "file").(*fileTool) } func TestFileTool_WriteAndRead(t *testing.T) { t.Parallel() dir := t.TempDir() ft := fileToolForTest(t, &config.Config{DataDir: dir}) if _, err := ft.Execute(context.Background(), map[string]any{"action": "write", "path": "sub/a.txt", "content": "hello world"}); err != nil { t.Fatalf("write: %v", err) } got, err := ft.Execute(context.Background(), map[string]any{"action": "read", "path": "sub/a.txt"}) if err != nil { t.Fatalf("read: %v", err) } m := got.(map[string]any) if m["content"] != "hello world" { t.Fatalf("content = %q", m["content"]) } if m["size"] != len("hello world") { t.Fatalf("size = %v", m["size"]) } if m["path"] != filepath.Join(dir, "sub", "a.txt") { t.Fatalf("path = %q", m["path"]) } } func TestFileTool_RejectsTraversal(t *testing.T) { t.Parallel() ft := fileToolForTest(t, &config.Config{DataDir: t.TempDir()}) cases := []string{ "../escape.txt", "../../../../etc/passwd", "sub/../../escape.txt", } for _, p := range cases { _, err := ft.Execute(context.Background(), map[string]any{"action": "read", "path": p}) if err == nil { t.Fatalf("expected traversal rejection for %q", p) } } // An absolute path outside the data dir is also rejected. outside := filepath.Join(t.TempDir(), "outside.txt") _, err := ft.Execute(context.Background(), map[string]any{"action": "write", "path": outside, "content": "x"}) if err == nil { t.Fatal("expected rejection for absolute path outside the data dir") } } func TestFileTool_ReadSizeLimit(t *testing.T) { t.Parallel() dir := t.TempDir() // Write with the default limit, then read with a tiny cap. if _, err := fileToolForTest(t, &config.Config{DataDir: dir}). Execute(context.Background(), map[string]any{"action": "write", "path": "big.txt", "content": "1234567890"}); err != nil { t.Fatalf("write: %v", err) } ft := fileToolForTest(t, &config.Config{DataDir: dir}, WithReadLimit(4)) _, err := ft.Execute(context.Background(), map[string]any{"action": "read", "path": "big.txt"}) if err == nil || !strings.Contains(err.Error(), "read limit") { t.Fatalf("expected read-limit error, got %v", err) } } func TestFileTool_WriteSizeLimit(t *testing.T) { t.Parallel() ft := fileToolForTest(t, &config.Config{DataDir: t.TempDir()}, WithWriteLimit(4)) _, err := ft.Execute(context.Background(), map[string]any{"action": "write", "path": "x.txt", "content": "too long"}) if err == nil || !strings.Contains(err.Error(), "write limit") { t.Fatalf("expected write-limit error, got %v", err) } } func TestFileTool_List(t *testing.T) { t.Parallel() dir := t.TempDir() ft := fileToolForTest(t, &config.Config{DataDir: dir}) for _, name := range []string{"a.txt", "b.txt", "sub"} { p := filepath.Join(dir, name) if name == "sub" { if err := os.MkdirAll(p, dirPerm); err != nil { t.Fatal(err) } } else if err := os.WriteFile(p, []byte("x"), filePerm); err != nil { t.Fatal(err) } } got, err := ft.Execute(context.Background(), map[string]any{"action": "list", "path": "."}) if err != nil { t.Fatalf("list: %v", err) } m := got.(map[string]any) if m["count"].(int) != 3 { t.Fatalf("count = %v", m["count"]) } entries := m["entries"].([]map[string]any) found := map[string]bool{} for _, e := range entries { found[e["name"].(string)] = true } for _, want := range []string{"a.txt", "b.txt", "sub"} { if !found[want] { t.Errorf("missing entry %q", want) } } } func TestFileTool_Search(t *testing.T) { t.Parallel() dir := t.TempDir() ft := fileToolForTest(t, &config.Config{DataDir: dir}) for _, name := range []string{"report_2026.txt", "notes.md", "report_old.txt"} { if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), filePerm); err != nil { t.Fatal(err) } } got, err := ft.Execute(context.Background(), map[string]any{"action": "search", "query": "report"}) if err != nil { t.Fatalf("search: %v", err) } m := got.(map[string]any) if m["count"].(int) != 2 { t.Fatalf("count = %v", m["count"]) } if _, err := ft.Execute(context.Background(), map[string]any{"action": "search", "query": ""}); err == nil { t.Fatal("expected error for empty search query") } } func TestFileTool_NoBaseDirectory(t *testing.T) { t.Parallel() ft := fileToolForTest(t, &config.Config{}) // empty DataDir, no whitelist _, err := ft.Execute(context.Background(), map[string]any{"action": "read", "path": "a.txt"}) if err == nil || !strings.Contains(err.Error(), "no base directory") { t.Fatalf("expected no-base-directory error, got %v", err) } } func TestFileTool_PathWhitelist(t *testing.T) { t.Parallel() dir := t.TempDir() extra := t.TempDir() ft := fileToolForTest(t, &config.Config{DataDir: dir}, WithFilePathWhitelist(extra)) if err := os.WriteFile(filepath.Join(extra, "w.txt"), []byte("hi"), filePerm); err != nil { t.Fatal(err) } got, err := ft.Execute(context.Background(), map[string]any{"action": "read", "path": filepath.Join(extra, "w.txt")}) if err != nil { t.Fatalf("read whitelisted file: %v", err) } if got.(map[string]any)["content"] != "hi" { t.Fatalf("content = %q", got.(map[string]any)["content"]) } } func TestFileTool_RejectsSymlinkEscape(t *testing.T) { t.Parallel() dir := t.TempDir() secret := t.TempDir() if err := os.WriteFile(filepath.Join(secret, "secret.txt"), []byte("hidden"), filePerm); err != nil { t.Fatal(err) } link := filepath.Join(dir, "link.txt") if err := os.Symlink(filepath.Join(secret, "secret.txt"), link); err != nil { t.Skipf("symlink unsupported: %v", err) } ft := fileToolForTest(t, &config.Config{DataDir: dir}) _, err := ft.Execute(context.Background(), map[string]any{"action": "read", "path": "link.txt"}) if err == nil { t.Fatal("expected symlink escape to be rejected") } }