fix(skills): prove Windows rollback identity via stable file ID

NTFS file tunneling can restore the original creation time for a
recreated same-named object, which defeated the creation-time
incarnation check and allowed rollback to delete a concurrent
replacement. Replace the platform-specific identity pair with a single
skillPathIdentityProven function:

- Unix: delegates to os.SameFile (inode/dev), ignoring file ID strings
- Windows: compares VolumeSerialNumber:FileIndexHigh:FileIndexLow from
  GetFileInformationByHandle, which uniquely identifies the file on the
  volume for its lifetime and is unaffected by tunneling

When the file ID cannot be obtained at publish time, identity is not
proven and the auto-delete is refused. Add a regression test that
simulates tunneled creation time and verifies rollback still refuses
the concurrent replacement.
This commit is contained in:
玉澜
2026-08-17 20:20:49 +08:00
parent f88be7ae21
commit 0db91cfc44
7 changed files with 107 additions and 15 deletions
+2
View File
@@ -24,6 +24,8 @@ var (
skillPathLink = os.Link
skillPathRemove = os.Remove
skillPathReadDir = os.ReadDir
skillPathFileIdentity = skillPathFileIdentityImpl
skillPathFileIncarnation = skillPathFileIncarnationImpl
skillPathReadlink = os.Readlink
skillPathSymlink = os.Symlink
skillPathOpen = os.Open
+8 -3
View File
@@ -18,6 +18,7 @@ type SkillPathPublication struct {
fingerprint [sha256.Size]byte
identity os.FileInfo
incarnation string
fileID string
}
// PublishSkillPathNoReplace atomically publishes a staged path without ever
@@ -31,6 +32,7 @@ func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication
if err != nil {
return SkillPathPublication{}, fmt.Errorf("计算待发布 Skill 身份失败 %s: %w", staged, err)
}
stagedFileID := skillPathFileIdentity(staged)
if err := skillPathRenameNoReplace(staged, destination); err != nil {
return SkillPathPublication{}, fmt.Errorf("目标必须不存在的 Skill 发布失败 %s: %w", destination, err)
}
@@ -42,7 +44,8 @@ func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication
if err != nil {
return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 内容失败 %s(对象保留): %w", destination, err)
}
if !skillPathSameFileIdentity(identity, publishedIdentity) || publishedFingerprint != fingerprint {
publishedFileID := skillPathFileIdentity(destination)
if !skillPathIdentityProven(identity, publishedIdentity, stagedFileID, publishedFileID) || publishedFingerprint != fingerprint {
return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): staging 身份已变化", destination)
}
return SkillPathPublication{
@@ -50,6 +53,7 @@ func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication
fingerprint: fingerprint,
identity: publishedIdentity,
incarnation: skillPathFileIncarnation(publishedIdentity),
fileID: publishedFileID,
}, nil
}
@@ -93,7 +97,8 @@ func rollbackSkillPathPublication(publication SkillPathPublication) (err error)
cleanupRoot(),
)
}
if publication.identity == nil || !skillPathSameFileIdentity(publication.identity, liveIdentity) ||
if publication.identity == nil ||
!skillPathIdentityProven(publication.identity, liveIdentity, publication.fileID, skillPathFileIdentity(destination)) ||
publication.incarnation != skillPathFileIncarnation(liveIdentity) ||
liveFingerprint != publication.fingerprint {
return errors.Join(
@@ -113,7 +118,7 @@ func rollbackSkillPathPublication(publication SkillPathPublication) (err error)
actualIdentity, identityErr := skillPathLstat(quarantine)
actual, fingerprintErr := fingerprintSkillPath(quarantine)
if identityErr == nil && fingerprintErr == nil &&
skillPathSameFileIdentity(publication.identity, actualIdentity) &&
skillPathIdentityProven(publication.identity, actualIdentity, publication.fileID, skillPathFileIdentity(quarantine)) &&
actual == publication.fingerprint {
if removeErr := removePublishedSkillSource(quarantine); removeErr != nil {
return fmt.Errorf("移除事务发布的 Skill 失败 %s(隔离于 %s): %w", destination, quarantine, removeErr)
@@ -8,7 +8,7 @@ import (
"syscall"
)
func skillPathFileIncarnation(info os.FileInfo) string {
func skillPathFileIncarnationImpl(info os.FileInfo) string {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return fmt.Sprintf("unknown:%T", info.Sys())
@@ -20,3 +20,11 @@ func skillPathFileIncarnation(info os.FileInfo) string {
func skillPathSameFileIdentity(left, right os.FileInfo) bool {
return os.SameFile(left, right)
}
func skillPathFileIdentityImpl(_ string) string {
return ""
}
func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool {
return skillPathSameFileIdentity(staged, published)
}
@@ -8,7 +8,7 @@ import (
"syscall"
)
func skillPathFileIncarnation(info os.FileInfo) string {
func skillPathFileIncarnationImpl(info os.FileInfo) string {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return fmt.Sprintf("unknown:%T", info.Sys())
@@ -19,3 +19,11 @@ func skillPathFileIncarnation(info os.FileInfo) string {
func skillPathSameFileIdentity(left, right os.FileInfo) bool {
return os.SameFile(left, right)
}
func skillPathFileIdentityImpl(_ string) string {
return ""
}
func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool {
return skillPathSameFileIdentity(staged, published)
}
@@ -7,10 +7,18 @@ import (
"os"
)
func skillPathFileIncarnation(info os.FileInfo) string {
func skillPathFileIncarnationImpl(info os.FileInfo) string {
return fmt.Sprintf("%d:%d:%s", info.ModTime().UnixNano(), info.Size(), info.Mode())
}
func skillPathSameFileIdentity(left, right os.FileInfo) bool {
return os.SameFile(left, right)
}
func skillPathFileIdentityImpl(_ string) string {
return ""
}
func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool {
return skillPathSameFileIdentity(staged, published)
}
@@ -6,9 +6,11 @@ import (
"fmt"
"os"
"syscall"
"golang.org/x/sys/windows"
)
func skillPathFileIncarnation(info os.FileInfo) string {
func skillPathFileIncarnationImpl(info os.FileInfo) string {
stat, ok := info.Sys().(*syscall.Win32FileAttributeData)
if !ok {
return fmt.Sprintf("unknown:%T", info.Sys())
@@ -16,13 +18,47 @@ func skillPathFileIncarnation(info os.FileInfo) string {
return fmt.Sprintf("%d:%d", stat.CreationTime.HighDateTime, stat.CreationTime.LowDateTime)
}
// skillPathSameFileIdentity returns false because os.FileInfo on Windows does
// not expose the stable file ID (VolumeSerialNumber / FileIndex from
// GetFileInformationByHandle), which is the only primitive that distinguishes
// a recreated file from the original after NTFS file tunneling restores the
// creation time. Callers must use skillPathIdentityProven, which compares file
// IDs obtained via GetFileInformationByHandle.
func skillPathSameFileIdentity(_, _ os.FileInfo) bool {
// MoveFile can surface different metadata identities for the same reparse
// point before and after rename, so Windows ownership relies on the
// separately checked creation-time incarnation plus the lexical fingerprint.
// That pair is strong evidence but not proof: NTFS file tunneling restores
// the original creation time when a same-named object is recreated in the
// same directory shortly afterwards, so a byte-identical concurrent
// replacement can still be attributed to this transaction.
return true
return false
}
// skillPathFileIdentityImpl queries the stable volume serial number and file
// index via GetFileInformationByHandle. Unlike creation time (which NTFS file
// tunneling can restore for a recreated same-named object), the file index
// uniquely identifies the file on the volume for its lifetime.
func skillPathFileIdentityImpl(path string) string {
p, err := windows.UTF16PtrFromString(path)
if err != nil {
return ""
}
handle, err := windows.CreateFile(
p, windows.FILE_READ_ATTRIBUTES,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
nil, windows.OPEN_EXISTING, windows.FILE_FLAG_BACKUP_SEMANTICS, 0,
)
if err != nil {
return ""
}
defer windows.CloseHandle(handle)
var info windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
return ""
}
return fmt.Sprintf("%d:%d:%d", info.VolumeSerialNumber, info.FileIndexHigh, info.FileIndexLow)
}
// skillPathIdentityProven is the real identity proof on Windows. An empty
// expected value means the file ID could not be obtained at publish time, so
// identity cannot be proven and the caller must refuse the auto-delete.
func skillPathIdentityProven(_, _ os.FileInfo, expected, actual string) bool {
if expected == "" {
return false
}
return expected == actual
}
@@ -65,6 +65,31 @@ func TestCrossPlatformCoverageSkillPublicationNoClobberAndOwnedRollback(t *testi
}
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)
}
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) {