Files

188 lines
6.0 KiB
Go

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")
}
}