fix(skills): preserve read-only backup trees
This commit is contained in:
@@ -18,6 +18,16 @@ var (
|
||||
skillPathMkdirAll = os.MkdirAll
|
||||
skillPathMkdirTemp = os.MkdirTemp
|
||||
skillPathChmod = os.Chmod
|
||||
skillPathLstat = os.Lstat
|
||||
skillPathMkdir = os.Mkdir
|
||||
skillPathReadDir = os.ReadDir
|
||||
skillPathReadlink = os.Readlink
|
||||
skillPathSymlink = os.Symlink
|
||||
skillPathOpen = os.Open
|
||||
skillPathOpenFile = os.OpenFile
|
||||
skillPathCopyBytes = io.Copy
|
||||
skillPathSync = func(file *os.File) error { return file.Sync() }
|
||||
skillPathWalkDir = filepath.WalkDir
|
||||
)
|
||||
|
||||
// moveSkillPathRecoverably moves one managed Skill path without weakening the
|
||||
@@ -26,7 +36,7 @@ var (
|
||||
// path before removing the source. Any failure before source removal leaves the
|
||||
// source intact; a removal failure deliberately leaves both verified copies.
|
||||
func moveSkillPathRecoverably(src, dst string) (err error) {
|
||||
if _, statErr := os.Lstat(dst); statErr == nil {
|
||||
if _, statErr := skillPathLstat(dst); statErr == nil {
|
||||
return fmt.Errorf("目标已存在: %s", dst)
|
||||
} else if !os.IsNotExist(statErr) {
|
||||
return fmt.Errorf("检查移动目标失败 %s: %w", dst, statErr)
|
||||
@@ -62,7 +72,7 @@ func moveSkillPathRecoverably(src, dst string) (err error) {
|
||||
if err := skillPathVerify(src, stage); err != nil {
|
||||
return fmt.Errorf("校验跨设备 Skill staging 失败 %s: %w", stage, err)
|
||||
}
|
||||
stageInfo, err := os.Lstat(stage)
|
||||
stageInfo, err := skillPathLstat(stage)
|
||||
if err != nil {
|
||||
return fmt.Errorf("检查跨设备 Skill staging 失败 %s: %w", stage, err)
|
||||
}
|
||||
@@ -108,7 +118,7 @@ func removePublishedSkillSource(src string) error {
|
||||
}
|
||||
removeErr := skillPathRemoveAll(src)
|
||||
if removeErr == nil {
|
||||
if _, statErr := os.Lstat(src); os.IsNotExist(statErr) {
|
||||
if _, statErr := skillPathLstat(src); os.IsNotExist(statErr) {
|
||||
return nil
|
||||
} else if statErr == nil {
|
||||
removeErr = errors.New("源路径仍存在")
|
||||
@@ -121,7 +131,7 @@ func removePublishedSkillSource(src string) error {
|
||||
|
||||
func prepareSkillPathTreeRemoval(root string) ([]skillPathDirMode, error) {
|
||||
var modes []skillPathDirMode
|
||||
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
|
||||
err := skillPathWalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
@@ -152,7 +162,7 @@ func restoreSkillPathDirModes(modes []skillPathDirMode) error {
|
||||
var restoreErr error
|
||||
for i := len(modes) - 1; i >= 0; i-- {
|
||||
item := modes[i]
|
||||
if _, err := os.Lstat(item.path); os.IsNotExist(err) {
|
||||
if _, err := skillPathLstat(item.path); os.IsNotExist(err) {
|
||||
continue
|
||||
} else if err != nil {
|
||||
restoreErr = errors.Join(restoreErr, err)
|
||||
@@ -169,9 +179,9 @@ func makeSkillPathTreeWritable(root string) {
|
||||
// Best effort only: RemoveAll below remains the source of truth and its
|
||||
// error is returned. This preparation lets it traverse read-only staging
|
||||
// directories left by a copy or verification failure.
|
||||
_ = filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
|
||||
_ = skillPathWalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
|
||||
if walkErr == nil && entry.IsDir() {
|
||||
_ = os.Chmod(path, 0o700)
|
||||
_ = skillPathChmod(path, 0o700)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
@@ -184,7 +194,7 @@ func RestoreSkillPath(backup, original string) error {
|
||||
}
|
||||
|
||||
func copySkillPathLexically(src, dst string) error {
|
||||
info, err := os.Lstat(src)
|
||||
info, err := skillPathLstat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -193,10 +203,10 @@ func copySkillPathLexically(src, dst string) error {
|
||||
// Staging must remain writable while children are copied. Restore the
|
||||
// source mode only after the directory is complete so read-only Skill
|
||||
// directories (for example 0555) can still be migrated lexically.
|
||||
if err := os.Mkdir(dst, 0o700); err != nil {
|
||||
if err := skillPathMkdir(dst, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err := os.ReadDir(src)
|
||||
entries, err := skillPathReadDir(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -207,11 +217,11 @@ func copySkillPathLexically(src, dst string) error {
|
||||
}
|
||||
return os.Chmod(dst, info.Mode().Perm())
|
||||
case info.Mode()&os.ModeSymlink != 0:
|
||||
target, err := os.Readlink(src)
|
||||
target, err := skillPathReadlink(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Symlink(target, dst)
|
||||
return skillPathSymlink(target, dst)
|
||||
case info.Mode().IsRegular():
|
||||
return copyRegularSkillFile(src, dst, info.Mode().Perm())
|
||||
default:
|
||||
@@ -220,31 +230,31 @@ func copySkillPathLexically(src, dst string) error {
|
||||
}
|
||||
|
||||
func copyRegularSkillFile(src, dst string, mode os.FileMode) (err error) {
|
||||
in, err := os.Open(src)
|
||||
in, err := skillPathOpen(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { err = errors.Join(err, in.Close()) }()
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
out, err := skillPathOpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { err = errors.Join(err, out.Close()) }()
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
if _, err := skillPathCopyBytes(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := out.Sync(); err != nil {
|
||||
if err := skillPathSync(out); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chmod(dst, mode)
|
||||
}
|
||||
|
||||
func verifySkillPathCopy(src, dst string) error {
|
||||
srcInfo, err := os.Lstat(src)
|
||||
srcInfo, err := skillPathLstat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dstInfo, err := os.Lstat(dst)
|
||||
dstInfo, err := skillPathLstat(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -277,11 +287,11 @@ func verifySkillPathCopy(src, dst string) error {
|
||||
}
|
||||
return nil
|
||||
case srcInfo.Mode()&os.ModeSymlink != 0:
|
||||
srcTarget, err := os.Readlink(src)
|
||||
srcTarget, err := skillPathReadlink(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dstTarget, err := os.Readlink(dst)
|
||||
dstTarget, err := skillPathReadlink(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -314,7 +324,7 @@ func verifySkillPathCopy(src, dst string) error {
|
||||
}
|
||||
|
||||
func skillPathEntryNames(dir string) ([]string, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
entries, err := skillPathReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -327,13 +337,13 @@ func skillPathEntryNames(dir string) ([]string, error) {
|
||||
}
|
||||
|
||||
func skillPathFileDigest(path string) ([sha256.Size]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
f, err := skillPathOpen(path)
|
||||
if err != nil {
|
||||
return [sha256.Size]byte{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, f); err != nil {
|
||||
if _, err := skillPathCopyBytes(hash, f); err != nil {
|
||||
return [sha256.Size]byte{}, err
|
||||
}
|
||||
var digest [sha256.Size]byte
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageSkillPathMoveSystemErrors(t *testing.T) {
|
||||
t.Run("destination probe", func(t *testing.T) {
|
||||
src, dst := makeSkillMoveFixture(t)
|
||||
original := skillPathLstat
|
||||
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
|
||||
if path == dst {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
return original(path)
|
||||
})
|
||||
if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("destination parent", func(t *testing.T) {
|
||||
src, dst := makeSkillMoveFixture(t)
|
||||
testseam.Swap(t, &skillPathMkdirAll, func(string, os.FileMode) error { return os.ErrPermission })
|
||||
if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("staging creation", func(t *testing.T) {
|
||||
src, dst := makeSkillMoveFixture(t)
|
||||
forceCrossDeviceRename(t, src, dst)
|
||||
testseam.Swap(t, &skillPathMkdirTemp, func(string, string) (string, error) { return "", os.ErrPermission })
|
||||
if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("failed staging cleanup", func(t *testing.T) {
|
||||
src, dst := makeSkillMoveFixture(t)
|
||||
forceCrossDeviceRename(t, src, dst)
|
||||
testseam.Swap(t, &skillPathCopy, func(string, string) error { return errors.New("copy failed") })
|
||||
testseam.Swap(t, &skillPathRemoveAll, func(string) error { return errors.New("cleanup failed") })
|
||||
err := moveSkillPathRecoverably(src, dst)
|
||||
if err == nil || !strings.Contains(err.Error(), "copy failed") || !strings.Contains(err.Error(), "cleanup failed") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing copied staging", func(t *testing.T) {
|
||||
src, dst := makeSkillMoveFixture(t)
|
||||
forceCrossDeviceRename(t, src, dst)
|
||||
testseam.Swap(t, &skillPathCopy, func(string, string) error { return nil })
|
||||
testseam.Swap(t, &skillPathVerify, func(string, string) error { return nil })
|
||||
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "检查跨设备") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
path func(src, dst string) string
|
||||
want string
|
||||
}{
|
||||
{name: "prepare staging mode", path: func(_, dst string) string { return filepath.Join(filepath.Dir(dst), "payload") }, want: "准备跨设备"},
|
||||
{name: "restore published mode", path: func(_, dst string) string { return dst }, want: "恢复已发布"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
src, dst := makeSkillMoveFixture(t)
|
||||
forceCrossDeviceRename(t, src, dst)
|
||||
original := skillPathChmod
|
||||
testseam.Swap(t, &skillPathChmod, func(path string, mode os.FileMode) error {
|
||||
if tc.name == "prepare staging mode" && strings.HasSuffix(path, string(filepath.Separator)+"payload") {
|
||||
return errors.New("chmod failed")
|
||||
}
|
||||
if tc.name == "restore published mode" && path == dst {
|
||||
return errors.New("chmod failed")
|
||||
}
|
||||
return original(path, mode)
|
||||
})
|
||||
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("published staging cleanup", func(t *testing.T) {
|
||||
src, dst := makeSkillMoveFixture(t)
|
||||
forceCrossDeviceRename(t, src, dst)
|
||||
original := skillPathRemoveAll
|
||||
testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
|
||||
if strings.Contains(filepath.Base(path), ".cross-device-") {
|
||||
return errors.New("cleanup failed")
|
||||
}
|
||||
return original(path)
|
||||
})
|
||||
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "清理已发布") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("source deletion cannot be confirmed", func(t *testing.T) {
|
||||
src, dst := makeSkillMoveFixture(t)
|
||||
forceCrossDeviceRename(t, src, dst)
|
||||
removalAttempted := false
|
||||
originalLstat := skillPathLstat
|
||||
originalRemove := skillPathRemoveAll
|
||||
testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
|
||||
if path == src {
|
||||
removalAttempted = true
|
||||
return nil
|
||||
}
|
||||
return originalRemove(path)
|
||||
})
|
||||
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
|
||||
if path == src && removalAttempted {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
return originalLstat(path)
|
||||
})
|
||||
if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillPathPermissionRestorationErrors(t *testing.T) {
|
||||
t.Run("walk error", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error {
|
||||
return fn(root, nil, errors.New("walk failed"))
|
||||
})
|
||||
if _, err := prepareSkillPathTreeRemoval("ignored"); err == nil || !strings.Contains(err.Error(), "walk failed") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("entry info error", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error {
|
||||
return fn(root, skillPathErrorDirEntry{}, nil)
|
||||
})
|
||||
if _, err := prepareSkillPathTreeRemoval("ignored"); err == nil || !strings.Contains(err.Error(), "info failed") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing restored directory", func(t *testing.T) {
|
||||
if err := restoreSkillPathDirModes([]skillPathDirMode{{path: filepath.Join(t.TempDir(), "missing"), mode: 0o555}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("restore probe error", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return nil, os.ErrPermission })
|
||||
if err := restoreSkillPathDirModes([]skillPathDirMode{{path: "blocked", mode: 0o555}}); !errors.Is(err, os.ErrPermission) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("restore chmod error", func(t *testing.T) {
|
||||
path := t.TempDir()
|
||||
testseam.Swap(t, &skillPathChmod, func(string, os.FileMode) error { return os.ErrPermission })
|
||||
if err := restoreSkillPathDirModes([]skillPathDirMode{{path: path, mode: 0o555}}); !errors.Is(err, os.ErrPermission) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillPathLexicalCopyErrors(t *testing.T) {
|
||||
t.Run("source lstat", func(t *testing.T) {
|
||||
if err := copySkillPathLexically(filepath.Join(t.TempDir(), "missing"), "unused"); !os.IsNotExist(err) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("destination mkdir", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
|
||||
if err := os.Mkdir(src, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(dst, []byte("occupied"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := copySkillPathLexically(src, dst); err == nil {
|
||||
t.Fatal("expected mkdir failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("source readdir", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
src := filepath.Join(root, "src")
|
||||
if err := os.Mkdir(src, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &skillPathReadDir, func(string) ([]os.DirEntry, error) { return nil, os.ErrPermission })
|
||||
if err := copySkillPathLexically(src, filepath.Join(root, "dst")); !errors.Is(err, os.ErrPermission) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nested copy", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
|
||||
if err := os.Mkdir(src, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(src, "child"), []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original := skillPathLstat
|
||||
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
|
||||
if filepath.Base(path) == "child" {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
return original(path)
|
||||
})
|
||||
if err := copySkillPathLexically(src, dst); !errors.Is(err, os.ErrPermission) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("readlink", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
src := filepath.Join(root, "link")
|
||||
if err := os.Symlink("target", src); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "", os.ErrPermission })
|
||||
if err := copySkillPathLexically(src, filepath.Join(root, "dst")); !errors.Is(err, os.ErrPermission) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("special path", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) {
|
||||
return skillPathFakeInfo{mode: os.ModeNamedPipe}, nil
|
||||
})
|
||||
if err := copySkillPathLexically("special", "dst"); err == nil || !strings.Contains(err.Error(), "特殊") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("regular open", func(t *testing.T) {
|
||||
if err := copyRegularSkillFile(filepath.Join(t.TempDir(), "missing"), "dst", 0o600); !os.IsNotExist(err) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("regular destination open", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
|
||||
if err := os.WriteFile(src, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(dst, []byte("occupied"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := copyRegularSkillFile(src, dst, 0o600); err == nil {
|
||||
t.Fatal("expected exclusive create failure")
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
swap func(*testing.T)
|
||||
}{
|
||||
{name: "copy", swap: func(t *testing.T) {
|
||||
testseam.Swap(t, &skillPathCopyBytes, func(io.Writer, io.Reader) (int64, error) { return 0, errors.New("copy failed") })
|
||||
}},
|
||||
{name: "sync", swap: func(t *testing.T) {
|
||||
testseam.Swap(t, &skillPathSync, func(*os.File) error { return errors.New("sync failed") })
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
|
||||
if err := os.WriteFile(src, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tc.swap(t)
|
||||
if err := copyRegularSkillFile(src, dst, 0o600); err == nil || !strings.Contains(err.Error(), tc.name) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillPathVerificationErrors(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
t.Run("source and destination lstat", func(t *testing.T) {
|
||||
if err := verifySkillPathCopy(filepath.Join(root, "missing-src"), "dst"); !os.IsNotExist(err) {
|
||||
t.Fatalf("source error = %v", err)
|
||||
}
|
||||
src := filepath.Join(root, "source")
|
||||
if err := os.WriteFile(src, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := verifySkillPathCopy(src, filepath.Join(root, "missing-dst")); !os.IsNotExist(err) {
|
||||
t.Fatalf("destination error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("directory and file modes", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
dir bool
|
||||
}{
|
||||
{name: "directory", dir: true},
|
||||
{name: "file"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
src, dst := filepath.Join(t.TempDir(), "src"), filepath.Join(t.TempDir(), "dst")
|
||||
if tc.dir {
|
||||
if err := os.Mkdir(src, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(dst, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
if err := os.WriteFile(src, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(dst, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "权限") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("directory read errors", func(t *testing.T) {
|
||||
for _, failDest := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "source", true: "destination"}[failDest], func(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst")
|
||||
if err := os.Mkdir(src, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(dst, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original := skillPathReadDir
|
||||
testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
|
||||
if (path == dst) == failDest {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
return original(path)
|
||||
})
|
||||
if err := verifySkillPathCopy(src, dst); !errors.Is(err, os.ErrPermission) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nested mismatch", func(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst")
|
||||
if err := os.Mkdir(src, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(dst, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(src, "child"), []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(dst, "child"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "类型") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("symlink reads", func(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst")
|
||||
if err := os.Symlink("one", src); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
if err := os.Symlink("one", dst); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, failDest := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "source", true: "destination"}[failDest], func(t *testing.T) {
|
||||
original := skillPathReadlink
|
||||
testseam.Swap(t, &skillPathReadlink, func(path string) (string, error) {
|
||||
if (path == dst) == failDest {
|
||||
return "", os.ErrPermission
|
||||
}
|
||||
return original(path)
|
||||
})
|
||||
if err := verifySkillPathCopy(src, dst); !errors.Is(err, os.ErrPermission) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("file digest opens", func(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst")
|
||||
if err := os.WriteFile(src, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(dst, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, failDest := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "source", true: "destination"}[failDest], func(t *testing.T) {
|
||||
original := skillPathOpen
|
||||
testseam.Swap(t, &skillPathOpen, func(path string) (*os.File, error) {
|
||||
if (path == dst) == failDest {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
return original(path)
|
||||
})
|
||||
if err := verifySkillPathCopy(src, dst); !errors.Is(err, os.ErrPermission) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("special path", func(t *testing.T) {
|
||||
fake := skillPathFakeInfo{mode: os.ModeNamedPipe}
|
||||
testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return fake, nil })
|
||||
if err := verifySkillPathCopy("src", "dst"); err == nil || !strings.Contains(err.Error(), "特殊") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("entry names", func(t *testing.T) {
|
||||
if _, err := skillPathEntryNames(filepath.Join(root, "missing-dir")); !os.IsNotExist(err) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("digest open and read", func(t *testing.T) {
|
||||
if _, err := skillPathFileDigest(filepath.Join(root, "missing-file")); !os.IsNotExist(err) {
|
||||
t.Fatalf("open error = %v", err)
|
||||
}
|
||||
file := filepath.Join(root, "digest-file")
|
||||
if err := os.WriteFile(file, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &skillPathCopyBytes, func(io.Writer, io.Reader) (int64, error) { return 0, errors.New("read failed") })
|
||||
if _, err := skillPathFileDigest(file); err == nil || !strings.Contains(err.Error(), "read failed") {
|
||||
t.Fatalf("read error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type skillPathFakeInfo struct {
|
||||
mode os.FileMode
|
||||
}
|
||||
|
||||
func (skillPathFakeInfo) Name() string { return "fake" }
|
||||
func (skillPathFakeInfo) Size() int64 { return 0 }
|
||||
func (info skillPathFakeInfo) Mode() os.FileMode { return info.mode }
|
||||
func (skillPathFakeInfo) ModTime() time.Time { return time.Time{} }
|
||||
func (info skillPathFakeInfo) IsDir() bool { return info.mode.IsDir() }
|
||||
func (skillPathFakeInfo) Sys() any { return nil }
|
||||
|
||||
type skillPathErrorDirEntry struct{}
|
||||
|
||||
func (skillPathErrorDirEntry) Name() string { return "broken" }
|
||||
func (skillPathErrorDirEntry) IsDir() bool { return true }
|
||||
func (skillPathErrorDirEntry) Type() os.FileMode { return os.ModeDir }
|
||||
func (skillPathErrorDirEntry) Info() (os.FileInfo, error) { return nil, errors.New("info failed") }
|
||||
Reference in New Issue
Block a user