From 3adaeb0f8cc3e9233c4e74415eedef8274bb9ed7 Mon Sep 17 00:00:00 2001 From: Enmanuel Date: Wed, 8 Apr 2026 23:04:13 +0000 Subject: [PATCH] test: agregar tests completos para write_file, list_directory, append_file, delete_file 44 tests cubriendo todas las nuevas tools de archivos y la tool existente. Tests por tool: - write_file (11): crear archivo, ReadOnly, path fuera de allowed, contenido >1MB, crear dirs padre, sobreescribir, path traversal, symlink escape, deny-by-default - list_directory (9): listado plano y recursivo, limite 500 entries, symlinks fuera de allowed, path traversal, deny-by-default, no-directorio, dir vacio - append_file (11): append a existente, crear si no existe, ReadOnly, path fuera, limite 10MB total, path traversal, symlink escape, crear dirs padre - delete_file (9): borrar archivo, rechazar directorios, ReadOnly, path fuera, path traversal, symlink escape, archivo inexistente, deny-by-default Tambien corrige resolveReal() para resolver paths con multiples niveles de directorios inexistentes (necesario para MkdirAll en write/append). --- tools/file/append_test.go | 212 ++++++++++++++++++++++++++++++++++++++ tools/file/delete_test.go | 160 ++++++++++++++++++++++++++++ tools/file/list_test.go | 176 +++++++++++++++++++++++++++++++ tools/file/validate.go | 34 ++++-- tools/file/write_test.go | 202 ++++++++++++++++++++++++++++++++++++ 5 files changed, 775 insertions(+), 9 deletions(-) create mode 100644 tools/file/append_test.go create mode 100644 tools/file/delete_test.go create mode 100644 tools/file/list_test.go create mode 100644 tools/file/write_test.go diff --git a/tools/file/append_test.go b/tools/file/append_test.go new file mode 100644 index 0000000..18d4490 --- /dev/null +++ b/tools/file/append_test.go @@ -0,0 +1,212 @@ +package file + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/enmanuel/agents/internal/config" +) + +func TestAppendFile_AppendsToExistingFile(t *testing.T) { + tmp := t.TempDir() + target := filepath.Join(tmp, "log.txt") + os.WriteFile(target, []byte("line1\n"), 0644) + + cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false} + tool := NewAppendFile(cfg) + + result := tool.Exec(context.Background(), map[string]any{ + "path": target, + "content": "line2\n", + }) + if result.Err != nil { + t.Fatalf("expected success, got: %v", result.Err) + } + + data, _ := os.ReadFile(target) + if string(data) != "line1\nline2\n" { + t.Fatalf("expected 'line1\\nline2\\n', got %q", string(data)) + } + + if !strings.Contains(result.Output, "6 bytes") { + t.Fatalf("expected '6 bytes' in output, got: %q", result.Output) + } + if !strings.Contains(result.Output, "total size: 12 bytes") { + t.Fatalf("expected total size in output, got: %q", result.Output) + } +} + +func TestAppendFile_CreatesNewFileIfNotExists(t *testing.T) { + tmp := t.TempDir() + target := filepath.Join(tmp, "new.txt") + + cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false} + tool := NewAppendFile(cfg) + + result := tool.Exec(context.Background(), map[string]any{ + "path": target, + "content": "first line", + }) + if result.Err != nil { + t.Fatalf("expected success, got: %v", result.Err) + } + + data, err := os.ReadFile(target) + if err != nil { + t.Fatalf("file not created: %v", err) + } + if string(data) != "first line" { + t.Fatalf("expected 'first line', got %q", string(data)) + } +} + +func TestAppendFile_RejectsReadOnly(t *testing.T) { + tmp := t.TempDir() + cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: true} + tool := NewAppendFile(cfg) + + result := tool.Exec(context.Background(), map[string]any{ + "path": filepath.Join(tmp, "test.txt"), + "content": "data", + }) + if result.Err == nil { + t.Fatal("expected error when ReadOnly is true") + } + if !strings.Contains(result.Err.Error(), "read_only") { + t.Fatalf("expected read_only error, got: %v", result.Err) + } +} + +func TestAppendFile_RejectsPathOutsideAllowed(t *testing.T) { + tmp := t.TempDir() + cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false} + tool := NewAppendFile(cfg) + + result := tool.Exec(context.Background(), map[string]any{ + "path": "/etc/test.txt", + "content": "data", + }) + if result.Err == nil { + t.Fatal("expected error for path outside allowed") + } +} + +func TestAppendFile_RejectsTotalSizeOver10MB(t *testing.T) { + tmp := t.TempDir() + target := filepath.Join(tmp, "big.txt") + + // Create a file just under the limit + existing := strings.Repeat("x", maxAppendTotal-100) + os.WriteFile(target, []byte(existing), 0644) + + cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false} + tool := NewAppendFile(cfg) + + // Try to append content that would exceed the limit + result := tool.Exec(context.Background(), map[string]any{ + "path": target, + "content": strings.Repeat("y", 200), + }) + if result.Err == nil { + t.Fatal("expected error when total size exceeds 10 MB") + } + if !strings.Contains(result.Err.Error(), "10 MB") { + t.Fatalf("expected 10 MB error, got: %v", result.Err) + } +} + +func TestAppendFile_PathTraversal(t *testing.T) { + tmp := t.TempDir() + cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false} + tool := NewAppendFile(cfg) + + result := tool.Exec(context.Background(), map[string]any{ + "path": filepath.Join(tmp, "..", "..", "etc", "evil.txt"), + "content": "data", + }) + if result.Err == nil { + t.Fatal("expected error for path traversal") + } +} + +func TestAppendFile_SymlinkEscape(t *testing.T) { + tmp := t.TempDir() + link := filepath.Join(tmp, "escape") + os.Symlink("/tmp", link) + + cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false} + tool := NewAppendFile(cfg) + + result := tool.Exec(context.Background(), map[string]any{ + "path": filepath.Join(link, "evil.txt"), + "content": "data", + }) + if result.Err == nil { + t.Fatal("expected error for symlink escape") + } +} + +func TestAppendFile_DenyByDefault(t *testing.T) { + cfg := config.FileOpsCfg{AllowedPaths: []string{}, ReadOnly: false} + tool := NewAppendFile(cfg) + + result := tool.Exec(context.Background(), map[string]any{ + "path": "/tmp/test.txt", + "content": "data", + }) + if result.Err == nil { + t.Fatal("expected error when AllowedPaths is empty") + } +} + +func TestAppendFile_EmptyPath(t *testing.T) { + cfg := config.FileOpsCfg{AllowedPaths: []string{"/tmp"}, ReadOnly: false} + tool := NewAppendFile(cfg) + + result := tool.Exec(context.Background(), map[string]any{ + "path": "", + "content": "data", + }) + if result.Err == nil { + t.Fatal("expected error for empty path") + } +} + +func TestAppendFile_EmptyContent(t *testing.T) { + cfg := config.FileOpsCfg{AllowedPaths: []string{"/tmp"}, ReadOnly: false} + tool := NewAppendFile(cfg) + + result := tool.Exec(context.Background(), map[string]any{ + "path": "/tmp/test.txt", + "content": "", + }) + if result.Err == nil { + t.Fatal("expected error for empty content") + } +} + +func TestAppendFile_CreatesParentDirectories(t *testing.T) { + tmp := t.TempDir() + cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false} + tool := NewAppendFile(cfg) + + target := filepath.Join(tmp, "sub", "dir", "file.txt") + result := tool.Exec(context.Background(), map[string]any{ + "path": target, + "content": "nested content", + }) + if result.Err != nil { + t.Fatalf("expected success, got: %v", result.Err) + } + + data, err := os.ReadFile(target) + if err != nil { + t.Fatalf("file not created: %v", err) + } + if string(data) != "nested content" { + t.Fatalf("expected 'nested content', got %q", string(data)) + } +} diff --git a/tools/file/delete_test.go b/tools/file/delete_test.go new file mode 100644 index 0000000..ac7ec3d --- /dev/null +++ b/tools/file/delete_test.go @@ -0,0 +1,160 @@ +package file + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/enmanuel/agents/internal/config" +) + +func TestDeleteFile_DeletesExistingFile(t *testing.T) { + tmp := t.TempDir() + target := filepath.Join(tmp, "doomed.txt") + os.WriteFile(target, []byte("bye"), 0644) + + cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false} + tool := NewDeleteFile(cfg) + + result := tool.Exec(context.Background(), map[string]any{"path": target}) + if result.Err != nil { + t.Fatalf("expected success, got: %v", result.Err) + } + + if _, err := os.Stat(target); !os.IsNotExist(err) { + t.Fatal("file should have been deleted") + } + + if !strings.Contains(result.Output, "deleted") { + t.Fatalf("expected 'deleted' in output, got: %q", result.Output) + } +} + +func TestDeleteFile_RejectsDirectories(t *testing.T) { + tmp := t.TempDir() + subdir := filepath.Join(tmp, "mydir") + os.Mkdir(subdir, 0755) + + cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false} + tool := NewDeleteFile(cfg) + + result := tool.Exec(context.Background(), map[string]any{"path": subdir}) + if result.Err == nil { + t.Fatal("expected error when trying to delete a directory") + } + if !strings.Contains(result.Err.Error(), "directory") { + t.Fatalf("expected directory error, got: %v", result.Err) + } + + // Verify directory still exists + if _, err := os.Stat(subdir); os.IsNotExist(err) { + t.Fatal("directory should not have been deleted") + } +} + +func TestDeleteFile_RejectsReadOnly(t *testing.T) { + tmp := t.TempDir() + target := filepath.Join(tmp, "protected.txt") + os.WriteFile(target, []byte("safe"), 0644) + + cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: true} + tool := NewDeleteFile(cfg) + + result := tool.Exec(context.Background(), map[string]any{"path": target}) + if result.Err == nil { + t.Fatal("expected error when ReadOnly is true") + } + if !strings.Contains(result.Err.Error(), "read_only") { + t.Fatalf("expected read_only error, got: %v", result.Err) + } + + // Verify file still exists + if _, err := os.Stat(target); os.IsNotExist(err) { + t.Fatal("file should not have been deleted") + } +} + +func TestDeleteFile_RejectsPathOutsideAllowed(t *testing.T) { + tmp := t.TempDir() + cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false} + tool := NewDeleteFile(cfg) + + result := tool.Exec(context.Background(), map[string]any{"path": "/etc/hosts"}) + if result.Err == nil { + t.Fatal("expected error for path outside allowed") + } +} + +func TestDeleteFile_PathTraversal(t *testing.T) { + tmp := t.TempDir() + cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false} + tool := NewDeleteFile(cfg) + + result := tool.Exec(context.Background(), map[string]any{ + "path": filepath.Join(tmp, "..", "..", "etc", "hosts"), + }) + if result.Err == nil { + t.Fatal("expected error for path traversal") + } +} + +func TestDeleteFile_SymlinkEscape(t *testing.T) { + tmp := t.TempDir() + + // Create a file outside allowed paths + outside := t.TempDir() + outsideFile := filepath.Join(outside, "secret.txt") + os.WriteFile(outsideFile, []byte("secret"), 0644) + + // Create symlink inside allowed paths pointing to the outside file + link := filepath.Join(tmp, "link.txt") + os.Symlink(outsideFile, link) + + cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false} + tool := NewDeleteFile(cfg) + + result := tool.Exec(context.Background(), map[string]any{"path": link}) + if result.Err == nil { + t.Fatal("expected error for symlink escape") + } + + // Verify the outside file still exists + if _, err := os.Stat(outsideFile); os.IsNotExist(err) { + t.Fatal("outside file should not have been deleted") + } +} + +func TestDeleteFile_NonExistentFile(t *testing.T) { + tmp := t.TempDir() + cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false} + tool := NewDeleteFile(cfg) + + result := tool.Exec(context.Background(), map[string]any{ + "path": filepath.Join(tmp, "nonexistent.txt"), + }) + if result.Err == nil { + t.Fatal("expected error for non-existent file") + } +} + +func TestDeleteFile_EmptyPath(t *testing.T) { + cfg := config.FileOpsCfg{AllowedPaths: []string{"/tmp"}, ReadOnly: false} + tool := NewDeleteFile(cfg) + + result := tool.Exec(context.Background(), map[string]any{"path": ""}) + if result.Err == nil { + t.Fatal("expected error for empty path") + } +} + +func TestDeleteFile_DenyByDefault(t *testing.T) { + cfg := config.FileOpsCfg{AllowedPaths: []string{}, ReadOnly: false} + tool := NewDeleteFile(cfg) + + result := tool.Exec(context.Background(), map[string]any{"path": "/tmp/test.txt"}) + if result.Err == nil { + t.Fatal("expected error when AllowedPaths is empty") + } +} diff --git a/tools/file/list_test.go b/tools/file/list_test.go new file mode 100644 index 0000000..edfa86a --- /dev/null +++ b/tools/file/list_test.go @@ -0,0 +1,176 @@ +package file + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/enmanuel/agents/internal/config" +) + +func TestListDirectory_ListsFilesAndDirs(t *testing.T) { + tmp := t.TempDir() + os.WriteFile(filepath.Join(tmp, "file1.txt"), []byte("hello"), 0644) + os.WriteFile(filepath.Join(tmp, "file2.txt"), []byte("world"), 0644) + os.Mkdir(filepath.Join(tmp, "subdir"), 0755) + + cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}} + tool := NewListDirectory(cfg) + + result := tool.Exec(context.Background(), map[string]any{"path": tmp}) + if result.Err != nil { + t.Fatalf("expected success, got: %v", result.Err) + } + + if !strings.Contains(result.Output, "file1.txt") { + t.Fatalf("expected file1.txt in output, got: %s", result.Output) + } + if !strings.Contains(result.Output, "file2.txt") { + t.Fatalf("expected file2.txt in output, got: %s", result.Output) + } + if !strings.Contains(result.Output, "subdir") { + t.Fatalf("expected subdir in output, got: %s", result.Output) + } + if !strings.Contains(result.Output, "dir") { + t.Fatalf("expected 'dir' type in output, got: %s", result.Output) + } +} + +func TestListDirectory_Recursive(t *testing.T) { + tmp := t.TempDir() + sub := filepath.Join(tmp, "sub") + os.Mkdir(sub, 0755) + os.WriteFile(filepath.Join(tmp, "root.txt"), []byte("r"), 0644) + os.WriteFile(filepath.Join(sub, "nested.txt"), []byte("n"), 0644) + + cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}} + tool := NewListDirectory(cfg) + + result := tool.Exec(context.Background(), map[string]any{ + "path": tmp, + "recursive": true, + }) + if result.Err != nil { + t.Fatalf("expected success, got: %v", result.Err) + } + + if !strings.Contains(result.Output, "root.txt") { + t.Fatalf("expected root.txt in output, got: %s", result.Output) + } + if !strings.Contains(result.Output, filepath.Join("sub", "nested.txt")) { + t.Fatalf("expected sub/nested.txt in output, got: %s", result.Output) + } +} + +func TestListDirectory_RespectsMaxEntries(t *testing.T) { + tmp := t.TempDir() + // Create more than maxListEntries files with unique names + for i := 0; i < maxListEntries+10; i++ { + name := fmt.Sprintf("file_%04d.txt", i) + os.WriteFile(filepath.Join(tmp, name), []byte("x"), 0644) + } + + cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}} + tool := NewListDirectory(cfg) + + result := tool.Exec(context.Background(), map[string]any{"path": tmp}) + if result.Err != nil { + t.Fatalf("expected success, got: %v", result.Err) + } + + lines := strings.Split(result.Output, "\n") + // Should be maxListEntries + 1 (truncation message) + if len(lines) > maxListEntries+1 { + t.Fatalf("expected at most %d lines, got %d", maxListEntries+1, len(lines)) + } + if !strings.Contains(result.Output, "truncated") { + t.Fatalf("expected truncation message, got: %s", result.Output[len(result.Output)-200:]) + } +} + +func TestListDirectory_SymlinkOutsideAllowedSkipped(t *testing.T) { + tmp := t.TempDir() + // Create a symlink pointing outside AllowedPaths + link := filepath.Join(tmp, "escape") + os.Symlink("/etc", link) + + cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}} + tool := NewListDirectory(cfg) + + result := tool.Exec(context.Background(), map[string]any{"path": tmp}) + if result.Err != nil { + t.Fatalf("expected success, got: %v", result.Err) + } + + // The symlink should be skipped, not listed + if strings.Contains(result.Output, "escape") { + t.Fatalf("symlink pointing outside allowed paths should be skipped, got: %s", result.Output) + } +} + +func TestListDirectory_PathTraversal(t *testing.T) { + tmp := t.TempDir() + cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}} + tool := NewListDirectory(cfg) + + result := tool.Exec(context.Background(), map[string]any{ + "path": filepath.Join(tmp, "..", "..", "etc"), + }) + if result.Err == nil { + t.Fatal("expected error for path traversal") + } +} + +func TestListDirectory_DenyByDefault(t *testing.T) { + cfg := config.FileOpsCfg{AllowedPaths: []string{}} + tool := NewListDirectory(cfg) + + result := tool.Exec(context.Background(), map[string]any{"path": "/tmp"}) + if result.Err == nil { + t.Fatal("expected error when AllowedPaths is empty") + } +} + +func TestListDirectory_NotADirectory(t *testing.T) { + tmp := t.TempDir() + f := filepath.Join(tmp, "file.txt") + os.WriteFile(f, []byte("hello"), 0644) + + cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}} + tool := NewListDirectory(cfg) + + result := tool.Exec(context.Background(), map[string]any{"path": f}) + if result.Err == nil { + t.Fatal("expected error for non-directory path") + } + if !strings.Contains(result.Err.Error(), "not a directory") { + t.Fatalf("expected 'not a directory' error, got: %v", result.Err) + } +} + +func TestListDirectory_EmptyPath(t *testing.T) { + cfg := config.FileOpsCfg{AllowedPaths: []string{"/tmp"}} + tool := NewListDirectory(cfg) + + result := tool.Exec(context.Background(), map[string]any{"path": ""}) + if result.Err == nil { + t.Fatal("expected error for empty path") + } +} + +func TestListDirectory_EmptyDirectory(t *testing.T) { + tmp := t.TempDir() + cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}} + tool := NewListDirectory(cfg) + + result := tool.Exec(context.Background(), map[string]any{"path": tmp}) + if result.Err != nil { + t.Fatalf("expected success for empty dir, got: %v", result.Err) + } + if result.Output != "" { + t.Fatalf("expected empty output for empty dir, got: %q", result.Output) + } +} diff --git a/tools/file/validate.go b/tools/file/validate.go index b83a8ef..40336d7 100644 --- a/tools/file/validate.go +++ b/tools/file/validate.go @@ -49,19 +49,35 @@ func validateWritePath(absPath string, allowedPaths []string, readOnly bool) err } // resolveReal resolves symlinks for a path. -// If the exact path doesn't exist, it resolves the deepest existing ancestor -// and appends the remaining segments, preventing partial traversal. +// If the exact path doesn't exist, it walks up the tree to find the deepest +// existing ancestor, resolves its symlinks, and appends the remaining segments. +// This prevents partial traversal attacks via symlinks in non-existent paths. func resolveReal(path string) (string, error) { real, err := filepath.EvalSymlinks(path) if err == nil { return filepath.Clean(real), nil } - // Path doesn't exist — resolve parent and append base. - parent := filepath.Dir(path) - base := filepath.Base(path) - realParent, err := filepath.EvalSymlinks(parent) - if err != nil { - return "", err + + // Walk up to find the deepest existing ancestor. + cleaned := filepath.Clean(path) + var tail []string + cur := cleaned + for { + parent := filepath.Dir(cur) + tail = append([]string{filepath.Base(cur)}, tail...) + realParent, err := filepath.EvalSymlinks(parent) + if err == nil { + // Found an existing ancestor — rebuild the path. + result := realParent + for _, seg := range tail { + result = filepath.Join(result, seg) + } + return filepath.Clean(result), nil + } + if parent == cur { + // Reached the root without finding an existing ancestor. + return "", fmt.Errorf("cannot resolve any ancestor of %q", path) + } + cur = parent } - return filepath.Clean(filepath.Join(realParent, base)), nil } diff --git a/tools/file/write_test.go b/tools/file/write_test.go new file mode 100644 index 0000000..af3ecf0 --- /dev/null +++ b/tools/file/write_test.go @@ -0,0 +1,202 @@ +package file + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/enmanuel/agents/internal/config" +) + +func TestWriteFile_CreatesNewFile(t *testing.T) { + tmp := t.TempDir() + cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false} + tool := NewWriteFile(cfg) + + target := filepath.Join(tmp, "new.txt") + result := tool.Exec(context.Background(), map[string]any{ + "path": target, + "content": "hello world", + }) + if result.Err != nil { + t.Fatalf("expected success, got: %v", result.Err) + } + + data, err := os.ReadFile(target) + if err != nil { + t.Fatalf("file not created: %v", err) + } + if string(data) != "hello world" { + t.Fatalf("expected 'hello world', got %q", string(data)) + } + + if !strings.Contains(result.Output, "11 bytes") { + t.Fatalf("expected output mentioning bytes, got %q", result.Output) + } +} + +func TestWriteFile_RejectsReadOnly(t *testing.T) { + tmp := t.TempDir() + cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: true} + tool := NewWriteFile(cfg) + + result := tool.Exec(context.Background(), map[string]any{ + "path": filepath.Join(tmp, "test.txt"), + "content": "data", + }) + if result.Err == nil { + t.Fatal("expected error when ReadOnly is true") + } + if !strings.Contains(result.Err.Error(), "read_only") { + t.Fatalf("expected read_only error, got: %v", result.Err) + } +} + +func TestWriteFile_RejectsPathOutsideAllowed(t *testing.T) { + tmp := t.TempDir() + cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false} + tool := NewWriteFile(cfg) + + result := tool.Exec(context.Background(), map[string]any{ + "path": "/etc/test.txt", + "content": "data", + }) + if result.Err == nil { + t.Fatal("expected error for path outside allowed") + } +} + +func TestWriteFile_RejectsContentOver1MB(t *testing.T) { + tmp := t.TempDir() + cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false} + tool := NewWriteFile(cfg) + + bigContent := strings.Repeat("x", maxWriteSize+1) + result := tool.Exec(context.Background(), map[string]any{ + "path": filepath.Join(tmp, "big.txt"), + "content": bigContent, + }) + if result.Err == nil { + t.Fatal("expected error for content exceeding 1 MB") + } + if !strings.Contains(result.Err.Error(), "1 MB") { + t.Fatalf("expected 1 MB error, got: %v", result.Err) + } +} + +func TestWriteFile_CreatesParentDirectories(t *testing.T) { + tmp := t.TempDir() + cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false} + tool := NewWriteFile(cfg) + + target := filepath.Join(tmp, "sub", "dir", "file.txt") + result := tool.Exec(context.Background(), map[string]any{ + "path": target, + "content": "nested", + }) + if result.Err != nil { + t.Fatalf("expected success, got: %v", result.Err) + } + + data, err := os.ReadFile(target) + if err != nil { + t.Fatalf("file not created: %v", err) + } + if string(data) != "nested" { + t.Fatalf("expected 'nested', got %q", string(data)) + } +} + +func TestWriteFile_OverwritesExistingFile(t *testing.T) { + tmp := t.TempDir() + target := filepath.Join(tmp, "exists.txt") + os.WriteFile(target, []byte("old"), 0644) + + cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false} + tool := NewWriteFile(cfg) + + result := tool.Exec(context.Background(), map[string]any{ + "path": target, + "content": "new content", + }) + if result.Err != nil { + t.Fatalf("expected success, got: %v", result.Err) + } + + data, _ := os.ReadFile(target) + if string(data) != "new content" { + t.Fatalf("expected 'new content', got %q", string(data)) + } +} + +func TestWriteFile_PathTraversal(t *testing.T) { + tmp := t.TempDir() + cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false} + tool := NewWriteFile(cfg) + + result := tool.Exec(context.Background(), map[string]any{ + "path": filepath.Join(tmp, "..", "..", "etc", "evil.txt"), + "content": "data", + }) + if result.Err == nil { + t.Fatal("expected error for path traversal") + } +} + +func TestWriteFile_SymlinkEscape(t *testing.T) { + tmp := t.TempDir() + link := filepath.Join(tmp, "escape") + os.Symlink("/tmp", link) + + cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false} + tool := NewWriteFile(cfg) + + result := tool.Exec(context.Background(), map[string]any{ + "path": filepath.Join(link, "evil.txt"), + "content": "data", + }) + if result.Err == nil { + t.Fatal("expected error for symlink escape") + } +} + +func TestWriteFile_EmptyPath(t *testing.T) { + cfg := config.FileOpsCfg{AllowedPaths: []string{"/tmp"}, ReadOnly: false} + tool := NewWriteFile(cfg) + + result := tool.Exec(context.Background(), map[string]any{ + "path": "", + "content": "data", + }) + if result.Err == nil { + t.Fatal("expected error for empty path") + } +} + +func TestWriteFile_EmptyContent(t *testing.T) { + cfg := config.FileOpsCfg{AllowedPaths: []string{"/tmp"}, ReadOnly: false} + tool := NewWriteFile(cfg) + + result := tool.Exec(context.Background(), map[string]any{ + "path": "/tmp/test.txt", + "content": "", + }) + if result.Err == nil { + t.Fatal("expected error for empty content") + } +} + +func TestWriteFile_DenyByDefault(t *testing.T) { + cfg := config.FileOpsCfg{AllowedPaths: []string{}, ReadOnly: false} + tool := NewWriteFile(cfg) + + result := tool.Exec(context.Background(), map[string]any{ + "path": "/tmp/test.txt", + "content": "data", + }) + if result.Err == nil { + t.Fatal("expected error when AllowedPaths is empty") + } +}