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).
291 lines
13 KiB
Go
291 lines
13 KiB
Go
package upgrade
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
// errNoReplaceRenameUnsupported marks a platform that has no atomic no-replace
|
|
// rename primitive at all.
|
|
var errNoReplaceRenameUnsupported = errors.New("平台不支持原子 no-replace rename")
|
|
|
|
var skillPathRenameNoReplaceAtomic = renameSkillPathNoReplaceAtomic
|
|
|
|
// renameSkillPathNoReplace moves source onto destination and never replaces an
|
|
// object that already occupies destination. On success it also returns the
|
|
// fresh mkdir-claim identity captured by the child-move directory fallback
|
|
// (empty when the atomic primitive or the empty-target fast path published
|
|
// dest, or when the platform cannot report a stable file identity). The
|
|
// publication path uses that identity as a tamperproof witness that dest is
|
|
// still the mkdir claim this transaction created; every other caller ignores
|
|
// it.
|
|
//
|
|
// The atomic primitives (Linux RENAME_NOREPLACE, Darwin RENAME_EXCL) require
|
|
// support from the underlying filesystem and report EINVAL/ENOTSUP when it is
|
|
// missing — rename(2) lists only ext4, btrfs, tmpfs and cifs, so NFS, FUSE and
|
|
// overlayfs homes reject the flag. Failing there would abort the whole install
|
|
// on those filesystems, so the fallback uses truly atomic no-clobber primitives
|
|
// that work on every POSIX filesystem: os.Mkdir for directories (fails with
|
|
// EEXIST if the path is occupied), os.Link for regular files (fails with
|
|
// EEXIST if the path is occupied), and os.Symlink for symbolic links (same
|
|
// EEXIST guarantee). Neither primitive has a TOCTOU window.
|
|
func renameSkillPathNoReplace(source, destination string) (string, error) {
|
|
err := skillPathRenameNoReplaceAtomic(source, destination)
|
|
if err == nil {
|
|
return "", nil
|
|
}
|
|
if !errors.Is(err, errNoReplaceRenameUnsupported) && !isNoReplaceRenameUnsupported(err) {
|
|
return "", err
|
|
}
|
|
return renameSkillPathNoReplaceFallback(source, destination)
|
|
}
|
|
|
|
// renameSkillPathNoReplaceFallback is used when the kernel no-replace rename
|
|
// flag is unavailable. It dispatches on the source type to pick an atomic
|
|
// no-clobber primitive that does not require filesystem support. When the
|
|
// directory slow path publishes via child-move, the returned claim identity
|
|
// is the destination file identity captured immediately after mkdir; every
|
|
// other outcome returns an empty claim identity.
|
|
func renameSkillPathNoReplaceFallback(source, destination string) (string, error) {
|
|
info, err := skillPathLstat(source)
|
|
if err != nil {
|
|
return "", fmt.Errorf("读取源 Skill 身份失败 %s: %w", source, err)
|
|
}
|
|
switch {
|
|
case info.IsDir():
|
|
return renameSkillDirNoReplaceFallback(source, destination)
|
|
case info.Mode().IsRegular():
|
|
return "", renameSkillFileNoReplaceFallback(source, destination)
|
|
default:
|
|
return "", &os.LinkError{
|
|
Op: "rename", Old: source, New: destination,
|
|
Err: fmt.Errorf("%w: 源路径类型不支持降级发布", errNoReplaceRenameUnsupported),
|
|
}
|
|
}
|
|
}
|
|
|
|
// renameSkillDirNoReplaceFallback claims the destination once with mkdir and
|
|
// never unlinks it for the remainder of the transaction. The fast path
|
|
// renames the source over the claim; on platforms that permit replacing an
|
|
// empty directory (Linux) that rename only ever touches the claim we own.
|
|
// Platforms that refuse to rename over any directory (macOS returns EEXIST
|
|
// even for an empty target) take the slow path instead — but only after
|
|
// proving the claim is still empty: a rename that failed because a
|
|
// concurrent writer landed inside the claim must abort with the destination
|
|
// retained, never fall through to child moves whose per-entry primitives
|
|
// would collide with (and rollback would delete) the foreign data. The slow
|
|
// path moves the source children into the claim one by one, each through an
|
|
// atomic no-clobber primitive (mkdir claim for directories, os.Link for
|
|
// files, os.Symlink for links), so a same-name concurrent entry aborts the
|
|
// move and a foreign entry of any name — detected by re-reading the claim —
|
|
// retains the destination instead of letting the rollback delete it. The
|
|
// destination claim is removed only while still empty, so a foreign entry
|
|
// always survives a failed publication. On success the source is fully
|
|
// consumed either way: the fast-path rename moves it onto the claim, and
|
|
// the child-move slow path removes the emptied source shell after the last
|
|
// child relocates.
|
|
//
|
|
// The returned claim identity is non-empty only when the child-move slow
|
|
// path published dest and the platform can report a stable file identity;
|
|
// the fast-path rename consumes the mkdir claim (dest inherits the source
|
|
// inode) and returns "", as does an unsuccessful publish. Callers that need
|
|
// a tamperproof ownership witness (see PublishSkillPathNoReplace) compare
|
|
// the returned identity against skillPathFileIdentity(destination) at the
|
|
// moment of the ownership check — a wholesale replacement of dest after
|
|
// the claim produces a different identity.
|
|
//
|
|
// The old fallback (mkdir → rename → remove → rename) had a TOCTOU window
|
|
// between the second remove and the second rename during which a concurrent
|
|
// writer could create a file or empty directory at dest that the second
|
|
// os.Rename would silently overwrite. This implementation removes that
|
|
// window: the claim is never unlinked between the fast-path rename attempt
|
|
// and publication, and child-move relies exclusively on atomic no-clobber
|
|
// primitives that fail with EEXIST rather than replace.
|
|
func renameSkillDirNoReplaceFallback(source, destination string) (string, error) {
|
|
sourceInfo, err := skillPathLstat(source)
|
|
if err != nil {
|
|
return "", fmt.Errorf("读取源 Skill 身份失败 %s: %w", source, err)
|
|
}
|
|
if err := skillPathMkdir(destination, 0o700); err != nil {
|
|
if os.IsExist(err) {
|
|
return "", &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist}
|
|
}
|
|
return "", fmt.Errorf("声明 Skill 发布目标失败 %s: %w", destination, err)
|
|
}
|
|
// Capture the claim identity immediately after mkdir but before any
|
|
// children move in. A concurrent replacement of destination (unlink +
|
|
// recreate) after this point produces a different identity.
|
|
claimID := skillPathFileIdentity(destination)
|
|
if err := skillPathRename(source, destination); err == nil {
|
|
// Fast path consumed the source: destination inode is now the
|
|
// staged inode, not the mkdir claim. Return no claim identity —
|
|
// the caller falls back to the standard staged-identity proof.
|
|
return "", nil
|
|
}
|
|
claimEntries, claimErr := skillPathReadDir(destination)
|
|
if claimErr != nil {
|
|
return "", errors.Join(
|
|
fmt.Errorf("降级发布 Skill 目录失败 %s: %w", destination, claimErr),
|
|
removeClaimIfEmpty(destination),
|
|
)
|
|
}
|
|
if len(claimEntries) > 0 {
|
|
// A concurrent writer populated the empty claim before we could
|
|
// take the slow path. Falling through to child-move would let
|
|
// its per-entry no-clobber primitives collide with the foreign
|
|
// data; abort with the destination retained instead.
|
|
return "", fmt.Errorf("降级发布中止,目标在声明后被并发写入 %s: %w", destination, os.ErrExist)
|
|
}
|
|
if err := moveSkillDirChildrenIntoClaim(source, destination, sourceInfo.Mode().Perm()); err != nil {
|
|
return "", fmt.Errorf("降级发布 Skill 目录失败 %s: %w", destination, err)
|
|
}
|
|
// Child-move consumed every source entry, so the emptied source shell is
|
|
// ours by construction. os.Remove refuses a non-empty directory, so a
|
|
// concurrent writer inside the staging tree fails the publication loudly
|
|
// instead of being silently discarded with the shell.
|
|
if err := skillPathRemove(source); err != nil && !os.IsNotExist(err) {
|
|
return "", fmt.Errorf("清理已迁移 Skill 源目录失败 %s: %w", source, err)
|
|
}
|
|
// Child-move succeeded: dest is still the mkdir claim we created.
|
|
return claimID, nil
|
|
}
|
|
|
|
// moveSkillDirChildNoReplace moves one child of a staged directory into the
|
|
// claimed destination through an atomic no-clobber primitive: directories
|
|
// recursively claim with mkdir, regular files publish via os.Link, and
|
|
// symlinks are recreated with os.Symlink. Every primitive fails with EEXIST
|
|
// when the destination entry was taken concurrently, so no step can replace
|
|
// a foreign object.
|
|
func moveSkillDirChildNoReplace(source, destination string) error {
|
|
info, err := skillPathLstat(source)
|
|
if err != nil {
|
|
return fmt.Errorf("读取源 Skill 目录项失败 %s: %w", source, err)
|
|
}
|
|
switch {
|
|
case info.IsDir():
|
|
// The nested fallback consumes the nested source on success — the
|
|
// fast-path rename moves it away and the child-move slow path
|
|
// removes the emptied shell. The nested claim identity is not a
|
|
// witness for this transaction (only the top-level destination's
|
|
// identity is), so it is discarded here.
|
|
_, err := renameSkillDirNoReplaceFallback(source, destination)
|
|
return err
|
|
case info.Mode().IsRegular():
|
|
return renameSkillFileNoReplaceFallback(source, destination)
|
|
case info.Mode()&os.ModeSymlink != 0:
|
|
return renameSkillSymlinkNoReplaceFallback(source, destination)
|
|
default:
|
|
return &os.LinkError{
|
|
Op: "rename", Old: source, New: destination,
|
|
Err: fmt.Errorf("%w: 源路径类型不支持降级发布", errNoReplaceRenameUnsupported),
|
|
}
|
|
}
|
|
}
|
|
|
|
// removeClaimIfEmpty deletes the claimed destination only while it still
|
|
// holds no entries. A foreign entry that appeared after the claim must never
|
|
// be removed: the rollback has no ownership proof for it.
|
|
func removeClaimIfEmpty(destination string) error {
|
|
entries, err := skillPathReadDir(destination)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("检查降级发布目标失败 %s: %w", destination, err)
|
|
}
|
|
if len(entries) > 0 {
|
|
return nil
|
|
}
|
|
if err := skillPathRemove(destination); err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("清理降级发布目标失败 %s: %w", destination, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// moveSkillDirChildrenIntoClaim moves every top-level child of source into
|
|
// the claimed destination — each through moveSkillDirChildNoReplace, so a
|
|
// concurrently created same-name entry fails atomically — and restores the
|
|
// source mode on the claim. The claim is re-read after the moves: an entry
|
|
// count above the source's means a different-named foreign object landed
|
|
// mid-move, and the publication aborts with the destination retained. Any
|
|
// failure moves the already relocated children back and removes the claim
|
|
// only while it is empty, leaving the source intact and foreign entries in
|
|
// place.
|
|
func moveSkillDirChildrenIntoClaim(source, destination string, sourceMode os.FileMode) error {
|
|
entries, err := skillPathReadDir(source)
|
|
if err != nil {
|
|
return errors.Join(
|
|
fmt.Errorf("读取源 Skill 目录失败 %s: %w", source, err),
|
|
removeClaimIfEmpty(destination),
|
|
)
|
|
}
|
|
moved := make([]string, 0, len(entries))
|
|
rollback := func() error {
|
|
var rollbackErr error
|
|
for i := len(moved) - 1; i >= 0; i-- {
|
|
if err := skillPathRename(filepath.Join(destination, moved[i]), filepath.Join(source, moved[i])); err != nil {
|
|
rollbackErr = errors.Join(rollbackErr, err)
|
|
}
|
|
}
|
|
rollbackErr = errors.Join(rollbackErr, removeClaimIfEmpty(destination))
|
|
return rollbackErr
|
|
}
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
if err := moveSkillDirChildNoReplace(filepath.Join(source, name), filepath.Join(destination, name)); err != nil {
|
|
return errors.Join(fmt.Errorf("迁移 Skill 目录项失败 %s: %w", name, err), rollback())
|
|
}
|
|
moved = append(moved, name)
|
|
}
|
|
live, liveErr := skillPathReadDir(destination)
|
|
if liveErr != nil {
|
|
return errors.Join(
|
|
fmt.Errorf("确认降级发布目标失败 %s: %w", destination, liveErr),
|
|
rollback(),
|
|
)
|
|
}
|
|
if len(live) > len(entries) {
|
|
return errors.Join(
|
|
fmt.Errorf("降级发布中止,目标出现并发目录项 %s: %w", destination, os.ErrExist),
|
|
rollback(),
|
|
)
|
|
}
|
|
if err := skillPathChmod(destination, sourceMode); err != nil {
|
|
return errors.Join(
|
|
fmt.Errorf("恢复已发布 Skill 目录权限失败 %s: %w", destination, err),
|
|
rollback(),
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// renameSkillFileNoReplaceFallback uses os.Link, which atomically fails if the
|
|
// destination exists, then removes the source to complete the move.
|
|
func renameSkillFileNoReplaceFallback(source, destination string) error {
|
|
if err := skillPathLink(source, destination); err != nil {
|
|
if os.IsExist(err) {
|
|
return &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist}
|
|
}
|
|
return fmt.Errorf("发布 Skill 文件失败 %s: %w", destination, err)
|
|
}
|
|
return skillPathRemove(source)
|
|
}
|
|
|
|
// renameSkillSymlinkNoReplaceFallback recreates the link at the destination
|
|
// with os.Symlink — atomic, EEXIST when taken — and then removes the source.
|
|
func renameSkillSymlinkNoReplaceFallback(source, destination string) error {
|
|
target, err := skillPathReadlink(source)
|
|
if err != nil {
|
|
return fmt.Errorf("读取源 Skill 链接失败 %s: %w", source, err)
|
|
}
|
|
if err := skillPathSymlink(target, destination); err != nil {
|
|
if os.IsExist(err) {
|
|
return &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist}
|
|
}
|
|
return fmt.Errorf("发布 Skill 链接失败 %s: %w", destination, err)
|
|
}
|
|
return skillPathRemove(source)
|
|
}
|