Files
dingtalk-workspace-cli/internal/upgrade/skill_publication_test.go
T
玉澜 27aca3ccc3 fix(skills): close no-replace fallback TOCTOU and surface uncertain publications
The mkdir->rename->remove->rename directory fallback had a TOCTOU window
between the second remove and the second rename: a concurrent writer
creating an entry at the destination was silently clobbered. The fallback
now claims the destination once with mkdir and never unlinks it: the
fast-path rename publishes over the claim (Linux), and platforms that
refuse directory renames (macOS, Windows) move the staged children into
the claim through atomic no-clobber primitives (mkdir/os.Link/os.Symlink),
consuming the emptied source shell on success.

renameSkillPathNoReplace now returns the mkdir-claim identity captured by
the child-move path. PublishSkillPathNoReplace uses it as a three-state
ownership witness: the atomic/fast paths keep the staged-inode proof, the
child-move path proves dest is still the mkdir claim, and a mismatch
reports the new ErrSkillPathPublicationUncertain sentinel with the
destination retained. The witness is real on POSIX now: darwin and linux
report the dev:ino file identity instead of the empty no-op.

Upstream consumers honor the sentinel: the mono/multi upgrade copy
fallbacks no longer retry over an uncertain destination (the retry would
displace the concurrent writer's object), and skill setup reports the
retained destination instead of claiming a rollback.

Rewrites the fallback tests that pinned the removed remove-and-retry flow
and adds regression coverage: concurrent claim entries abort with the
destination retained, wholesale replacement after child-move reports the
uncertain sentinel, staged-set transactions pass the sentinel through,
and both copy fallbacks short-circuit (ablation-verified).
2026-08-19 17:42:20 +08:00

514 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) (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) (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) (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) (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")
}
})
t.Run("synthetic info does not match same-file identity", func(t *testing.T) {
fake := skillPathFakeInfo{}
if skillPathSameFileIdentity(fake, fake) {
t.Fatal("synthetic info should not match")
}
})
})
}
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)
}
}