Skip to content

Commit 0d6c74f

Browse files
wesmclaude
andcommitted
fix: resolve symlink targets in isDirOrSymlink, fix FindClaudeSourceFile parity
isDirOrSymlink now stat-resolves symlink targets to confirm they point to directories, rejecting symlinks to files and broken links. FindClaudeSourceFile updated to use isDirOrSymlink instead of IsDir, matching DiscoverClaudeProjects behavior for symlinked project directories. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4b98434 commit 0d6c74f

File tree

2 files changed

+111
-7
lines changed

2 files changed

+111
-7
lines changed

internal/sync/discovery.go

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,21 @@ var uuidRe = regexp.MustCompile(
2121
)
2222

2323
// isDirOrSymlink reports whether the entry is a directory or a
24-
// symlink (which may point to a directory).
25-
func isDirOrSymlink(entry os.DirEntry) bool {
26-
return entry.IsDir() || entry.Type()&os.ModeSymlink != 0
24+
// symlink that resolves to a directory. parentDir is needed to
25+
// build the full path for symlink resolution.
26+
func isDirOrSymlink(
27+
entry os.DirEntry, parentDir string,
28+
) bool {
29+
if entry.IsDir() {
30+
return true
31+
}
32+
if entry.Type()&os.ModeSymlink == 0 {
33+
return false
34+
}
35+
fi, err := os.Stat(
36+
filepath.Join(parentDir, entry.Name()),
37+
)
38+
return err == nil && fi.IsDir()
2739
}
2840

2941
// DiscoveredFile holds a discovered session JSONL file.
@@ -43,7 +55,7 @@ func DiscoverClaudeProjects(projectsDir string) []DiscoveredFile {
4355

4456
var files []DiscoveredFile
4557
for _, entry := range entries {
46-
if !isDirOrSymlink(entry) {
58+
if !isDirOrSymlink(entry, projectsDir) {
4759
continue
4860
}
4961

@@ -126,7 +138,7 @@ func FindClaudeSourceFile(
126138

127139
target := sessionID + ".jsonl"
128140
for _, entry := range entries {
129-
if !entry.IsDir() {
141+
if !isDirOrSymlink(entry, projectsDir) {
130142
continue
131143
}
132144
candidate := filepath.Join(
@@ -277,7 +289,7 @@ func DiscoverGeminiSessions(
277289

278290
var files []DiscoveredFile
279291
for _, hd := range hashDirs {
280-
if !isDirOrSymlink(hd) {
292+
if !isDirOrSymlink(hd, tmpDir) {
281293
continue
282294
}
283295
hash := hd.Name()
@@ -332,7 +344,7 @@ func FindGeminiSourceFile(
332344
}
333345

334346
for _, hd := range hashDirs {
335-
if !isDirOrSymlink(hd) {
347+
if !isDirOrSymlink(hd, tmpDir) {
336348
continue
337349
}
338350
chatsDir := filepath.Join(tmpDir, hd.Name(), "chats")

internal/sync/sync_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,3 +709,95 @@ func TestFindCopilotSourceFile_DirPreferred(t *testing.T) {
709709
t.Errorf("got %q, want dir path %q", got, dirPath)
710710
}
711711
}
712+
713+
// --- Symlink tests ---
714+
715+
func TestIsDirOrSymlink(t *testing.T) {
716+
dir := t.TempDir()
717+
718+
// Real directory
719+
realDir := filepath.Join(dir, "real-dir")
720+
if err := os.MkdirAll(realDir, 0o755); err != nil {
721+
t.Fatalf("mkdir: %v", err)
722+
}
723+
724+
// Regular file
725+
realFile := filepath.Join(dir, "file.txt")
726+
if err := os.WriteFile(
727+
realFile, []byte("hi"), 0o644,
728+
); err != nil {
729+
t.Fatalf("write: %v", err)
730+
}
731+
732+
// Symlink to directory
733+
if err := os.Symlink(
734+
realDir, filepath.Join(dir, "link-to-dir"),
735+
); err != nil {
736+
t.Skipf("symlink not supported: %v", err)
737+
}
738+
739+
// Symlink to file
740+
if err := os.Symlink(
741+
realFile, filepath.Join(dir, "link-to-file"),
742+
); err != nil {
743+
t.Fatalf("symlink: %v", err)
744+
}
745+
746+
// Broken symlink
747+
if err := os.Symlink(
748+
filepath.Join(dir, "gone"),
749+
filepath.Join(dir, "broken"),
750+
); err != nil {
751+
t.Fatalf("symlink: %v", err)
752+
}
753+
754+
entries, err := os.ReadDir(dir)
755+
if err != nil {
756+
t.Fatalf("readdir: %v", err)
757+
}
758+
759+
want := map[string]bool{
760+
"real-dir": true,
761+
"file.txt": false,
762+
"link-to-dir": true,
763+
"link-to-file": false,
764+
"broken": false,
765+
}
766+
767+
for _, e := range entries {
768+
expected, ok := want[e.Name()]
769+
if !ok {
770+
continue
771+
}
772+
got := isDirOrSymlink(e, dir)
773+
if got != expected {
774+
t.Errorf("isDirOrSymlink(%q) = %v, want %v",
775+
e.Name(), got, expected)
776+
}
777+
}
778+
}
779+
780+
func TestFindClaudeSourceFile_Symlink(t *testing.T) {
781+
dir := t.TempDir()
782+
783+
realDir := filepath.Join(dir, "real-project")
784+
if err := os.MkdirAll(realDir, 0o755); err != nil {
785+
t.Fatalf("mkdir: %v", err)
786+
}
787+
if err := os.WriteFile(
788+
filepath.Join(realDir, "sess-abc.jsonl"),
789+
[]byte("{}"), 0o644,
790+
); err != nil {
791+
t.Fatalf("write: %v", err)
792+
}
793+
794+
linkDir := filepath.Join(dir, "linked-project")
795+
if err := os.Symlink(realDir, linkDir); err != nil {
796+
t.Skipf("symlink not supported: %v", err)
797+
}
798+
799+
got := FindClaudeSourceFile(dir, "sess-abc")
800+
if got == "" {
801+
t.Error("expected to find session via symlink")
802+
}
803+
}

0 commit comments

Comments
 (0)