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:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user