fix(skills): make publication rollback race-safe

This commit is contained in:
玉澜 committed 2026-08-14 19:23:59 +08:00
1 parent 4ad321557f
commit a47740ca1c
20 files changed
+934 -135

No files matched your search

+153
View File
@@ -0,0 +1,153 @@
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
}
// 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)
}
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
}
// 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
}
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 && publication.identity != 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)
}
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))
}