Files
dingtalk-workspace-cli/internal/upgrade/skill_path_move.go
T

359 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package upgrade
import (
"crypto/sha256"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
)
var (
skillPathRename = func(src, dst string) error { return upgradeRename(src, dst) }
skillPathRenameNoReplace = renameSkillPathNoReplace
skillPathCopy = copySkillPathLexically
skillPathVerify = verifySkillPathCopy
skillPathRemoveAll = os.RemoveAll
skillPathMkdirAll = os.MkdirAll
skillPathMkdirTemp = os.MkdirTemp
skillPathChmod = os.Chmod
skillPathLstat = os.Lstat
skillPathMkdir = os.Mkdir
skillPathLink = os.Link
skillPathRemove = os.Remove
skillPathReadDir = os.ReadDir
skillPathFileIdentity = skillPathFileIdentityImpl
skillPathFileIncarnation = skillPathFileIncarnationImpl
skillPathSameFileIdentity = skillPathSameFileIdentityImpl
skillPathReadlink = os.Readlink
skillPathSymlink = os.Symlink
skillPathOpen = os.Open
skillPathOpenFile = os.OpenFile
skillPathCopyBytes = io.Copy
skillPathSync = func(file *os.File) error { return file.Sync() }
skillPathWalkDir = filepath.WalkDir
)
// moveSkillPathRecoverably moves one managed Skill path without weakening the
// backup contract when source and destination are on different filesystems.
// The cross-device path publishes a fully copied and verified sibling staging
// path before removing the source. Any failure before source removal leaves the
// source intact; a removal failure deliberately leaves both verified copies.
func moveSkillPathRecoverably(src, dst string) (err error) {
if _, statErr := skillPathLstat(dst); statErr == nil {
return fmt.Errorf("目标已存在: %s", dst)
} else if !os.IsNotExist(statErr) {
return fmt.Errorf("检查移动目标失败 %s: %w", dst, statErr)
}
if err := skillPathMkdirAll(filepath.Dir(dst), dirPermShared); err != nil {
return fmt.Errorf("创建移动目标目录失败 %s: %w", filepath.Dir(dst), err)
}
if renameErr := skillPathRenameNoReplace(src, dst); renameErr == nil {
return nil
} else if !isCrossDeviceError(renameErr) {
return fmt.Errorf("移动 Skill 路径失败 %s -> %s: %w", src, dst, renameErr)
}
stageRoot, err := skillPathMkdirTemp(filepath.Dir(dst), "."+filepath.Base(dst)+".cross-device-")
if err != nil {
return fmt.Errorf("创建跨设备 Skill staging 失败 %s: %w", dst, err)
}
stage := filepath.Join(stageRoot, "payload")
stageCleaned := false
defer func() {
if stageCleaned {
return
}
makeSkillPathTreeWritable(stageRoot)
if cleanupErr := skillPathRemoveAll(stageRoot); cleanupErr != nil {
err = errors.Join(err, fmt.Errorf("清理跨设备 Skill staging 失败 %s: %w", stageRoot, cleanupErr))
}
}()
if err := skillPathCopy(src, stage); err != nil {
return fmt.Errorf("复制跨设备 Skill staging 失败 %s: %w", stage, err)
}
if err := skillPathVerify(src, stage); err != nil {
return fmt.Errorf("校验跨设备 Skill staging 失败 %s: %w", stage, err)
}
stageInfo, err := skillPathLstat(stage)
if err != nil {
return fmt.Errorf("检查跨设备 Skill staging 失败 %s: %w", stage, err)
}
if stageInfo.IsDir() {
// Darwin refuses to rename a read-only directory even when both parent
// directories are writable. The exact source mode was verified above;
// temporarily add owner access for publication, then restore it before
// the final verification and before the source is removed.
if err := skillPathChmod(stage, stageInfo.Mode().Perm()|0o700); err != nil {
return fmt.Errorf("准备跨设备 Skill staging 发布失败 %s: %w", stage, err)
}
}
if err := skillPathRenameNoReplace(stage, dst); err != nil {
return fmt.Errorf("发布跨设备 Skill 备份失败 %s: %w", dst, err)
}
if stageInfo.IsDir() {
if err := skillPathChmod(dst, stageInfo.Mode().Perm()); err != nil {
return fmt.Errorf("恢复已发布 Skill 目录权限失败 %s: %w", dst, err)
}
}
if err := skillPathVerify(src, dst); err != nil {
return fmt.Errorf("校验已发布 Skill 备份失败 %s: %w", dst, err)
}
if err := skillPathRemoveAll(stageRoot); err != nil {
return fmt.Errorf("清理已发布 Skill staging 失败 %s: %w", stageRoot, err)
}
stageCleaned = true
if err := removePublishedSkillSource(src); err != nil {
return fmt.Errorf("Skill 目标已发布但源路径删除失败(源 %s 与目标 %s 均保留): %w", src, dst, err)
}
return nil
}
type skillPathDirMode struct {
path string
mode os.FileMode
}
func removePublishedSkillSource(src string) error {
dirModes, err := prepareSkillPathTreeRemoval(src)
if err != nil {
return err
}
removeErr := skillPathRemoveAll(src)
if removeErr == nil {
if _, statErr := skillPathLstat(src); os.IsNotExist(statErr) {
return nil
} else if statErr == nil {
removeErr = errors.New("源路径仍存在")
} else {
removeErr = fmt.Errorf("无法确认源路径已删除: %w", statErr)
}
}
return errors.Join(removeErr, restoreSkillPathDirModes(dirModes))
}
func prepareSkillPathTreeRemoval(root string) ([]skillPathDirMode, error) {
var modes []skillPathDirMode
err := skillPathWalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if !entry.IsDir() {
return nil
}
info, err := entry.Info()
if err != nil {
return err
}
mode := info.Mode().Perm()
if mode&0o700 == 0o700 {
return nil
}
if err := skillPathChmod(path, mode|0o700); err != nil {
return err
}
modes = append(modes, skillPathDirMode{path: path, mode: mode})
return nil
})
if err != nil {
return nil, errors.Join(err, restoreSkillPathDirModes(modes))
}
return modes, nil
}
func restoreSkillPathDirModes(modes []skillPathDirMode) error {
var restoreErr error
for i := len(modes) - 1; i >= 0; i-- {
item := modes[i]
if _, err := skillPathLstat(item.path); os.IsNotExist(err) {
continue
} else if err != nil {
restoreErr = errors.Join(restoreErr, err)
continue
}
if err := skillPathChmod(item.path, item.mode); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复源目录权限失败 %s: %w", item.path, err))
}
}
return restoreErr
}
func makeSkillPathTreeWritable(root string) {
// Best effort only: RemoveAll below remains the source of truth and its
// error is returned. This preparation lets it traverse read-only staging
// directories left by a copy or verification failure.
_ = skillPathWalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr == nil && entry.IsDir() {
_ = skillPathChmod(path, 0o700)
}
return nil
})
}
// RestoreSkillPath restores a previously recorded backup using the same
// rename-first, verified cross-device fallback as backup publication.
func RestoreSkillPath(backup, original string) error {
return moveSkillPathRecoverably(backup, original)
}
func copySkillPathLexically(src, dst string) error {
info, err := skillPathLstat(src)
if err != nil {
return err
}
switch {
case info.IsDir():
// Staging must remain writable while children are copied. Restore the
// source mode only after the directory is complete so read-only Skill
// directories (for example 0555) can still be migrated lexically.
if err := skillPathMkdir(dst, 0o700); err != nil {
return err
}
entries, err := skillPathReadDir(src)
if err != nil {
return err
}
for _, entry := range entries {
if err := copySkillPathLexically(filepath.Join(src, entry.Name()), filepath.Join(dst, entry.Name())); err != nil {
return err
}
}
return os.Chmod(dst, info.Mode().Perm())
case info.Mode()&os.ModeSymlink != 0:
target, err := skillPathReadlink(src)
if err != nil {
return err
}
return skillPathSymlink(target, dst)
case info.Mode().IsRegular():
return copyRegularSkillFile(src, dst, info.Mode().Perm())
default:
return fmt.Errorf("不支持复制特殊 Skill 路径 %s(mode=%s)", src, info.Mode())
}
}
func copyRegularSkillFile(src, dst string, mode os.FileMode) (err error) {
in, err := skillPathOpen(src)
if err != nil {
return err
}
defer func() { err = errors.Join(err, in.Close()) }()
out, err := skillPathOpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
defer func() { err = errors.Join(err, out.Close()) }()
if _, err := skillPathCopyBytes(out, in); err != nil {
return err
}
if err := skillPathSync(out); err != nil {
return err
}
return os.Chmod(dst, mode)
}
func verifySkillPathCopy(src, dst string) error {
srcInfo, err := skillPathLstat(src)
if err != nil {
return err
}
dstInfo, err := skillPathLstat(dst)
if err != nil {
return err
}
if srcInfo.Mode()&os.ModeType != dstInfo.Mode()&os.ModeType {
return fmt.Errorf("路径类型不一致: %s (%s) != %s (%s)", src, srcInfo.Mode(), dst, dstInfo.Mode())
}
switch {
case srcInfo.IsDir():
if srcInfo.Mode().Perm() != dstInfo.Mode().Perm() {
return fmt.Errorf("目录权限不一致: %s (%s) != %s (%s)", src, srcInfo.Mode().Perm(), dst, dstInfo.Mode().Perm())
}
srcNames, err := skillPathEntryNames(src)
if err != nil {
return err
}
dstNames, err := skillPathEntryNames(dst)
if err != nil {
return err
}
if len(srcNames) != len(dstNames) {
return fmt.Errorf("目录项数量不一致: %s (%d) != %s (%d)", src, len(srcNames), dst, len(dstNames))
}
for i, name := range srcNames {
if name != dstNames[i] {
return fmt.Errorf("目录项不一致: %s != %s", name, dstNames[i])
}
if err := verifySkillPathCopy(filepath.Join(src, name), filepath.Join(dst, name)); err != nil {
return err
}
}
return nil
case srcInfo.Mode()&os.ModeSymlink != 0:
srcTarget, err := skillPathReadlink(src)
if err != nil {
return err
}
dstTarget, err := skillPathReadlink(dst)
if err != nil {
return err
}
if srcTarget != dstTarget {
return fmt.Errorf("符号链接目标不一致: %q != %q", srcTarget, dstTarget)
}
return nil
case srcInfo.Mode().IsRegular():
if srcInfo.Mode().Perm() != dstInfo.Mode().Perm() {
return fmt.Errorf("文件权限不一致: %s (%s) != %s (%s)", src, srcInfo.Mode().Perm(), dst, dstInfo.Mode().Perm())
}
if srcInfo.Size() != dstInfo.Size() {
return fmt.Errorf("文件大小不一致: %s (%d) != %s (%d)", src, srcInfo.Size(), dst, dstInfo.Size())
}
srcDigest, err := skillPathFileDigest(src)
if err != nil {
return err
}
dstDigest, err := skillPathFileDigest(dst)
if err != nil {
return err
}
if srcDigest != dstDigest {
return fmt.Errorf("文件内容摘要不一致: %s != %s", src, dst)
}
return nil
default:
return fmt.Errorf("不支持校验特殊 Skill 路径 %s(mode=%s)", src, srcInfo.Mode())
}
}
func skillPathEntryNames(dir string) ([]string, error) {
entries, err := skillPathReadDir(dir)
if err != nil {
return nil, err
}
names := make([]string, 0, len(entries))
for _, entry := range entries {
names = append(names, entry.Name())
}
sort.Strings(names)
return names, nil
}
func skillPathFileDigest(path string) ([sha256.Size]byte, error) {
f, err := skillPathOpen(path)
if err != nil {
return [sha256.Size]byte{}, err
}
defer f.Close()
hash := sha256.New()
if _, err := skillPathCopyBytes(hash, f); err != nil {
return [sha256.Size]byte{}, err
}
var digest [sha256.Size]byte
copy(digest[:], hash.Sum(nil))
return digest, nil
}