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.
198 lines
7.1 KiB
Go
198 lines
7.1 KiB
Go
package upgrade
|
||
|
||
import (
|
||
"crypto/sha256"
|
||
"errors"
|
||
"fmt"
|
||
"hash"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
)
|
||
|
||
// SkillPathPublication records enough immutable identity to prove that a
|
||
// destination still belongs to the transaction that published it. The
|
||
// fingerprint is intentionally private so callers cannot forge records.
|
||
type SkillPathPublication struct {
|
||
Destination string
|
||
fingerprint [sha256.Size]byte
|
||
identity os.FileInfo
|
||
incarnation string
|
||
fileID string
|
||
}
|
||
|
||
// PublishSkillPathNoReplace atomically publishes a staged path without ever
|
||
// replacing a destination created after the backup phase.
|
||
func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication, error) {
|
||
identity, err := skillPathLstat(staged)
|
||
if err != nil {
|
||
return SkillPathPublication{}, fmt.Errorf("读取待发布 Skill 身份失败 %s: %w", staged, err)
|
||
}
|
||
fingerprint, err := fingerprintSkillPath(staged)
|
||
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)
|
||
}
|
||
publishedIdentity, err := skillPathLstat(destination)
|
||
if err != nil {
|
||
return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): %w", destination, err)
|
||
}
|
||
publishedFingerprint, err := fingerprintSkillPath(destination)
|
||
if err != nil {
|
||
return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 内容失败 %s(对象保留): %w", destination, err)
|
||
}
|
||
publishedFileID := skillPathFileIdentity(destination)
|
||
if !skillPathIdentityProven(identity, publishedIdentity, stagedFileID, publishedFileID) || publishedFingerprint != fingerprint {
|
||
return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): staging 身份已变化", destination)
|
||
}
|
||
return SkillPathPublication{
|
||
Destination: destination,
|
||
fingerprint: fingerprint,
|
||
identity: publishedIdentity,
|
||
incarnation: skillPathFileIncarnation(publishedIdentity),
|
||
fileID: publishedFileID,
|
||
}, nil
|
||
}
|
||
|
||
// RollbackSkillPathPublications removes only objects that can still be proven
|
||
// to have been published by this transaction. Each live path is first claimed
|
||
// into a private sibling quarantine. A concurrent replacement is restored when
|
||
// possible, otherwise retained in quarantine and reported explicitly.
|
||
func RollbackSkillPathPublications(publications []SkillPathPublication) error {
|
||
var rollbackErr error
|
||
for i := len(publications) - 1; i >= 0; i-- {
|
||
if err := rollbackSkillPathPublication(publications[i]); err != nil {
|
||
rollbackErr = errors.Join(rollbackErr, err)
|
||
}
|
||
}
|
||
return rollbackErr
|
||
}
|
||
|
||
func rollbackSkillPathPublication(publication SkillPathPublication) (err error) {
|
||
destination := publication.Destination
|
||
quarantineRoot, err := skillPathMkdirTemp(filepath.Dir(destination), "."+filepath.Base(destination)+".rollback-")
|
||
if err != nil {
|
||
return fmt.Errorf("创建 Skill 回滚隔离目录失败 %s: %w", destination, err)
|
||
}
|
||
quarantine := filepath.Join(quarantineRoot, "payload")
|
||
cleanupRoot := func() error {
|
||
if cleanupErr := skillPathRemoveAll(quarantineRoot); cleanupErr != nil {
|
||
return fmt.Errorf("清理 Skill 回滚隔离目录失败 %s: %w", quarantineRoot, cleanupErr)
|
||
}
|
||
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 ||
|
||
!skillPathIdentityProven(publication.identity, liveIdentity, publication.fileID, skillPathFileIdentity(destination)) ||
|
||
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) {
|
||
return cleanupErr
|
||
}
|
||
return errors.Join(fmt.Errorf("隔离待回滚 Skill 失败 %s: %w", destination, err), cleanupErr)
|
||
}
|
||
|
||
actualIdentity, identityErr := skillPathLstat(quarantine)
|
||
actual, fingerprintErr := fingerprintSkillPath(quarantine)
|
||
if identityErr == nil && fingerprintErr == nil &&
|
||
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)
|
||
}
|
||
return cleanupRoot()
|
||
}
|
||
|
||
verificationErr := errors.Join(identityErr, fingerprintErr)
|
||
if verificationErr == nil {
|
||
verificationErr = errors.New("发布对象身份已变化")
|
||
}
|
||
if restoreErr := skillPathRenameNoReplace(quarantine, destination); restoreErr != nil {
|
||
return fmt.Errorf("拒绝删除非本事务 Skill %s: %v;并发对象保留于 %s,恢复原路径失败: %w", destination, verificationErr, quarantine, restoreErr)
|
||
}
|
||
return errors.Join(
|
||
fmt.Errorf("拒绝删除非本事务 Skill %s: %w", destination, verificationErr),
|
||
cleanupRoot(),
|
||
)
|
||
}
|
||
|
||
func fingerprintSkillPath(path string) ([sha256.Size]byte, error) {
|
||
h := sha256.New()
|
||
if err := fingerprintSkillPathInto(h, path); err != nil {
|
||
return [sha256.Size]byte{}, err
|
||
}
|
||
var result [sha256.Size]byte
|
||
copy(result[:], h.Sum(nil))
|
||
return result, nil
|
||
}
|
||
|
||
func fingerprintSkillPathInto(h hash.Hash, path string) error {
|
||
info, err := skillPathLstat(path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
writeFingerprintField(h, info.Mode().Type().String())
|
||
writeFingerprintField(h, info.Mode().Perm().String())
|
||
switch {
|
||
case info.IsDir():
|
||
entries, err := skillPathReadDir(path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
|
||
writeFingerprintField(h, fmt.Sprintf("%d", len(entries)))
|
||
for _, entry := range entries {
|
||
writeFingerprintField(h, entry.Name())
|
||
if err := fingerprintSkillPathInto(h, filepath.Join(path, entry.Name())); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
case info.Mode()&os.ModeSymlink != 0:
|
||
target, err := skillPathReadlink(path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
writeFingerprintField(h, target)
|
||
return nil
|
||
case info.Mode().IsRegular():
|
||
digest, err := skillPathFileDigest(path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
writeFingerprintField(h, fmt.Sprintf("%d", info.Size()))
|
||
_, err = h.Write(digest[:])
|
||
return err
|
||
default:
|
||
return fmt.Errorf("不支持识别特殊 Skill 路径 %s(mode=%s)", path, info.Mode())
|
||
}
|
||
}
|
||
|
||
func writeFingerprintField(h hash.Hash, value string) {
|
||
_, _ = fmt.Fprintf(h, "%d:", len(value))
|
||
_, _ = h.Write([]byte(value))
|
||
}
|