diff --git a/backend/installfinders/common/helpers.go b/backend/installfinders/common/helpers.go index 021a21b2..6d8464b1 100644 --- a/backend/installfinders/common/helpers.go +++ b/backend/installfinders/common/helpers.go @@ -2,12 +2,22 @@ package common import ( "log/slog" + "os" "path/filepath" "runtime" "strings" ) func OsPathEqual(path1, path2 string) bool { + // Same file on disk (inode) means same install, regardless of path + // spelling. This catches duplicates from case-only differences on + // case-insensitive filesystems (e.g. macOS default APFS), where string + // comparison alone sees two different installs. + if info1, err1 := os.Stat(path1); err1 == nil { + if info2, err2 := os.Stat(path2); err2 == nil && os.SameFile(info1, info2) { + return true + } + } path1 = realPath(path1) path2 = realPath(path2) if runtime.GOOS == "windows" { diff --git a/backend/installfinders/common/helpers_test.go b/backend/installfinders/common/helpers_test.go new file mode 100644 index 00000000..69771027 --- /dev/null +++ b/backend/installfinders/common/helpers_test.go @@ -0,0 +1,31 @@ +package common + +import ( + "os" + "path/filepath" + "testing" +) + +func TestOsPathEqualSameFile(t *testing.T) { + dir := t.TempDir() + + // Same path is trivially equal. + if !OsPathEqual(dir, dir) { + t.Error("identical paths should be equal") + } + + // Same directory reached through a symlink must be equal. + link := filepath.Join(t.TempDir(), "link") + if err := os.Symlink(dir, link); err != nil { + t.Skipf("cannot create symlink: %v", err) + } + if !OsPathEqual(dir, link) { + t.Error("symlinked paths to the same directory should be equal") + } + + // Different directories must not be equal. + other := t.TempDir() + if OsPathEqual(dir, other) { + t.Error("different directories should not be equal") + } +}