Skip to content

Commit c072ccd

Browse files
committed
fix: handle cross-device plugin moves
Close #628
1 parent a6ef9b2 commit c072ccd

3 files changed

Lines changed: 204 additions & 8 deletions

File tree

internal/manager.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,12 @@ func (m *Manager) LookupSdk(name string) (sdk.Sdk, error) {
7878

7979
// Normalize the name for cache lookup
8080
normalizedName := strings.ToLower(name)
81-
81+
8282
// Check cache with read lock
8383
m.mu.RLock()
8484
s, ok := m.openSdks[normalizedName]
8585
m.mu.RUnlock()
86-
86+
8787
if ok {
8888
logger.Debugf("SDK %s found in cache\n", name)
8989
return s, nil
@@ -109,7 +109,7 @@ func (m *Manager) LookupSdk(name string) (sdk.Sdk, error) {
109109
m.mu.Lock()
110110
m.openSdks[normalizedName] = s
111111
m.mu.Unlock()
112-
112+
113113
logger.Debugf("SDK %s loaded and cached successfully\n", name)
114114
return s, nil
115115
}
@@ -204,12 +204,12 @@ func (m *Manager) LoadAllSdk() ([]sdk.Sdk, error) {
204204
s, err := sdk.NewSdk(m.RuntimeEnvContext, path)
205205
if err == nil {
206206
sdkSlice = append(sdkSlice, s)
207-
207+
208208
// Add to cache with write lock
209209
m.mu.Lock()
210210
m.openSdks[normalizedName] = s
211211
m.mu.Unlock()
212-
212+
213213
logger.Debugf("SDK %s loaded successfully\n", sdkName)
214214
} else {
215215
logger.Debugf("Failed to load SDK %s: %v\n", sdkName, err)
@@ -233,7 +233,7 @@ func (m *Manager) Close() {
233233
// while we're closing SDK handlers
234234
m.mu.Lock()
235235
defer m.mu.Unlock()
236-
236+
237237
for _, handler := range m.openSdks {
238238
handler.Close()
239239
}
@@ -357,7 +357,7 @@ func (m *Manager) Update(pluginName string) error {
357357
}
358358
}()
359359
logger.Debugf("Moving updated plugin from %s to %s\n", tempPlugin.InstalledPath, sdkMetadata.PluginInstalledPath)
360-
if err = os.Rename(tempPlugin.InstalledPath, sdkMetadata.PluginInstalledPath); err != nil {
360+
if err = util.MovePath(tempPlugin.InstalledPath, sdkMetadata.PluginInstalledPath); err != nil {
361361
return fmt.Errorf("update %s plugin failed, err: %w", pluginName, err)
362362
}
363363

@@ -539,7 +539,7 @@ func (m *Manager) Add(pluginName, url, alias string) error {
539539
}
540540
}
541541
logger.Debugf("Moving plugin from %s to %s\n", tempPlugin.InstalledPath, installPath)
542-
if err = os.Rename(tempPlugin.InstalledPath, installPath); err != nil {
542+
if err = util.MovePath(tempPlugin.InstalledPath, installPath); err != nil {
543543
logger.Debugf("Failed to move plugin: %v\n", err)
544544
return fmt.Errorf("install plugin error: %w", err)
545545
}

internal/shared/util/file.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,18 @@
1717
package util
1818

1919
import (
20+
"errors"
2021
"io"
2122
"os"
2223
"os/exec"
2324
"path/filepath"
2425
"runtime"
2526
"strings"
27+
"syscall"
2628
)
2729

30+
var renamePath = os.Rename
31+
2832
func FileExists(filename string) bool {
2933
_, err := os.Stat(filename)
3034
if os.IsNotExist(err) {
@@ -57,6 +61,33 @@ func CopyFile(src, dst string) error {
5761
return nil
5862
}
5963

64+
// MovePath moves a file or directory to the target path.
65+
// If a cross-device rename fails, it falls back to copy and remove.
66+
func MovePath(src, dst string) error {
67+
if err := renamePath(src, dst); err == nil {
68+
return nil
69+
} else if !isCrossDeviceRenameError(err) {
70+
return err
71+
}
72+
73+
info, err := os.Stat(src)
74+
if err != nil {
75+
return err
76+
}
77+
78+
if info.IsDir() {
79+
if err := copyDir(src, dst); err != nil {
80+
return err
81+
}
82+
} else {
83+
if err := copySingleFile(src, dst, info.Mode()); err != nil {
84+
return err
85+
}
86+
}
87+
88+
return os.RemoveAll(src)
89+
}
90+
6091
// MoveFiles Move a folder or file to a specified directory
6192
func MoveFiles(src, targetDir string) error {
6293
info, err := os.Stat(src)
@@ -127,3 +158,66 @@ func MkSymlink(oldname, newname string) (err error) {
127158
}
128159
return os.Symlink(oldname, newname)
129160
}
161+
162+
func isCrossDeviceRenameError(err error) bool {
163+
var linkErr *os.LinkError
164+
if errors.As(err, &linkErr) {
165+
err = linkErr.Err
166+
}
167+
168+
if errors.Is(err, syscall.EXDEV) {
169+
return true
170+
}
171+
172+
msg := strings.ToLower(err.Error())
173+
return strings.Contains(msg, "cross-device link") ||
174+
strings.Contains(msg, "different disk drive") ||
175+
strings.Contains(msg, "not same device")
176+
}
177+
178+
func copyDir(src, dst string) error {
179+
info, err := os.Stat(src)
180+
if err != nil {
181+
return err
182+
}
183+
if err := os.MkdirAll(dst, info.Mode().Perm()); err != nil {
184+
return err
185+
}
186+
187+
entries, err := os.ReadDir(src)
188+
if err != nil {
189+
return err
190+
}
191+
192+
for _, entry := range entries {
193+
srcPath := filepath.Join(src, entry.Name())
194+
dstPath := filepath.Join(dst, entry.Name())
195+
196+
if entry.IsDir() {
197+
if err := copyDir(srcPath, dstPath); err != nil {
198+
return err
199+
}
200+
continue
201+
}
202+
203+
fileInfo, err := entry.Info()
204+
if err != nil {
205+
return err
206+
}
207+
if err := copySingleFile(srcPath, dstPath, fileInfo.Mode()); err != nil {
208+
return err
209+
}
210+
}
211+
212+
return nil
213+
}
214+
215+
func copySingleFile(src, dst string, mode os.FileMode) error {
216+
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
217+
return err
218+
}
219+
if err := CopyFile(src, dst); err != nil {
220+
return err
221+
}
222+
return ChangeModeIfNot(dst, mode)
223+
}

internal/shared/util/file_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/*
2+
* Copyright 2026 Han Li and contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package util
18+
19+
import (
20+
"errors"
21+
"os"
22+
"path/filepath"
23+
"syscall"
24+
"testing"
25+
)
26+
27+
func TestMovePathFallsBackOnCrossDeviceRename(t *testing.T) {
28+
oldRenamePath := renamePath
29+
renamePath = func(oldPath, newPath string) error {
30+
return &os.LinkError{Op: "rename", Old: oldPath, New: newPath, Err: syscall.EXDEV}
31+
}
32+
t.Cleanup(func() {
33+
renamePath = oldRenamePath
34+
})
35+
36+
srcDir := filepath.Join(t.TempDir(), "src")
37+
dstDir := filepath.Join(t.TempDir(), "dst")
38+
nestedDir := filepath.Join(srcDir, "nested")
39+
40+
if err := os.MkdirAll(nestedDir, 0755); err != nil {
41+
t.Fatalf("failed to create source directory: %v", err)
42+
}
43+
if err := os.WriteFile(filepath.Join(srcDir, "main.lua"), []byte("print('hello')\n"), 0644); err != nil {
44+
t.Fatalf("failed to write root file: %v", err)
45+
}
46+
if err := os.WriteFile(filepath.Join(nestedDir, "config.txt"), []byte("ok"), 0600); err != nil {
47+
t.Fatalf("failed to write nested file: %v", err)
48+
}
49+
50+
if err := MovePath(srcDir, dstDir); err != nil {
51+
t.Fatalf("MovePath returned error: %v", err)
52+
}
53+
54+
if _, err := os.Stat(srcDir); !errors.Is(err, os.ErrNotExist) {
55+
t.Fatalf("expected source to be removed, got err=%v", err)
56+
}
57+
58+
content, err := os.ReadFile(filepath.Join(dstDir, "main.lua"))
59+
if err != nil {
60+
t.Fatalf("failed to read moved root file: %v", err)
61+
}
62+
if string(content) != "print('hello')\n" {
63+
t.Fatalf("unexpected root file content: %q", content)
64+
}
65+
66+
info, err := os.Stat(filepath.Join(dstDir, "nested", "config.txt"))
67+
if err != nil {
68+
t.Fatalf("failed to stat moved nested file: %v", err)
69+
}
70+
if info.Mode().Perm() != 0600 {
71+
t.Fatalf("expected nested file mode 0600, got %o", info.Mode().Perm())
72+
}
73+
}
74+
75+
func TestMovePathReturnsNonCrossDeviceErrors(t *testing.T) {
76+
oldRenamePath := renamePath
77+
renamePath = func(oldPath, newPath string) error {
78+
return &os.LinkError{Op: "rename", Old: oldPath, New: newPath, Err: os.ErrPermission}
79+
}
80+
t.Cleanup(func() {
81+
renamePath = oldRenamePath
82+
})
83+
84+
srcFile := filepath.Join(t.TempDir(), "plugin.lua")
85+
dstFile := filepath.Join(t.TempDir(), "plugin-copy.lua")
86+
87+
if err := os.WriteFile(srcFile, []byte("print('hello')\n"), 0644); err != nil {
88+
t.Fatalf("failed to write source file: %v", err)
89+
}
90+
91+
err := MovePath(srcFile, dstFile)
92+
if !errors.Is(err, os.ErrPermission) {
93+
t.Fatalf("expected permission error, got %v", err)
94+
}
95+
96+
if _, statErr := os.Stat(srcFile); statErr != nil {
97+
t.Fatalf("expected source file to remain in place, got %v", statErr)
98+
}
99+
if _, statErr := os.Stat(dstFile); !errors.Is(statErr, os.ErrNotExist) {
100+
t.Fatalf("expected destination file to be absent, got %v", statErr)
101+
}
102+
}

0 commit comments

Comments
 (0)