diff --git a/internal/ignore/ignore.go b/internal/ignore/ignore.go index a18b438..f7ffedf 100644 --- a/internal/ignore/ignore.go +++ b/internal/ignore/ignore.go @@ -4,6 +4,7 @@ import ( "bufio" "os" "path/filepath" + "runtime" "strings" "github.com/go-git/go-git/v5/plumbing/format/gitignore" @@ -14,6 +15,15 @@ type Matcher interface { IsIgnored(path string, isDir bool) bool } +// IsCaseInsensitiveFS reports whether the OS resolves filenames case-insensitively by default. +// In GoCode, Windows and macOS are treated as case-insensitive filesystems per project +// platform policy (NTFS and default APFS/HFS+ are case-insensitive). +// Note: While macOS supports case-sensitive APFS volumes as an opt-in format, standard +// installations are case-insensitive, matching Git's core.ignoreCase default. +func IsCaseInsensitiveFS() bool { + return runtime.GOOS == "windows" || runtime.GOOS == "darwin" +} + type gitIgnoreMatcher struct { matcher gitignore.Matcher root string @@ -77,11 +87,15 @@ func readIgnorePatterns(ignoreFilePath, rootDir string) ([]gitignore.Pattern, er var patterns []gitignore.Pattern scanner := bufio.NewScanner(f) + caseFold := IsCaseInsensitiveFS() for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } + if caseFold { + line = strings.ToLower(line) + } patterns = append(patterns, gitignore.ParsePattern(line, nil)) } @@ -106,7 +120,9 @@ func (m *gitIgnoreMatcher) IsIgnored(path string, isDir bool) bool { slashPath := filepath.ToSlash(relPath) slashPath = strings.TrimPrefix(slashPath, "./") slashPath = strings.TrimPrefix(slashPath, "/") - + if IsCaseInsensitiveFS() { + slashPath = strings.ToLower(slashPath) + } parts := strings.Split(slashPath, "/") return m.matcher.Match(parts, isDir) } diff --git a/internal/ignore/ignore_test.go b/internal/ignore/ignore_test.go index b1df85d..67ff810 100644 --- a/internal/ignore/ignore_test.go +++ b/internal/ignore/ignore_test.go @@ -3,6 +3,7 @@ package ignore import ( "os" "path/filepath" + "runtime" "testing" ) @@ -55,3 +56,91 @@ func TestIgnoreMatcher_NoIgnoreFile(t *testing.T) { t.Errorf("expected no files to be ignored when no ignore file is present") } } + +func TestIgnoreMatcher_CaseInsensitiveOnSupportedOS(t *testing.T) { + if !IsCaseInsensitiveFS() { + t.Skip("skipping case-insensitive tests on case-sensitive filesystem") + } + + tmpDir := t.TempDir() + ignoreContent := ` +secrets/ +custom_secret.txt +*.confidential +notes/* +!notes/public.txt +` + _ = os.WriteFile(filepath.Join(tmpDir, ".gocodeignore"), []byte(ignoreContent), 0o644) + matcher := LoadMatcher(tmpDir) + + tests := []struct { + path string + isDir bool + want bool + desc string + }{ + // Filename variants + {"custom_secret.txt", false, true, "exact filename"}, + {"CUSTOM_SECRET.TXT", false, true, "uppercase filename"}, + {"Custom_Secret.txt", false, true, "mixed case filename"}, + + // Directory variants + {"secrets", true, true, "exact directory"}, + {"SECRETS", true, true, "uppercase directory"}, + {"Secrets", true, true, "mixed case directory"}, + {"secrets/key.txt", false, true, "file in exact directory"}, + {"SECRETS/key.txt", false, true, "file in uppercase directory"}, + {"Secrets/Key.txt", false, true, "file with mixed case in mixed directory"}, + + // Wildcard variants + {"report.confidential", false, true, "exact wildcard extension"}, + {"REPORT.CONFIDENTIAL", false, true, "uppercase wildcard extension"}, + {"Report.Confidential", false, true, "mixed case wildcard extension"}, + + // Negation variants + {"notes/private.txt", false, true, "ignored under notes/*"}, + {"NOTES/Private.txt", false, true, "ignored under notes/* with cased directory"}, + {"notes/public.txt", false, false, "un-ignored via !notes/public.txt"}, + {"Notes/Public.txt", false, false, "un-ignored via !notes/public.txt with cased path"}, + {"NOTES/PUBLIC.TXT", false, false, "un-ignored via !notes/public.txt all caps"}, + + // Non-ignored files + {"main.go", false, false, "ordinary file"}, + {"MAIN.GO", false, false, "ordinary file cased"}, + } + + for _, tt := range tests { + targetPath := filepath.Join(tmpDir, filepath.FromSlash(tt.path)) + got := matcher.IsIgnored(targetPath, tt.isDir) + if got != tt.want { + t.Errorf("[%s] path %q (isDir=%v): got %v, want %v", tt.desc, tt.path, tt.isDir, got, tt.want) + } + } +} + +func TestIgnoreMatcher_LinuxCaseSensitive(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("skipping Linux-only case-sensitive test") + } + + tmpDir := t.TempDir() + ignoreContent := "secret.txt\nprivate/\n" + _ = os.WriteFile(filepath.Join(tmpDir, ".gocodeignore"), []byte(ignoreContent), 0o644) + matcher := LoadMatcher(tmpDir) + + // On Linux, exact case matches + if !matcher.IsIgnored(filepath.Join(tmpDir, "secret.txt"), false) { + t.Error("expected secret.txt to be ignored on Linux") + } + if !matcher.IsIgnored(filepath.Join(tmpDir, "private", "data.txt"), false) { + t.Error("expected private/data.txt to be ignored on Linux") + } + + // On Linux, altered case does NOT match + if matcher.IsIgnored(filepath.Join(tmpDir, "SECRET.TXT"), false) { + t.Error("expected SECRET.TXT NOT to be ignored on Linux") + } + if matcher.IsIgnored(filepath.Join(tmpDir, "Private", "data.txt"), false) { + t.Error("expected Private/data.txt NOT to be ignored on Linux") + } +} diff --git a/internal/tools/tools_test.go b/internal/tools/tools_test.go index 3a4cfb1..217f12d 100644 --- a/internal/tools/tools_test.go +++ b/internal/tools/tools_test.go @@ -426,6 +426,78 @@ func TestTools_IgnoreRules(t *testing.T) { } } +func TestTools_IgnoreRules_CaseInsensitiveBypassPrevented(t *testing.T) { + if !isCaseInsensitiveFS() { + t.Skip("skipping case-insensitive ignore tool tests on case-sensitive filesystem") + } + + ws := t.TempDir() + + ignoreRules := "ignored_file.txt\nignored_dir/\n*.confidential\n" + _ = os.WriteFile(filepath.Join(ws, ".gocodeignore"), []byte(ignoreRules), 0o644) + + filePath := filepath.Join(ws, "ignored_file.txt") + _ = os.WriteFile(filePath, []byte("harmless fixture content"), 0o644) + + dirPath := filepath.Join(ws, "ignored_dir") + _ = os.MkdirAll(dirPath, 0o755) + subFile := filepath.Join(dirPath, "sub.txt") + _ = os.WriteFile(subFile, []byte("sub fixture content"), 0o644) + + confFile := filepath.Join(ws, "report.confidential") + _ = os.WriteFile(confFile, []byte("report fixture content"), 0o644) + + realIgnoreMatcher := ignore.LoadMatcher(ws) + sensitiveMatcher := ignore.NewSensitiveMatcher(realIgnoreMatcher, nil) + + // 1. Verify FileReadTool blocks alternate-cased paths + readTool := &FileReadTool{WorkspaceRoot: ws, SensitiveMatcher: sensitiveMatcher} + for _, target := range []string{"IGNORED_FILE.TXT", "Ignored_File.txt", "Ignored_Dir/sub.txt", "IGNORED_DIR/sub.txt", "report.CONFIDENTIAL"} { + args, _ := json.Marshal(fileReadArgs{Path: target}) + res, err := readTool.Execute(context.Background(), args) + if err != nil { + t.Fatalf("unexpected error for %q: %v", target, err) + } + if !strings.Contains(res.Error, "ignored by ignore rules") { + t.Errorf("expected FileReadTool to block case-variant %q, got error: %q, output: %q", target, res.Error, res.Output) + } + } + + // 2. Verify FileWriteTool blocks alternate-cased paths + writeTool := &FileWriteTool{WorkspaceRoot: ws, SensitiveMatcher: sensitiveMatcher} + for _, target := range []string{"IGNORED_FILE.TXT", "Ignored_Dir/new.txt"} { + args, _ := json.Marshal(fileWriteArgs{Path: target, Content: "overwritten"}) + res, err := writeTool.Execute(context.Background(), args) + if err != nil { + t.Fatalf("unexpected error for %q: %v", target, err) + } + if !strings.Contains(res.Error, "ignored by ignore rules") { + t.Errorf("expected FileWriteTool to block case-variant %q, got error: %q", target, res.Error) + } + } + data, _ := os.ReadFile(filePath) + if string(data) != "harmless fixture content" { + t.Errorf("file was modified despite ignore rule: %q", string(data)) + } + + // 3. Verify FilePatchTool blocks alternate-cased paths + patchTool := &FilePatchTool{WorkspaceRoot: ws, SensitiveMatcher: sensitiveMatcher} + for _, target := range []string{"IGNORED_FILE.TXT", "Ignored_Dir/sub.txt"} { + args, _ := json.Marshal(filePatchArgs{Path: target, Find: "fixture", Replace: "patched"}) + res, err := patchTool.Execute(context.Background(), args) + if err != nil { + t.Fatalf("unexpected error for %q: %v", target, err) + } + if !strings.Contains(res.Error, "ignored by ignore rules") { + t.Errorf("expected FilePatchTool to block case-variant %q, got error: %q", target, res.Error) + } + } + data, _ = os.ReadFile(filePath) + if string(data) != "harmless fixture content" { + t.Errorf("file was patched despite ignore rule: %q", string(data)) + } +} + func TestTools_OnModified(t *testing.T) { tmpDir := t.TempDir() filePath := filepath.Join(tmpDir, "tracked.txt") diff --git a/internal/tools/workspace.go b/internal/tools/workspace.go index a066134..c2a93be 100644 --- a/internal/tools/workspace.go +++ b/internal/tools/workspace.go @@ -4,7 +4,6 @@ import ( "fmt" "os" "path/filepath" - "runtime" "strings" "github.com/mevarx/GoCode/internal/ignore" @@ -12,7 +11,7 @@ import ( // isCaseInsensitiveFS reports whether the OS resolves filenames case-insensitively. func isCaseInsensitiveFS() bool { - return runtime.GOOS == "windows" || runtime.GOOS == "darwin" + return ignore.IsCaseInsensitiveFS() } // rejectNormalisationForms refuses spellings the OS opens differently than named (ADS, trailing space/dot).