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