|
|
|
@@ -0,0 +1,451 @@
|
|
|
|
|
// Copyright 2026 Alibaba Group
|
|
|
|
|
// Licensed under the Apache License, Version 2.0
|
|
|
|
|
|
|
|
|
|
package upgrade
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"errors"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// childMoveDirEntry fakes one directory entry for seam-driven claim scans.
|
|
|
|
|
type childMoveDirEntry struct {
|
|
|
|
|
name string
|
|
|
|
|
isDir bool
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (entry childMoveDirEntry) Name() string { return entry.name }
|
|
|
|
|
func (entry childMoveDirEntry) IsDir() bool { return entry.isDir }
|
|
|
|
|
func (entry childMoveDirEntry) Type() os.FileMode { return entry.info().Mode().Type() }
|
|
|
|
|
func (entry childMoveDirEntry) Info() (os.FileInfo, error) { return entry.info(), nil }
|
|
|
|
|
func (entry childMoveDirEntry) info() os.FileInfo {
|
|
|
|
|
if entry.isDir {
|
|
|
|
|
return skillPathFakeInfo{mode: os.ModeDir | 0o755}
|
|
|
|
|
}
|
|
|
|
|
return skillPathFakeInfo{mode: 0o644}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// forceChildMove routes every directory publication through the child-move
|
|
|
|
|
// slow path: the atomic primitive reports the flag unsupported and the
|
|
|
|
|
// fast-path rename fails the way platforms that refuse renaming over any
|
|
|
|
|
// existing directory do.
|
|
|
|
|
func forceChildMove(t *testing.T) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
|
|
|
|
|
testseam.Swap(t, &skillPathRename, func(string, string) error { return os.ErrExist })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestCrossPlatformCoverageChildMoveEdges pins the error and dispatch
|
|
|
|
|
// branches of the child-move directory fallback on every platform: the
|
|
|
|
|
// platform coverage gates only execute TestCrossPlatformCoverage-named
|
|
|
|
|
// tests, so these branches must be driven through seams rather than the
|
|
|
|
|
// platform-specific filesystem behaviors that cover them incidentally
|
|
|
|
|
// elsewhere.
|
|
|
|
|
func TestCrossPlatformCoverageChildMoveEdges(t *testing.T) {
|
|
|
|
|
|
|
|
|
|
t.Run("directory source stat failure surfaces", func(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
source := filepath.Join(dir, "source")
|
|
|
|
|
destination := filepath.Join(dir, "destination")
|
|
|
|
|
seedUpgradeSkill(t, source, "payload", false)
|
|
|
|
|
forceChildMove(t)
|
|
|
|
|
original := skillPathLstat
|
|
|
|
|
calls := 0
|
|
|
|
|
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
|
|
|
|
|
if path == source {
|
|
|
|
|
calls++
|
|
|
|
|
if calls > 1 {
|
|
|
|
|
return nil, os.ErrPermission
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return original(path)
|
|
|
|
|
})
|
|
|
|
|
if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrPermission) {
|
|
|
|
|
t.Fatalf("directory stat failure = %v", err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("claim read failure surfaces and reports the cleanup check", func(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
source := filepath.Join(dir, "source")
|
|
|
|
|
destination := filepath.Join(dir, "destination")
|
|
|
|
|
seedUpgradeSkill(t, source, "payload", false)
|
|
|
|
|
forceChildMove(t)
|
|
|
|
|
testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
|
|
|
|
|
if path == destination {
|
|
|
|
|
return nil, os.ErrPermission
|
|
|
|
|
}
|
|
|
|
|
return os.ReadDir(path)
|
|
|
|
|
})
|
|
|
|
|
_, err := renameSkillPathNoReplace(source, destination)
|
|
|
|
|
if !strings.Contains(err.Error(), "降级发布 Skill 目录失败") || !strings.Contains(err.Error(), "检查降级发布目标") {
|
|
|
|
|
t.Fatalf("claim read failure = %v", err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("claim vanished during cleanup is tolerated", func(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
source := filepath.Join(dir, "source")
|
|
|
|
|
destination := filepath.Join(dir, "destination")
|
|
|
|
|
seedUpgradeSkill(t, source, "payload", false)
|
|
|
|
|
forceChildMove(t)
|
|
|
|
|
destinationReads := 0
|
|
|
|
|
original := skillPathReadDir
|
|
|
|
|
testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
|
|
|
|
|
if path == destination {
|
|
|
|
|
destinationReads++
|
|
|
|
|
if destinationReads == 1 {
|
|
|
|
|
return nil, os.ErrPermission
|
|
|
|
|
}
|
|
|
|
|
return nil, os.ErrNotExist
|
|
|
|
|
}
|
|
|
|
|
return original(path)
|
|
|
|
|
})
|
|
|
|
|
_, err := renameSkillPathNoReplace(source, destination)
|
|
|
|
|
if !strings.Contains(err.Error(), "降级发布 Skill 目录失败") || strings.Contains(err.Error(), "检查降级发布目标") {
|
|
|
|
|
t.Fatalf("vanished claim cleanup = %v", err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("file child link failure surfaces and the empty claim cleanup reports", func(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
source := filepath.Join(dir, "source")
|
|
|
|
|
destination := filepath.Join(dir, "destination")
|
|
|
|
|
seedUpgradeSkill(t, source, "payload", false)
|
|
|
|
|
forceChildMove(t)
|
|
|
|
|
testseam.Swap(t, &skillPathLink, func(string, string) error { return os.ErrPermission })
|
|
|
|
|
testseam.Swap(t, &skillPathRemove, func(path string) error {
|
|
|
|
|
if path == destination {
|
|
|
|
|
return os.ErrPermission
|
|
|
|
|
}
|
|
|
|
|
return os.Remove(path)
|
|
|
|
|
})
|
|
|
|
|
_, err := renameSkillPathNoReplace(source, destination)
|
|
|
|
|
if !strings.Contains(err.Error(), "发布 Skill 文件失败") || !strings.Contains(err.Error(), "清理降级发布目标") {
|
|
|
|
|
t.Fatalf("link failure = %v", err)
|
|
|
|
|
}
|
|
|
|
|
assertUpgradeSkillContent(t, source, "payload")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("file child collision reports ErrExist", func(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
source := filepath.Join(dir, "source")
|
|
|
|
|
destination := filepath.Join(dir, "destination")
|
|
|
|
|
seedUpgradeSkill(t, source, "payload", false)
|
|
|
|
|
forceChildMove(t)
|
|
|
|
|
testseam.Swap(t, &skillPathLink, func(string, string) error { return os.ErrExist })
|
|
|
|
|
if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrExist) {
|
|
|
|
|
t.Fatalf("file child collision = %v, want ErrExist", err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("later child failure rolls earlier children back", func(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
source := filepath.Join(dir, "source")
|
|
|
|
|
destination := filepath.Join(dir, "destination")
|
|
|
|
|
seedUpgradeSkill(t, source, "payload", false)
|
|
|
|
|
if err := os.WriteFile(filepath.Join(source, "second"), []byte("second"), 0o644); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
forceChildMove(t)
|
|
|
|
|
// The second child's link fails; the forced rename failure then also
|
|
|
|
|
// breaks the rollback rename of the already relocated first child.
|
|
|
|
|
testseam.Swap(t, &skillPathLink, func(source, _ string) error {
|
|
|
|
|
if filepath.Base(source) == "second" {
|
|
|
|
|
return os.ErrPermission
|
|
|
|
|
}
|
|
|
|
|
return os.Link(source, filepath.Join(destination, filepath.Base(source)))
|
|
|
|
|
})
|
|
|
|
|
_, err := renameSkillPathNoReplace(source, destination)
|
|
|
|
|
if !strings.Contains(err.Error(), "迁移 Skill 目录项失败") {
|
|
|
|
|
t.Fatalf("child failure rollback = %v", err)
|
|
|
|
|
}
|
|
|
|
|
// The forced rename failure also breaks the rollback restore, so the
|
|
|
|
|
// relocated child stays at the destination — retained, never deleted.
|
|
|
|
|
assertUpgradeSkillContent(t, destination, "payload")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("live claim read failure aborts with rollback", func(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
source := filepath.Join(dir, "source")
|
|
|
|
|
destination := filepath.Join(dir, "destination")
|
|
|
|
|
seedUpgradeSkill(t, source, "payload", false)
|
|
|
|
|
forceChildMove(t)
|
|
|
|
|
destinationReads := 0
|
|
|
|
|
original := skillPathReadDir
|
|
|
|
|
testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
|
|
|
|
|
if path == destination {
|
|
|
|
|
destinationReads++
|
|
|
|
|
if destinationReads == 2 {
|
|
|
|
|
return nil, os.ErrPermission
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return original(path)
|
|
|
|
|
})
|
|
|
|
|
_, err := renameSkillPathNoReplace(source, destination)
|
|
|
|
|
if !strings.Contains(err.Error(), "确认降级发布目标") {
|
|
|
|
|
t.Fatalf("live claim read failure = %v", err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("foreign claim entry aborts the move", func(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
source := filepath.Join(dir, "source")
|
|
|
|
|
destination := filepath.Join(dir, "destination")
|
|
|
|
|
seedUpgradeSkill(t, source, "payload", false)
|
|
|
|
|
forceChildMove(t)
|
|
|
|
|
destinationReads := 0
|
|
|
|
|
original := skillPathReadDir
|
|
|
|
|
testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
|
|
|
|
|
if path == destination {
|
|
|
|
|
destinationReads++
|
|
|
|
|
if destinationReads == 2 {
|
|
|
|
|
return []os.DirEntry{
|
|
|
|
|
childMoveDirEntry{name: "SKILL.md"},
|
|
|
|
|
childMoveDirEntry{name: "foreign"},
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return original(path)
|
|
|
|
|
})
|
|
|
|
|
_, err := renameSkillPathNoReplace(source, destination)
|
|
|
|
|
if !errors.Is(err, os.ErrExist) || !strings.Contains(err.Error(), "并发目录项") {
|
|
|
|
|
t.Fatalf("foreign claim entry = %v", err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("claim mode restore failure aborts with rollback", func(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
source := filepath.Join(dir, "source")
|
|
|
|
|
destination := filepath.Join(dir, "destination")
|
|
|
|
|
seedUpgradeSkill(t, source, "payload", false)
|
|
|
|
|
forceChildMove(t)
|
|
|
|
|
testseam.Swap(t, &skillPathChmod, func(string, os.FileMode) error { return os.ErrPermission })
|
|
|
|
|
_, err := renameSkillPathNoReplace(source, destination)
|
|
|
|
|
if !strings.Contains(err.Error(), "恢复已发布 Skill 目录权限") {
|
|
|
|
|
t.Fatalf("mode restore failure = %v", err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("source shell removal failure surfaces with the destination retained", func(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
source := filepath.Join(dir, "source")
|
|
|
|
|
destination := filepath.Join(dir, "destination")
|
|
|
|
|
seedUpgradeSkill(t, source, "payload", false)
|
|
|
|
|
forceChildMove(t)
|
|
|
|
|
testseam.Swap(t, &skillPathRemove, func(path string) error {
|
|
|
|
|
if path == source {
|
|
|
|
|
return os.ErrPermission
|
|
|
|
|
}
|
|
|
|
|
return os.Remove(path)
|
|
|
|
|
})
|
|
|
|
|
_, err := renameSkillPathNoReplace(source, destination)
|
|
|
|
|
if !strings.Contains(err.Error(), "清理已迁移 Skill 源目录") {
|
|
|
|
|
t.Fatalf("shell removal failure = %v", err)
|
|
|
|
|
}
|
|
|
|
|
assertUpgradeSkillContent(t, destination, "payload")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("nested directories and mixed children publish", func(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
source := filepath.Join(dir, "source")
|
|
|
|
|
destination := filepath.Join(dir, "destination")
|
|
|
|
|
seedUpgradeSkill(t, source, "payload", false)
|
|
|
|
|
if err := os.MkdirAll(filepath.Join(source, "nested"), 0o755); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if err := os.WriteFile(filepath.Join(source, "nested", "inner.txt"), []byte("inner"), 0o644); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if err := os.WriteFile(filepath.Join(source, "link"), []byte("stub"), 0o644); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
forceChildMove(t)
|
|
|
|
|
// The "link" child is presented to the dispatcher as a symlink; its
|
|
|
|
|
// recreation is simulated through seams so the test needs no
|
|
|
|
|
// symlink privileges (Windows runners refuse unprivileged symlinks).
|
|
|
|
|
originalLstat := skillPathLstat
|
|
|
|
|
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
|
|
|
|
|
if path == filepath.Join(source, "link") {
|
|
|
|
|
return skillPathFakeInfo{mode: os.ModeSymlink}, nil
|
|
|
|
|
}
|
|
|
|
|
return originalLstat(path)
|
|
|
|
|
})
|
|
|
|
|
testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "target", nil })
|
|
|
|
|
testseam.Swap(t, &skillPathSymlink, func(string, string) error { return nil })
|
|
|
|
|
claim, err := renameSkillPathNoReplace(source, destination)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if claim == "" {
|
|
|
|
|
t.Fatal("child-move must return the claim identity")
|
|
|
|
|
}
|
|
|
|
|
assertUpgradeSkillContent(t, destination, "payload")
|
|
|
|
|
if data, readErr := os.ReadFile(filepath.Join(destination, "nested", "inner.txt")); readErr != nil || string(data) != "inner" {
|
|
|
|
|
t.Fatalf("nested child = %q, %v", data, readErr)
|
|
|
|
|
}
|
|
|
|
|
if _, statErr := os.Lstat(filepath.Join(source, "link")); !os.IsNotExist(statErr) {
|
|
|
|
|
t.Fatalf("simulated symlink child must have its source consumed, stat err=%v", statErr)
|
|
|
|
|
}
|
|
|
|
|
if _, statErr := os.Lstat(source); !os.IsNotExist(statErr) {
|
|
|
|
|
t.Fatalf("source must be gone, stat err=%v", statErr)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("special-type child is refused", func(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
source := filepath.Join(dir, "source")
|
|
|
|
|
destination := filepath.Join(dir, "destination")
|
|
|
|
|
seedUpgradeSkill(t, source, "payload", false)
|
|
|
|
|
forceChildMove(t)
|
|
|
|
|
originalLstat := skillPathLstat
|
|
|
|
|
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
|
|
|
|
|
if path == filepath.Join(source, "SKILL.md") {
|
|
|
|
|
return mockSpecialInfo{}, nil
|
|
|
|
|
}
|
|
|
|
|
return originalLstat(path)
|
|
|
|
|
})
|
|
|
|
|
if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, errNoReplaceRenameUnsupported) {
|
|
|
|
|
t.Fatalf("special child = %v, want unsupported", err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("symlink child readlink failure surfaces", func(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
source := filepath.Join(dir, "source")
|
|
|
|
|
destination := filepath.Join(dir, "destination")
|
|
|
|
|
seedUpgradeSkill(t, source, "payload", false)
|
|
|
|
|
forceChildMove(t)
|
|
|
|
|
originalLstat := skillPathLstat
|
|
|
|
|
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
|
|
|
|
|
if path == filepath.Join(source, "SKILL.md") {
|
|
|
|
|
return skillPathFakeInfo{mode: os.ModeSymlink}, nil
|
|
|
|
|
}
|
|
|
|
|
return originalLstat(path)
|
|
|
|
|
})
|
|
|
|
|
testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "", os.ErrPermission })
|
|
|
|
|
if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrPermission) {
|
|
|
|
|
t.Fatalf("symlink readlink failure = %v", err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("symlink child collision reports ErrExist", func(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
source := filepath.Join(dir, "source")
|
|
|
|
|
destination := filepath.Join(dir, "destination")
|
|
|
|
|
seedUpgradeSkill(t, source, "payload", false)
|
|
|
|
|
forceChildMove(t)
|
|
|
|
|
originalLstat := skillPathLstat
|
|
|
|
|
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
|
|
|
|
|
if path == filepath.Join(source, "SKILL.md") {
|
|
|
|
|
return skillPathFakeInfo{mode: os.ModeSymlink}, nil
|
|
|
|
|
}
|
|
|
|
|
return originalLstat(path)
|
|
|
|
|
})
|
|
|
|
|
testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "target", nil })
|
|
|
|
|
testseam.Swap(t, &skillPathSymlink, func(string, string) error { return os.ErrExist })
|
|
|
|
|
if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrExist) {
|
|
|
|
|
t.Fatalf("symlink collision = %v, want ErrExist", err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("symlink child publish failure surfaces", func(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
source := filepath.Join(dir, "source")
|
|
|
|
|
destination := filepath.Join(dir, "destination")
|
|
|
|
|
seedUpgradeSkill(t, source, "payload", false)
|
|
|
|
|
forceChildMove(t)
|
|
|
|
|
originalLstat := skillPathLstat
|
|
|
|
|
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
|
|
|
|
|
if path == filepath.Join(source, "SKILL.md") {
|
|
|
|
|
return skillPathFakeInfo{mode: os.ModeSymlink}, nil
|
|
|
|
|
}
|
|
|
|
|
return originalLstat(path)
|
|
|
|
|
})
|
|
|
|
|
testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "target", nil })
|
|
|
|
|
testseam.Swap(t, &skillPathSymlink, func(string, string) error { return os.ErrPermission })
|
|
|
|
|
if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrPermission) {
|
|
|
|
|
t.Fatalf("symlink publish failure = %v", err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("child stat failure surfaces", func(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
source := filepath.Join(dir, "source")
|
|
|
|
|
destination := filepath.Join(dir, "destination")
|
|
|
|
|
seedUpgradeSkill(t, source, "payload", false)
|
|
|
|
|
forceChildMove(t)
|
|
|
|
|
originalLstat := skillPathLstat
|
|
|
|
|
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
|
|
|
|
|
if path == filepath.Join(source, "SKILL.md") {
|
|
|
|
|
return nil, os.ErrPermission
|
|
|
|
|
}
|
|
|
|
|
return originalLstat(path)
|
|
|
|
|
})
|
|
|
|
|
if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrPermission) {
|
|
|
|
|
t.Fatalf("child stat failure = %v", err)
|
|
|
|
|
}
|
|
|
|
|
assertUpgradeSkillContent(t, source, "payload")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("source read failure aborts with the empty claim cleaned", func(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
source := filepath.Join(dir, "source")
|
|
|
|
|
destination := filepath.Join(dir, "destination")
|
|
|
|
|
seedUpgradeSkill(t, source, "payload", false)
|
|
|
|
|
forceChildMove(t)
|
|
|
|
|
original := skillPathReadDir
|
|
|
|
|
testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
|
|
|
|
|
if path == source {
|
|
|
|
|
return nil, os.ErrPermission
|
|
|
|
|
}
|
|
|
|
|
return original(path)
|
|
|
|
|
})
|
|
|
|
|
_, err := renameSkillPathNoReplace(source, destination)
|
|
|
|
|
if !strings.Contains(err.Error(), "读取源 Skill 目录失败") {
|
|
|
|
|
t.Fatalf("source read failure = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if _, statErr := os.Lstat(destination); !os.IsNotExist(statErr) {
|
|
|
|
|
t.Fatalf("empty claim must be cleaned, stat err=%v", statErr)
|
|
|
|
|
}
|
|
|
|
|
assertUpgradeSkillContent(t, source, "payload")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("file identity degrades on unreadable and unrecognizable objects", func(t *testing.T) {
|
|
|
|
|
testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return nil, os.ErrPermission })
|
|
|
|
|
if id := skillPathFileIdentityImpl("unreadable"); id != "" {
|
|
|
|
|
t.Fatalf("unreadable identity = %q, want empty", id)
|
|
|
|
|
}
|
|
|
|
|
testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return skillPathFakeInfo{mode: 0o644}, nil })
|
|
|
|
|
if id := skillPathFileIdentityImpl("synthetic"); id != "" {
|
|
|
|
|
t.Fatalf("unrecognizable identity = %q, want empty", id)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("published content drift after the fast-path rename surfaces", func(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
staged := filepath.Join(dir, "staged")
|
|
|
|
|
destination := filepath.Join(dir, "destination")
|
|
|
|
|
seedUpgradeSkill(t, staged, "value", false)
|
|
|
|
|
originalRename := skillPathRenameNoReplace
|
|
|
|
|
testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) (string, error) {
|
|
|
|
|
if _, err := originalRename(source, target); err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
// The published bytes drift after the rename while the object
|
|
|
|
|
// keeps its identity, so only the fingerprint check can refuse.
|
|
|
|
|
if err := os.WriteFile(filepath.Join(target, "SKILL.md"), []byte("drifted"), 0o644); err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
return "", nil
|
|
|
|
|
})
|
|
|
|
|
if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "staging 内容已变化") {
|
|
|
|
|
t.Fatalf("content drift = %v", err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|