From 1bae8723419cf4410da934dddbc5800234e2e143 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 19:47:04 +0800 Subject: [PATCH] fix(skills): distinguish reused publication inodes --- internal/upgrade/skill_publication.go | 44 +++++- .../skill_publication_identity_darwin.go | 18 +++ .../skill_publication_identity_linux.go | 17 +++ .../skill_publication_identity_other.go | 12 ++ .../skill_publication_identity_windows.go | 17 +++ internal/upgrade/skill_publication_test.go | 144 ++++++++++++++++-- 6 files changed, 238 insertions(+), 14 deletions(-) create mode 100644 internal/upgrade/skill_publication_identity_darwin.go create mode 100644 internal/upgrade/skill_publication_identity_linux.go create mode 100644 internal/upgrade/skill_publication_identity_other.go create mode 100644 internal/upgrade/skill_publication_identity_windows.go diff --git a/internal/upgrade/skill_publication.go b/internal/upgrade/skill_publication.go index f4315435..7c678af0 100644 --- a/internal/upgrade/skill_publication.go +++ b/internal/upgrade/skill_publication.go @@ -17,6 +17,7 @@ type SkillPathPublication struct { Destination string fingerprint [sha256.Size]byte identity os.FileInfo + incarnation string } // PublishSkillPathNoReplace atomically publishes a staged path without ever @@ -33,7 +34,22 @@ func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication if err := skillPathRenameNoReplace(staged, destination); err != nil { return SkillPathPublication{}, fmt.Errorf("目标必须不存在的 Skill 发布失败 %s: %w", destination, err) } - return SkillPathPublication{Destination: destination, fingerprint: fingerprint, identity: identity}, nil + publishedIdentity, err := skillPathLstat(destination) + if err != nil { + return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): %w", destination, err) + } + // Force os.SameFile to resolve and cache any path-backed identity (notably + // on Windows) while the just-published object is known to occupy the path. + if !os.SameFile(identity, publishedIdentity) { + return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): staging 身份已变化", destination) + } + _ = os.SameFile(publishedIdentity, publishedIdentity) + return SkillPathPublication{ + Destination: destination, + fingerprint: fingerprint, + identity: publishedIdentity, + incarnation: skillPathFileIncarnation(publishedIdentity), + }, nil } // RollbackSkillPathPublications removes only objects that can still be proven @@ -64,6 +80,27 @@ func rollbackSkillPathPublication(publication SkillPathPublication) (err error) return nil } + liveIdentity, liveIdentityErr := skillPathLstat(destination) + if os.IsNotExist(liveIdentityErr) { + return cleanupRoot() + } + liveFingerprint, liveFingerprintErr := fingerprintSkillPath(destination) + liveVerificationErr := errors.Join(liveIdentityErr, liveFingerprintErr) + if liveVerificationErr != nil { + return errors.Join( + fmt.Errorf("拒绝删除无法验证的 Skill %s: %w", destination, liveVerificationErr), + cleanupRoot(), + ) + } + if publication.identity == nil || !os.SameFile(publication.identity, liveIdentity) || + publication.incarnation != skillPathFileIncarnation(liveIdentity) || + liveFingerprint != publication.fingerprint { + return errors.Join( + fmt.Errorf("拒绝删除非本事务 Skill %s: 发布对象身份已变化", destination), + cleanupRoot(), + ) + } + if err := skillPathRename(destination, quarantine); err != nil { cleanupErr := cleanupRoot() if os.IsNotExist(err) { @@ -74,8 +111,9 @@ func rollbackSkillPathPublication(publication SkillPathPublication) (err error) actualIdentity, identityErr := skillPathLstat(quarantine) actual, fingerprintErr := fingerprintSkillPath(quarantine) - if identityErr == nil && fingerprintErr == nil && publication.identity != nil && - os.SameFile(publication.identity, actualIdentity) && actual == publication.fingerprint { + if identityErr == nil && fingerprintErr == nil && + os.SameFile(publication.identity, actualIdentity) && + actual == publication.fingerprint { if removeErr := removePublishedSkillSource(quarantine); removeErr != nil { return fmt.Errorf("移除事务发布的 Skill 失败 %s(隔离于 %s): %w", destination, quarantine, removeErr) } diff --git a/internal/upgrade/skill_publication_identity_darwin.go b/internal/upgrade/skill_publication_identity_darwin.go new file mode 100644 index 00000000..b436ac6e --- /dev/null +++ b/internal/upgrade/skill_publication_identity_darwin.go @@ -0,0 +1,18 @@ +//go:build darwin + +package upgrade + +import ( + "fmt" + "os" + "syscall" +) + +func skillPathFileIncarnation(info os.FileInfo) string { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Sprintf("unknown:%T", info.Sys()) + } + return fmt.Sprintf("%d:%d:%d:%d:%d:%d:%d", stat.Dev, stat.Ino, stat.Gen, + stat.Ctimespec.Sec, stat.Ctimespec.Nsec, stat.Birthtimespec.Sec, stat.Birthtimespec.Nsec) +} diff --git a/internal/upgrade/skill_publication_identity_linux.go b/internal/upgrade/skill_publication_identity_linux.go new file mode 100644 index 00000000..0f8a52fb --- /dev/null +++ b/internal/upgrade/skill_publication_identity_linux.go @@ -0,0 +1,17 @@ +//go:build linux + +package upgrade + +import ( + "fmt" + "os" + "syscall" +) + +func skillPathFileIncarnation(info os.FileInfo) string { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Sprintf("unknown:%T", info.Sys()) + } + return fmt.Sprintf("%d:%d:%d:%d", stat.Dev, stat.Ino, stat.Ctim.Sec, stat.Ctim.Nsec) +} diff --git a/internal/upgrade/skill_publication_identity_other.go b/internal/upgrade/skill_publication_identity_other.go new file mode 100644 index 00000000..fcdee1a2 --- /dev/null +++ b/internal/upgrade/skill_publication_identity_other.go @@ -0,0 +1,12 @@ +//go:build !linux && !darwin && !windows + +package upgrade + +import ( + "fmt" + "os" +) + +func skillPathFileIncarnation(info os.FileInfo) string { + return fmt.Sprintf("%d:%d:%s", info.ModTime().UnixNano(), info.Size(), info.Mode()) +} diff --git a/internal/upgrade/skill_publication_identity_windows.go b/internal/upgrade/skill_publication_identity_windows.go new file mode 100644 index 00000000..046327ef --- /dev/null +++ b/internal/upgrade/skill_publication_identity_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package upgrade + +import ( + "fmt" + "os" + "syscall" +) + +func skillPathFileIncarnation(info os.FileInfo) string { + stat, ok := info.Sys().(*syscall.Win32FileAttributeData) + if !ok { + return fmt.Sprintf("unknown:%T", info.Sys()) + } + return fmt.Sprintf("%d:%d", stat.CreationTime.HighDateTime, stat.CreationTime.LowDateTime) +} diff --git a/internal/upgrade/skill_publication_test.go b/internal/upgrade/skill_publication_test.go index 470b03cc..9733bcac 100644 --- a/internal/upgrade/skill_publication_test.go +++ b/internal/upgrade/skill_publication_test.go @@ -94,6 +94,44 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { } }) + 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("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) { @@ -115,14 +153,61 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { }) t.Run("quarantine rename", func(t *testing.T) { - destination := filepath.Join(t.TempDir(), "destination") - seedUpgradeSkill(t, destination, "value", false) + 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{{Destination: destination}}); !errors.Is(err, 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") @@ -174,10 +259,16 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { if err != nil { t.Fatal(err) } - if err := os.RemoveAll(destination); err != nil { - t.Fatal(err) - } - seedUpgradeSkill(t, destination, "replacement", false) + 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-") { @@ -200,10 +291,16 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { if err != nil { t.Fatal(err) } - if err := os.RemoveAll(destination); err != nil { - t.Fatal(err) - } - seedUpgradeSkill(t, destination, "replacement", false) + 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) @@ -211,6 +308,17 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { }) 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 }) @@ -245,6 +353,15 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { 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 { @@ -263,6 +380,11 @@ func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { 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") + } + }) }) }