Skip to content

Commit 2f9960c

Browse files
committed
fix: use os.samefile for symlink check, remove tmo file in copy dir
1 parent 737d510 commit 2f9960c

4 files changed

Lines changed: 151 additions & 26 deletions

File tree

internal/ai/skills/fs_util.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ func mergeDir(src, dst string) error {
2222
if err != nil {
2323
return err
2424
}
25+
// os.Symlink is not idempotent (returns EEXIST). Remove any existing
26+
// entry so the call is safe under concurrent writes or repeated merges.
27+
_ = os.Remove(dstPath)
2528
if err := os.Symlink(target, dstPath); err != nil {
2629
return err
2730
}

internal/ai/skills/fs_util_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package skills
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"runtime"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestMergeDir(t *testing.T) {
14+
if runtime.GOOS == "windows" {
15+
t.Skip("symlink tests skipped on windows")
16+
}
17+
18+
t.Run("copies regular files", func(t *testing.T) {
19+
src := t.TempDir()
20+
dst := t.TempDir()
21+
require.NoError(t, os.WriteFile(filepath.Join(src, "file.txt"), []byte("hello"), 0o644))
22+
23+
require.NoError(t, mergeDir(src, dst))
24+
25+
data, err := os.ReadFile(filepath.Join(dst, "file.txt"))
26+
require.NoError(t, err)
27+
assert.Equal(t, "hello", string(data))
28+
})
29+
30+
t.Run("preserves symlinks", func(t *testing.T) {
31+
src := t.TempDir()
32+
dst := t.TempDir()
33+
target := filepath.Join(src, "target.txt")
34+
require.NoError(t, os.WriteFile(target, []byte("target"), 0o644))
35+
require.NoError(t, os.Symlink(target, filepath.Join(src, "link")))
36+
37+
require.NoError(t, mergeDir(src, dst))
38+
39+
linkDst := filepath.Join(dst, "link")
40+
info, err := os.Lstat(linkDst)
41+
require.NoError(t, err)
42+
assert.NotZero(t, info.Mode()&os.ModeSymlink, "should be a symlink")
43+
})
44+
45+
t.Run("symlink overwrite is idempotent (no EEXIST)", func(t *testing.T) {
46+
src := t.TempDir()
47+
dst := t.TempDir()
48+
target := filepath.Join(src, "target.txt")
49+
require.NoError(t, os.WriteFile(target, []byte("target"), 0o644))
50+
require.NoError(t, os.Symlink(target, filepath.Join(src, "link")))
51+
52+
// First merge creates the symlink.
53+
require.NoError(t, mergeDir(src, dst))
54+
// Second merge must not fail with EEXIST.
55+
require.NoError(t, mergeDir(src, dst))
56+
57+
linkDst := filepath.Join(dst, "link")
58+
info, err := os.Lstat(linkDst)
59+
require.NoError(t, err)
60+
assert.NotZero(t, info.Mode()&os.ModeSymlink)
61+
})
62+
63+
t.Run("recurses into subdirectories", func(t *testing.T) {
64+
src := t.TempDir()
65+
dst := t.TempDir()
66+
sub := filepath.Join(src, "sub")
67+
require.NoError(t, os.MkdirAll(sub, 0o755))
68+
require.NoError(t, os.WriteFile(filepath.Join(sub, "nested.txt"), []byte("nested"), 0o644))
69+
70+
require.NoError(t, mergeDir(src, dst))
71+
72+
data, err := os.ReadFile(filepath.Join(dst, "sub", "nested.txt"))
73+
require.NoError(t, err)
74+
assert.Equal(t, "nested", string(data))
75+
})
76+
}

internal/ai/skills/symlink.go

Lines changed: 34 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@ package skills
22

33
import (
44
"fmt"
5+
"io"
56
"os"
67
"os/exec"
78
"path/filepath"
89
"runtime"
910
)
1011

12+
// stderrWriter is the target for diagnostic output. Replaced in tests.
13+
var stderrWriter io.Writer = os.Stderr
14+
1115
// CreateSkillLink installs skillName from sourceSkillDir into agentSkillsDir.
1216
// useCopy=true copies files recursively; useCopy=false creates a symlink.
1317
// The operation is idempotent: a correct existing symlink or copy is left unchanged.
@@ -21,15 +25,17 @@ func CreateSkillLink(sourceSkillDir, agentSkillsDir, skillName string, useCopy b
2125
info, err := os.Lstat(linkPath)
2226
if err == nil {
2327
if info.Mode()&os.ModeSymlink != 0 {
24-
if isSymlinkCorrect(linkPath, agentSkillsDir, sourceSkillDir) {
28+
// For useCopy=false: skip if already pointing to the right place.
29+
// For useCopy=true: remove the symlink so we can replace it with a copy.
30+
if !useCopy && isSymlinkCorrect(linkPath, sourceSkillDir) {
2531
return nil
2632
}
2733
if rmErr := os.Remove(linkPath); rmErr != nil {
2834
return fmt.Errorf("remove existing symlink %s: %w", linkPath, rmErr)
2935
}
3036
} else if info.IsDir() {
3137
if !useCopy {
32-
fmt.Fprintf(os.Stderr,
38+
fmt.Fprintf(stderrWriter,
3339
"warning: %s is a copied directory; remove it manually to switch to symlink mode\n",
3440
linkPath)
3541
return nil
@@ -49,19 +55,17 @@ func CreateSkillLink(sourceSkillDir, agentSkillsDir, skillName string, useCopy b
4955
}
5056

5157
// isSymlinkCorrect returns true if linkPath is a non-broken symlink resolving to sourceSkillDir.
52-
func isSymlinkCorrect(linkPath, agentSkillsDir, sourceSkillDir string) bool {
53-
target, err := os.Readlink(linkPath)
58+
// os.SameFile is used instead of string comparison to handle case-insensitive filesystems (e.g. macOS APFS).
59+
func isSymlinkCorrect(linkPath, sourceSkillDir string) bool {
60+
linkInfo, err := os.Stat(linkPath)
5461
if err != nil {
55-
return false
56-
}
57-
if !filepath.IsAbs(target) {
58-
target = filepath.Join(agentSkillsDir, target)
62+
return false // broken symlink
5963
}
60-
if filepath.Clean(target) != filepath.Clean(sourceSkillDir) {
64+
srcInfo, err := os.Stat(sourceSkillDir)
65+
if err != nil {
6166
return false
6267
}
63-
_, err = os.Stat(linkPath)
64-
return err == nil
68+
return os.SameFile(linkInfo, srcInfo)
6569
}
6670

6771
// createSymlink creates a symlink at linkPath pointing to sourceSkillDir.
@@ -83,7 +87,7 @@ func createSymlink(sourceSkillDir, agentSkillsDir, linkPath string) error {
8387
if err := exec.Command("cmd", "/C", "mklink", "/J", linkPath, sourceSkillDir).Run(); err == nil {
8488
return nil
8589
}
86-
fmt.Fprintf(os.Stderr, "warning: symlink and junction unavailable; copying %s to %s\n", sourceSkillDir, linkPath)
90+
fmt.Fprintf(stderrWriter, "warning: symlink and junction unavailable; copying %s to %s\n", sourceSkillDir, linkPath)
8791
return copyDir(sourceSkillDir, linkPath)
8892
}
8993

@@ -95,25 +99,31 @@ func copyDir(src, dst string) error {
9599
if err != nil {
96100
return fmt.Errorf("create temp copy dir: %w", err)
97101
}
102+
// Always clean up the temp dir so it is never left as an orphan in agentSkillsDir.
103+
tmpRemoved := false
104+
defer func() {
105+
if !tmpRemoved {
106+
_ = os.RemoveAll(tmpDst)
107+
}
108+
}()
109+
98110
if err := mergeDir(src, tmpDst); err != nil {
99-
_ = os.RemoveAll(tmpDst)
100111
return err
101112
}
102113
if err := os.RemoveAll(dst); err != nil {
103-
_ = os.RemoveAll(tmpDst)
104114
return fmt.Errorf("remove stale copy dir: %w", err)
105115
}
106116
if err := os.Rename(tmpDst, dst); err != nil {
107117
// Cross-filesystem fallback: re-create dst from the temp copy.
118+
fmt.Fprintf(stderrWriter, "warning: rename %s → %s failed (%v); falling back to copy\n", tmpDst, dst, err)
108119
if mkErr := os.MkdirAll(dst, 0o755); mkErr != nil {
109-
_ = os.RemoveAll(tmpDst)
110120
return fmt.Errorf("create copy dir: %w", mkErr)
111121
}
112122
if mergeErr := mergeDir(tmpDst, dst); mergeErr != nil {
113-
_ = os.RemoveAll(tmpDst)
114123
return mergeErr
115124
}
116-
_ = os.RemoveAll(tmpDst)
125+
} else {
126+
tmpRemoved = true // rename succeeded; temp dir is now dst
117127
}
118128
return nil
119129
}
@@ -151,20 +161,18 @@ func CheckSkillLink(agentSkillsDir, skillName, expectedSourceDir string) string
151161
return "copy"
152162
}
153163

154-
// It's a symlink. Verify the target exists.
155-
if _, err := os.Stat(linkPath); err != nil {
164+
// It's a symlink. Verify the target exists by following the link.
165+
resolvedInfo, err := os.Stat(linkPath)
166+
if err != nil {
156167
return "broken"
157168
}
158169

159-
// Target exists. Check if it points to the expected place.
160-
target, err := os.Readlink(linkPath)
170+
// Use os.SameFile to handle case-insensitive filesystems (e.g. macOS APFS).
171+
srcInfo, err := os.Stat(expectedSourceDir)
161172
if err != nil {
162-
return "broken"
163-
}
164-
if !filepath.IsAbs(target) {
165-
target = filepath.Join(agentSkillsDir, target)
173+
return "wrong_target"
166174
}
167-
if filepath.Clean(target) == filepath.Clean(expectedSourceDir) {
175+
if os.SameFile(resolvedInfo, srcInfo) {
168176
return "ok"
169177
}
170178
return "wrong_target"

internal/ai/skills/symlink_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,27 @@
11
package skills
22

33
import (
4+
"bytes"
45
"os"
56
"path/filepath"
67
"runtime"
8+
"strings"
79
"testing"
810

911
"github.com/stretchr/testify/assert"
1012
"github.com/stretchr/testify/require"
1113
)
1214

15+
// captureStderr replaces stderrWriter with a buffer for the duration of the test.
16+
func captureStderr(t *testing.T) *bytes.Buffer {
17+
t.Helper()
18+
buf := &bytes.Buffer{}
19+
orig := stderrWriter
20+
stderrWriter = buf
21+
t.Cleanup(func() { stderrWriter = orig })
22+
return buf
23+
}
24+
1325
// makeSkillSource creates a temporary directory with a SKILL.md file inside.
1426
func makeSkillSource(t *testing.T) string {
1527
t.Helper()
@@ -183,6 +195,7 @@ func TestCreateSkillLink(t *testing.T) {
183195
})
184196

185197
t.Run("warns and skips real directory when useCopy is false", func(t *testing.T) {
198+
buf := captureStderr(t)
186199
agentDir := t.TempDir()
187200
linkPath := filepath.Join(agentDir, "my-skill")
188201
require.NoError(t, os.MkdirAll(linkPath, 0o755))
@@ -200,6 +213,8 @@ func TestCreateSkillLink(t *testing.T) {
200213
info, err := os.Lstat(linkPath)
201214
require.NoError(t, err)
202215
assert.Zero(t, info.Mode()&os.ModeSymlink, "entry should remain a directory")
216+
// Warning must be emitted to stderr.
217+
assert.True(t, strings.Contains(buf.String(), "warning:"), "expected warning on stderr, got: %q", buf.String())
203218
})
204219

205220
t.Run("errors on regular file at linkPath", func(t *testing.T) {
@@ -228,6 +243,29 @@ func TestCreateSkillLink(t *testing.T) {
228243
_, err := os.Stat(staleFile)
229244
assert.True(t, os.IsNotExist(err), "stale file should be removed after re-install")
230245
})
246+
247+
t.Run("converts existing correct symlink to copy when useCopy switches to true", func(t *testing.T) {
248+
src := makeSkillSource(t)
249+
agentDir := t.TempDir()
250+
251+
// Install as symlink first.
252+
require.NoError(t, CreateSkillLink(src, agentDir, "my-skill", false))
253+
assert.Equal(t, "ok", CheckSkillLink(agentDir, "my-skill", src))
254+
info, err := os.Lstat(filepath.Join(agentDir, "my-skill"))
255+
require.NoError(t, err)
256+
assert.NotZero(t, info.Mode()&os.ModeSymlink, "should be a symlink after first install")
257+
258+
// Re-install with useCopy=true; the symlink must be replaced by a real directory.
259+
require.NoError(t, CreateSkillLink(src, agentDir, "my-skill", true))
260+
assert.Equal(t, "copy", CheckSkillLink(agentDir, "my-skill", src))
261+
info, err = os.Lstat(filepath.Join(agentDir, "my-skill"))
262+
require.NoError(t, err)
263+
assert.Zero(t, info.Mode()&os.ModeSymlink, "should be a real directory after copy install")
264+
data, err := os.ReadFile(filepath.Join(agentDir, "my-skill", "SKILL.md"))
265+
require.NoError(t, err)
266+
assert.Equal(t, "# skill", string(data))
267+
})
268+
231269
}
232270

233271
// --- RemoveSkillLink ---

0 commit comments

Comments
 (0)