feat(tools/io): 内置 IO/网络工具(file(路径安全)/http(SSRF防护)/web-search/web-fetch/knowledge)+ 大小截断
This commit is contained in:
1 parent
488cf9c79c
commit
3ce9489f8e
17 files changed
+2223
No files matched your search
@@ -0,0 +1,187 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user