Files
dingtalk-workspace-cli/internal/upgrade/skill_publication_test.go
T
玉澜 344104268a fix(skills): open reparse points in Windows file ID query and stabilize tunneled test
Add FILE_FLAG_OPEN_REPARSE_POINT to the Windows CreateFile call in
skillPathFileIdentityImpl so symlinks are opened as reparse points
rather than followed to their target. Staged symlinks carry relative
targets computed for the final destination, which may not resolve from
the staging directory; following them caused CreateFile to fail,
yielding an empty file ID that rejected publication and broke canonical
skill layout migration on Windows.

Make skillPathSameFileIdentity a seam variable so the tunneled
replacement test can deterministically simulate the identity change on
Unix. On tmpfs (used by Linux CI runners), os.SameFile can return true
for a recreated file due to inode reuse, making the test flaky. On
Windows the swap is a no-op because skillPathIdentityProven compares
file IDs from GetFileInformationByHandle and ignores
skillPathSameFileIdentity.
2026-08-17 14:39:59 +08:00

508 lines
18 KiB
Go

package upgrade
import (
"crypto/sha256"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageSkillPublicationNoClobberAndOwnedRollback(t *testing.T) {
t.Run("existing destination is never replaced", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "new", false)
seedUpgradeSkill(t, destination, "concurrent", false)
if _, err := PublishSkillPathNoReplace(staged, destination); err == nil {
t.Fatal("expected no-replace publication to fail")
}
assertUpgradeSkillContent(t, destination, "concurrent")
assertUpgradeSkillContent(t, staged, "new")
})
t.Run("owned publication is removed", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "new", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); err != nil {
t.Fatal(err)
}
if _, err := os.Lstat(destination); !os.IsNotExist(err) {
t.Fatalf("owned publication remains: %v", err)
}
})
t.Run("concurrent replacement is quarantined instead of deleted", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "new", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
if err := os.RemoveAll(destination); err != nil {
t.Fatal(err)
}
// Even a byte-for-byte identical replacement is not owned by this
// transaction: inode identity, not content alone, is authoritative.
seedUpgradeSkill(t, destination, "new", false)
err = RollbackSkillPathPublications([]SkillPathPublication{publication})
if err == nil || !strings.Contains(err.Error(), "拒绝删除非本事务") {
t.Fatalf("replacement rollback error = %v", err)
}
assertUpgradeSkillContent(t, destination, "new")
})
t.Run("tunneled replacement with same creation time is still refused", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "new", false)
// NTFS file tunneling can restore the original creation time for a
// same-named recreation, defeating the incarnation check. The file ID
// (or inode on Unix) must still differ and block the rollback.
testseam.Swap(t, &skillPathFileIncarnation, func(os.FileInfo) string { return "tunneled" })
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
// Simulate the identity change that a concurrent replacement causes.
// On Windows, skillPathIdentityProven compares file IDs from
// GetFileInformationByHandle and ignores this seam. On Unix, where the
// file ID is unavailable, this swap replaces the os.SameFile check
// (which can return true for a recreated file on tmpfs due to inode
// reuse) so the test is deterministic across filesystems.
testseam.Swap(t, &skillPathSameFileIdentity, func(_, _ os.FileInfo) bool { return false })
if err := os.RemoveAll(destination); err != nil {
t.Fatal(err)
}
seedUpgradeSkill(t, destination, "new", false)
err = RollbackSkillPathPublications([]SkillPathPublication{publication})
if err == nil || !strings.Contains(err.Error(), "拒绝删除非本事务") {
t.Fatalf("tunneled replacement rollback error = %v", err)
}
assertUpgradeSkillContent(t, destination, "new")
})
}
func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) {
failure := errors.New("injected publication failure")
t.Run("platform no-replace path encoding", func(t *testing.T) {
if err := renameSkillPathNoReplace("invalid\x00source", filepath.Join(t.TempDir(), "dest")); err == nil {
t.Fatal("expected invalid source path failure")
}
source := filepath.Join(t.TempDir(), "source")
if err := os.WriteFile(source, []byte("value"), 0o600); err != nil {
t.Fatal(err)
}
if err := renameSkillPathNoReplace(source, "invalid\x00destination"); err == nil {
t.Fatal("expected invalid destination path failure")
}
})
t.Run("publish identity", func(t *testing.T) {
if _, err := PublishSkillPathNoReplace(filepath.Join(t.TempDir(), "missing"), filepath.Join(t.TempDir(), "dest")); err == nil {
t.Fatal("expected identity failure")
}
})
t.Run("publish fingerprint", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
seedUpgradeSkill(t, staged, "value", false)
originalLstat := skillPathLstat
calls := 0
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
calls++
if calls == 2 {
return nil, failure
}
return originalLstat(path)
})
if _, err := PublishSkillPathNoReplace(staged, filepath.Join(root, "dest")); !errors.Is(err, failure) {
t.Fatal("expected fingerprint failure")
}
})
t.Run("publish confirmation missing", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
originalRename := skillPathRenameNoReplace
testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) error {
if err := originalRename(source, target); err != nil {
return err
}
return os.RemoveAll(target)
})
if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "对象保留") {
t.Fatalf("publish confirmation error = %v", err)
}
})
t.Run("publish confirmation identity changed", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
originalRename := skillPathRenameNoReplace
testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) error {
if err := originalRename(source, target); err != nil {
return err
}
if err := os.RemoveAll(target); err != nil {
return err
}
seedUpgradeSkill(t, target, "replacement", false)
return nil
})
if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "staging 身份已变化") {
t.Fatalf("publish identity error = %v", err)
}
})
t.Run("publish confirmation fingerprint", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
originalRename := skillPathRenameNoReplace
published := false
testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) error {
if err := originalRename(source, target); err != nil {
return err
}
published = true
return nil
})
originalReadDir := skillPathReadDir
testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
if published && path == destination {
return nil, failure
}
return originalReadDir(path)
})
if _, err := PublishSkillPathNoReplace(staged, destination); !errors.Is(err, failure) || !strings.Contains(err.Error(), "内容失败") {
t.Fatalf("publish fingerprint confirmation error = %v", err)
}
})
t.Run("rollback temp", func(t *testing.T) {
testseam.Swap(t, &skillPathMkdirTemp, func(string, string) (string, error) { return "", failure })
if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: "dest"}}); !errors.Is(err, failure) {
t.Fatalf("temp error = %v", err)
}
})
t.Run("missing destination cleanup", func(t *testing.T) {
if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: filepath.Join(t.TempDir(), "missing")}}); err != nil {
t.Fatal(err)
}
})
t.Run("missing destination cleanup failure", func(t *testing.T) {
testseam.Swap(t, &skillPathRemoveAll, func(string) error { return failure })
if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: filepath.Join(t.TempDir(), "missing")}}); !errors.Is(err, failure) {
t.Fatalf("cleanup error = %v", err)
}
})
t.Run("quarantine rename", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillPathRename, func(string, string) error { return failure })
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
t.Fatalf("quarantine error = %v", err)
}
})
t.Run("live verification", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
originalReadDir := skillPathReadDir
testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
if path == destination {
return nil, failure
}
return originalReadDir(path)
})
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
t.Fatalf("live verification error = %v", err)
}
})
t.Run("destination disappears before quarantine", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillPathRename, func(source, target string) error {
if err := os.RemoveAll(source); err != nil {
return err
}
return &os.PathError{Op: "rename", Path: source, Err: os.ErrNotExist}
})
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); err != nil {
t.Fatal(err)
}
})
t.Run("owned remove", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
originalRemove := skillPathRemoveAll
testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
if filepath.Base(path) == "payload" {
return failure
}
return originalRemove(path)
})
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
t.Fatalf("remove error = %v", err)
}
})
t.Run("quarantined fingerprint", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
originalLstat := skillPathLstat
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if filepath.Base(path) == "payload" {
return nil, failure
}
return originalLstat(path)
})
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
t.Fatalf("fingerprint error = %v", err)
}
})
t.Run("mismatch restore and cleanup", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "published", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
originalRename := skillPathRename
testseam.Swap(t, &skillPathRename, func(source, target string) error {
if source == destination {
if err := os.RemoveAll(source); err != nil {
return err
}
seedUpgradeSkill(t, source, "replacement", false)
}
return originalRename(source, target)
})
originalRemove := skillPathRemoveAll
testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
if strings.Contains(filepath.Base(path), ".destination.rollback-") {
return failure
}
return originalRemove(path)
})
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
t.Fatalf("mismatch cleanup error = %v", err)
}
assertUpgradeSkillContent(t, destination, "replacement")
})
t.Run("mismatch restore failure", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "published", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
originalRename := skillPathRename
testseam.Swap(t, &skillPathRename, func(source, target string) error {
if source == destination {
if err := os.RemoveAll(source); err != nil {
return err
}
seedUpgradeSkill(t, source, "replacement", false)
}
return originalRename(source, target)
})
testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) error { return failure })
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) || !strings.Contains(err.Error(), "并发对象保留") {
t.Fatalf("mismatch restore error = %v", err)
}
})
t.Run("fingerprint read failures and lexical types", func(t *testing.T) {
t.Run("directory ordering", func(t *testing.T) {
dir := t.TempDir()
for _, name := range []string{"b", "a"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil {
t.Fatal(err)
}
}
if _, err := fingerprintSkillPath(dir); err != nil {
t.Fatal(err)
}
})
t.Run("directory read", func(t *testing.T) {
dir := t.TempDir()
testseam.Swap(t, &skillPathReadDir, func(string) ([]os.DirEntry, error) { return nil, failure })
if _, err := fingerprintSkillPath(dir); !errors.Is(err, failure) {
t.Fatalf("read dir error = %v", err)
}
})
t.Run("recursive child", func(t *testing.T) {
dir := t.TempDir()
child := filepath.Join(dir, "child")
if err := os.WriteFile(child, []byte("value"), 0o644); err != nil {
t.Fatal(err)
}
originalLstat := skillPathLstat
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if path == child {
return nil, failure
}
return originalLstat(path)
})
if _, err := fingerprintSkillPath(dir); !errors.Is(err, failure) {
t.Fatalf("child error = %v", err)
}
})
t.Run("symlink read", func(t *testing.T) {
link := filepath.Join(t.TempDir(), "link")
if err := os.Symlink("missing", link); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "", failure })
if _, err := fingerprintSkillPath(link); !errors.Is(err, failure) {
t.Fatalf("readlink error = %v", err)
}
})
t.Run("symlink success", func(t *testing.T) {
link := filepath.Join(t.TempDir(), "link")
if err := os.Symlink("missing", link); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
if _, err := fingerprintSkillPath(link); err != nil {
t.Fatal(err)
}
})
t.Run("file digest", func(t *testing.T) {
file := filepath.Join(t.TempDir(), "file")
if err := os.WriteFile(file, []byte("value"), 0o644); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillPathOpen, func(string) (*os.File, error) { return nil, failure })
if _, err := fingerprintSkillPath(file); !errors.Is(err, failure) {
t.Fatalf("digest error = %v", err)
}
})
t.Run("special", func(t *testing.T) {
testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) {
return skillPathFakeInfo{mode: os.ModeNamedPipe}, nil
})
if err := fingerprintSkillPathInto(sha256.New(), "special"); err == nil {
t.Fatal("expected special path error")
}
})
t.Run("unknown platform identity shape", func(t *testing.T) {
if got := skillPathFileIncarnation(skillPathFakeInfo{}); got == "" {
t.Fatal("empty incarnation")
}
})
})
}
func TestCrossPlatformCoverageUpgradeRollbackRetainsConcurrentReplacement(t *testing.T) {
home := t.TempDir()
base := filepath.Join(home, ".agents", "skills")
first := filepath.Join(base, "dingtalk-first")
second := filepath.Join(base, "dingtalk-second")
seedUpgradeSkill(t, first, "old first", false)
seedUpgradeSkill(t, second, "old second", false)
stageRoot := filepath.Join(base, ".stage")
stagedFirst := filepath.Join(stageRoot, "dingtalk-first")
stagedSecond := filepath.Join(stageRoot, "dingtalk-second")
seedUpgradeSkill(t, stagedFirst, "new first", false)
seedUpgradeSkill(t, stagedSecond, "new second", false)
failure := errors.New("injected second publication failure")
originalPublish := upgradePublishSkillPath
calls := 0
testseam.Swap(t, &upgradePublishSkillPath, func(staged, destination string) (SkillPathPublication, error) {
calls++
if calls == 2 {
if err := os.RemoveAll(first); err != nil {
t.Fatal(err)
}
seedUpgradeSkill(t, first, "concurrent", false)
return SkillPathPublication{}, failure
}
return originalPublish(staged, destination)
})
err := publishStagedSkillSet(home, []stagedSkillDir{
{staged: stagedFirst, dest: first},
{staged: stagedSecond, dest: second},
}, []string{first, second})
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "拒绝删除非本事务") {
t.Fatalf("transaction error = %v", err)
}
assertUpgradeSkillContent(t, first, "concurrent")
assertUpgradeSkillContent(t, second, "old second")
matches, globErr := filepath.Glob(filepath.Join(home, ".dws", "skill-backups", "*", ".agents-skills-dingtalk-first", "SKILL.md"))
if globErr != nil || len(matches) != 1 {
t.Fatalf("retained old backup = %v, %v", matches, globErr)
}
content, readErr := os.ReadFile(matches[0])
if readErr != nil || string(content) != "old first" {
t.Fatalf("retained backup content = %q, %v", content, readErr)
}
}