The mkdir->rename->remove->rename directory fallback had a TOCTOU window between the second remove and the second rename: a concurrent writer creating an entry at the destination was silently clobbered. The fallback now claims the destination once with mkdir and never unlinks it: the fast-path rename publishes over the claim (Linux), and platforms that refuse directory renames (macOS, Windows) move the staged children into the claim through atomic no-clobber primitives (mkdir/os.Link/os.Symlink), consuming the emptied source shell on success. renameSkillPathNoReplace now returns the mkdir-claim identity captured by the child-move path. PublishSkillPathNoReplace uses it as a three-state ownership witness: the atomic/fast paths keep the staged-inode proof, the child-move path proves dest is still the mkdir claim, and a mismatch reports the new ErrSkillPathPublicationUncertain sentinel with the destination retained. The witness is real on POSIX now: darwin and linux report the dev:ino file identity instead of the empty no-op. Upstream consumers honor the sentinel: the mono/multi upgrade copy fallbacks no longer retry over an uncertain destination (the retry would displace the concurrent writer's object), and skill setup reports the retained destination instead of claiming a rollback. Rewrites the fallback tests that pinned the removed remove-and-retry flow and adds regression coverage: concurrent claim entries abort with the destination retained, wholesale replacement after child-move reports the uncertain sentinel, staged-set transactions pass the sentinel through, and both copy fallbacks short-circuit (ablation-verified).
242 lines
9.8 KiB
Go
242 lines
9.8 KiB
Go
package upgrade
|
||
|
||
import (
|
||
"crypto/sha256"
|
||
"errors"
|
||
"fmt"
|
||
"hash"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
)
|
||
|
||
// ErrSkillPathPublicationUncertain marks a publication whose transactional
|
||
// outcome cannot be determined safely: this transaction did occupy the
|
||
// destination at some point, but a stronger witness — the fresh mkdir-claim
|
||
// identity captured by the child-move fallback — now indicates that the
|
||
// object currently at the destination may belong to a concurrent writer.
|
||
// Callers must not automatically retract, replace, or retry-copy over the
|
||
// destination; instead they should surface the state so the user or a
|
||
// higher layer can inspect it. Errors wrapping this sentinel are safe to
|
||
// test with errors.Is.
|
||
var ErrSkillPathPublicationUncertain = errors.New("Skill 发布状态不确定")
|
||
|
||
// 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.
|
||
//
|
||
// The rename returns a mkdir-claim identity when (and only when) the
|
||
// child-move fallback published dest. That identity is the tamperproof
|
||
// witness that dest is still the mkdir claim this transaction created
|
||
// (unlike the staged shell, whose presence alone is compatible with a
|
||
// wholesale replacement of dest by a concurrent writer). When the claim
|
||
// identity no longer matches dest, publication reports uncertain state
|
||
// (ErrSkillPathPublicationUncertain) and keeps dest — auto-retracting or
|
||
// overwriting would delete the concurrent writer's data.
|
||
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)
|
||
claimIdentity, err := skillPathRenameNoReplace(staged, destination)
|
||
if err != nil {
|
||
return SkillPathPublication{}, fmt.Errorf("目标必须不存在的 Skill 发布失败 %s: %w", destination, err)
|
||
}
|
||
// If the child-move fallback published dest, the claim identity must
|
||
// still describe the object at destination. A wholesale replacement of
|
||
// dest between the mkdir claim and this check produces a different
|
||
// identity — dest is a foreign object and must not be treated as this
|
||
// transaction's publication. An unreadable live identity (dest already
|
||
// vanished, or the platform cannot report one) defers to the standard
|
||
// confirmation checks below instead of guessing.
|
||
if claimIdentity != "" {
|
||
if liveIdentity := skillPathFileIdentity(destination); liveIdentity != "" && liveIdentity != claimIdentity {
|
||
return SkillPathPublication{}, fmt.Errorf("%w:child-move 发布后目标 %s 已被替换;目标保留", ErrSkillPathPublicationUncertain, destination)
|
||
}
|
||
}
|
||
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)
|
||
// When claim identity is available (child-move path), it is the
|
||
// authoritative witness of "dest is our object"; the staged inode
|
||
// necessarily differs from the mkdir-claim inode. Only enforce the
|
||
// staged-inode identity proof when there is no claim identity — that
|
||
// is, when the atomic primitive or the fast-path rename consumed the
|
||
// staged inode into dest.
|
||
if claimIdentity == "" && !skillPathIdentityProven(identity, publishedIdentity, stagedFileID, publishedFileID) {
|
||
return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): staging 身份已变化", destination)
|
||
}
|
||
if 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("发布对象身份已变化")
|
||
}
|
||
// Rollback restore is not a publication path; ignore any claim
|
||
// identity returned by the rename — no ownership check follows.
|
||
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))
|
||
}
|