From fc18f8fd04822ef47ca5c3fa4d6cc19da278e85b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 14 Aug 2026 08:50:14 +0800 Subject: [PATCH] fix(skills): preserve read-only backup trees --- internal/upgrade/skill_path_move.go | 56 +- .../upgrade/skill_path_move_errors_test.go | 485 ++++++++++++++++++ 2 files changed, 518 insertions(+), 23 deletions(-) create mode 100644 internal/upgrade/skill_path_move_errors_test.go diff --git a/internal/upgrade/skill_path_move.go b/internal/upgrade/skill_path_move.go index 605f9056..2c28be90 100644 --- a/internal/upgrade/skill_path_move.go +++ b/internal/upgrade/skill_path_move.go @@ -18,6 +18,16 @@ var ( skillPathMkdirAll = os.MkdirAll skillPathMkdirTemp = os.MkdirTemp skillPathChmod = os.Chmod + skillPathLstat = os.Lstat + skillPathMkdir = os.Mkdir + skillPathReadDir = os.ReadDir + 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 @@ -26,7 +36,7 @@ var ( // 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 := os.Lstat(dst); statErr == nil { + if _, statErr := skillPathLstat(dst); statErr == nil { return fmt.Errorf("目标已存在: %s", dst) } else if !os.IsNotExist(statErr) { return fmt.Errorf("检查移动目标失败 %s: %w", dst, statErr) @@ -62,7 +72,7 @@ func moveSkillPathRecoverably(src, dst string) (err error) { if err := skillPathVerify(src, stage); err != nil { return fmt.Errorf("校验跨设备 Skill staging 失败 %s: %w", stage, err) } - stageInfo, err := os.Lstat(stage) + stageInfo, err := skillPathLstat(stage) if err != nil { return fmt.Errorf("检查跨设备 Skill staging 失败 %s: %w", stage, err) } @@ -108,7 +118,7 @@ func removePublishedSkillSource(src string) error { } removeErr := skillPathRemoveAll(src) if removeErr == nil { - if _, statErr := os.Lstat(src); os.IsNotExist(statErr) { + if _, statErr := skillPathLstat(src); os.IsNotExist(statErr) { return nil } else if statErr == nil { removeErr = errors.New("源路径仍存在") @@ -121,7 +131,7 @@ func removePublishedSkillSource(src string) error { func prepareSkillPathTreeRemoval(root string) ([]skillPathDirMode, error) { var modes []skillPathDirMode - err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + err := skillPathWalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } @@ -152,7 +162,7 @@ func restoreSkillPathDirModes(modes []skillPathDirMode) error { var restoreErr error for i := len(modes) - 1; i >= 0; i-- { item := modes[i] - if _, err := os.Lstat(item.path); os.IsNotExist(err) { + if _, err := skillPathLstat(item.path); os.IsNotExist(err) { continue } else if err != nil { restoreErr = errors.Join(restoreErr, err) @@ -169,9 +179,9 @@ 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. - _ = filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + _ = skillPathWalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { if walkErr == nil && entry.IsDir() { - _ = os.Chmod(path, 0o700) + _ = skillPathChmod(path, 0o700) } return nil }) @@ -184,7 +194,7 @@ func RestoreSkillPath(backup, original string) error { } func copySkillPathLexically(src, dst string) error { - info, err := os.Lstat(src) + info, err := skillPathLstat(src) if err != nil { return err } @@ -193,10 +203,10 @@ func copySkillPathLexically(src, dst string) error { // 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 := os.Mkdir(dst, 0o700); err != nil { + if err := skillPathMkdir(dst, 0o700); err != nil { return err } - entries, err := os.ReadDir(src) + entries, err := skillPathReadDir(src) if err != nil { return err } @@ -207,11 +217,11 @@ func copySkillPathLexically(src, dst string) error { } return os.Chmod(dst, info.Mode().Perm()) case info.Mode()&os.ModeSymlink != 0: - target, err := os.Readlink(src) + target, err := skillPathReadlink(src) if err != nil { return err } - return os.Symlink(target, dst) + return skillPathSymlink(target, dst) case info.Mode().IsRegular(): return copyRegularSkillFile(src, dst, info.Mode().Perm()) default: @@ -220,31 +230,31 @@ func copySkillPathLexically(src, dst string) error { } func copyRegularSkillFile(src, dst string, mode os.FileMode) (err error) { - in, err := os.Open(src) + in, err := skillPathOpen(src) if err != nil { return err } defer func() { err = errors.Join(err, in.Close()) }() - out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + 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 := io.Copy(out, in); err != nil { + if _, err := skillPathCopyBytes(out, in); err != nil { return err } - if err := out.Sync(); err != nil { + if err := skillPathSync(out); err != nil { return err } return os.Chmod(dst, mode) } func verifySkillPathCopy(src, dst string) error { - srcInfo, err := os.Lstat(src) + srcInfo, err := skillPathLstat(src) if err != nil { return err } - dstInfo, err := os.Lstat(dst) + dstInfo, err := skillPathLstat(dst) if err != nil { return err } @@ -277,11 +287,11 @@ func verifySkillPathCopy(src, dst string) error { } return nil case srcInfo.Mode()&os.ModeSymlink != 0: - srcTarget, err := os.Readlink(src) + srcTarget, err := skillPathReadlink(src) if err != nil { return err } - dstTarget, err := os.Readlink(dst) + dstTarget, err := skillPathReadlink(dst) if err != nil { return err } @@ -314,7 +324,7 @@ func verifySkillPathCopy(src, dst string) error { } func skillPathEntryNames(dir string) ([]string, error) { - entries, err := os.ReadDir(dir) + entries, err := skillPathReadDir(dir) if err != nil { return nil, err } @@ -327,13 +337,13 @@ func skillPathEntryNames(dir string) ([]string, error) { } func skillPathFileDigest(path string) ([sha256.Size]byte, error) { - f, err := os.Open(path) + f, err := skillPathOpen(path) if err != nil { return [sha256.Size]byte{}, err } defer f.Close() hash := sha256.New() - if _, err := io.Copy(hash, f); err != nil { + if _, err := skillPathCopyBytes(hash, f); err != nil { return [sha256.Size]byte{}, err } var digest [sha256.Size]byte diff --git a/internal/upgrade/skill_path_move_errors_test.go b/internal/upgrade/skill_path_move_errors_test.go new file mode 100644 index 00000000..5c151934 --- /dev/null +++ b/internal/upgrade/skill_path_move_errors_test.go @@ -0,0 +1,485 @@ +package upgrade + +import ( + "errors" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestCrossPlatformCoverageSkillPathMoveSystemErrors(t *testing.T) { + t.Run("destination probe", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + original := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == dst { + return nil, os.ErrPermission + } + return original(path) + }) + if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("destination parent", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + testseam.Swap(t, &skillPathMkdirAll, func(string, os.FileMode) error { return os.ErrPermission }) + if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("staging creation", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + testseam.Swap(t, &skillPathMkdirTemp, func(string, string) (string, error) { return "", os.ErrPermission }) + if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("failed staging cleanup", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + testseam.Swap(t, &skillPathCopy, func(string, string) error { return errors.New("copy failed") }) + testseam.Swap(t, &skillPathRemoveAll, func(string) error { return errors.New("cleanup failed") }) + err := moveSkillPathRecoverably(src, dst) + if err == nil || !strings.Contains(err.Error(), "copy failed") || !strings.Contains(err.Error(), "cleanup failed") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("missing copied staging", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + testseam.Swap(t, &skillPathCopy, func(string, string) error { return nil }) + testseam.Swap(t, &skillPathVerify, func(string, string) error { return nil }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "检查跨设备") { + t.Fatalf("error = %v", err) + } + }) + + for _, tc := range []struct { + name string + path func(src, dst string) string + want string + }{ + {name: "prepare staging mode", path: func(_, dst string) string { return filepath.Join(filepath.Dir(dst), "payload") }, want: "准备跨设备"}, + {name: "restore published mode", path: func(_, dst string) string { return dst }, want: "恢复已发布"}, + } { + t.Run(tc.name, func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + original := skillPathChmod + testseam.Swap(t, &skillPathChmod, func(path string, mode os.FileMode) error { + if tc.name == "prepare staging mode" && strings.HasSuffix(path, string(filepath.Separator)+"payload") { + return errors.New("chmod failed") + } + if tc.name == "restore published mode" && path == dst { + return errors.New("chmod failed") + } + return original(path, mode) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v", err) + } + }) + } + + t.Run("published staging cleanup", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + original := skillPathRemoveAll + testseam.Swap(t, &skillPathRemoveAll, func(path string) error { + if strings.Contains(filepath.Base(path), ".cross-device-") { + return errors.New("cleanup failed") + } + return original(path) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "清理已发布") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("source deletion cannot be confirmed", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + removalAttempted := false + originalLstat := skillPathLstat + originalRemove := skillPathRemoveAll + testseam.Swap(t, &skillPathRemoveAll, func(path string) error { + if path == src { + removalAttempted = true + return nil + } + return originalRemove(path) + }) + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == src && removalAttempted { + return nil, os.ErrPermission + } + return originalLstat(path) + }) + if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) +} + +func TestCrossPlatformCoverageSkillPathPermissionRestorationErrors(t *testing.T) { + t.Run("walk error", func(t *testing.T) { + testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error { + return fn(root, nil, errors.New("walk failed")) + }) + if _, err := prepareSkillPathTreeRemoval("ignored"); err == nil || !strings.Contains(err.Error(), "walk failed") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("entry info error", func(t *testing.T) { + testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error { + return fn(root, skillPathErrorDirEntry{}, nil) + }) + if _, err := prepareSkillPathTreeRemoval("ignored"); err == nil || !strings.Contains(err.Error(), "info failed") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("missing restored directory", func(t *testing.T) { + if err := restoreSkillPathDirModes([]skillPathDirMode{{path: filepath.Join(t.TempDir(), "missing"), mode: 0o555}}); err != nil { + t.Fatal(err) + } + }) + + t.Run("restore probe error", func(t *testing.T) { + testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return nil, os.ErrPermission }) + if err := restoreSkillPathDirModes([]skillPathDirMode{{path: "blocked", mode: 0o555}}); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("restore chmod error", func(t *testing.T) { + path := t.TempDir() + testseam.Swap(t, &skillPathChmod, func(string, os.FileMode) error { return os.ErrPermission }) + if err := restoreSkillPathDirModes([]skillPathDirMode{{path: path, mode: 0o555}}); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) +} + +func TestCrossPlatformCoverageSkillPathLexicalCopyErrors(t *testing.T) { + t.Run("source lstat", func(t *testing.T) { + if err := copySkillPathLexically(filepath.Join(t.TempDir(), "missing"), "unused"); !os.IsNotExist(err) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("destination mkdir", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.Mkdir(src, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte("occupied"), 0o600); err != nil { + t.Fatal(err) + } + if err := copySkillPathLexically(src, dst); err == nil { + t.Fatal("expected mkdir failure") + } + }) + + t.Run("source readdir", func(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "src") + if err := os.Mkdir(src, 0o755); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathReadDir, func(string) ([]os.DirEntry, error) { return nil, os.ErrPermission }) + if err := copySkillPathLexically(src, filepath.Join(root, "dst")); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("nested copy", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.Mkdir(src, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "child"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + original := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if filepath.Base(path) == "child" { + return nil, os.ErrPermission + } + return original(path) + }) + if err := copySkillPathLexically(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("readlink", func(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "link") + if err := os.Symlink("target", src); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "", os.ErrPermission }) + if err := copySkillPathLexically(src, filepath.Join(root, "dst")); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("special path", func(t *testing.T) { + testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { + return skillPathFakeInfo{mode: os.ModeNamedPipe}, nil + }) + if err := copySkillPathLexically("special", "dst"); err == nil || !strings.Contains(err.Error(), "特殊") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("regular open", func(t *testing.T) { + if err := copyRegularSkillFile(filepath.Join(t.TempDir(), "missing"), "dst", 0o600); !os.IsNotExist(err) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("regular destination open", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.WriteFile(src, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte("occupied"), 0o600); err != nil { + t.Fatal(err) + } + if err := copyRegularSkillFile(src, dst, 0o600); err == nil { + t.Fatal("expected exclusive create failure") + } + }) + + for _, tc := range []struct { + name string + swap func(*testing.T) + }{ + {name: "copy", swap: func(t *testing.T) { + testseam.Swap(t, &skillPathCopyBytes, func(io.Writer, io.Reader) (int64, error) { return 0, errors.New("copy failed") }) + }}, + {name: "sync", swap: func(t *testing.T) { + testseam.Swap(t, &skillPathSync, func(*os.File) error { return errors.New("sync failed") }) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.WriteFile(src, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + tc.swap(t) + if err := copyRegularSkillFile(src, dst, 0o600); err == nil || !strings.Contains(err.Error(), tc.name) { + t.Fatalf("error = %v", err) + } + }) + } +} + +func TestCrossPlatformCoverageSkillPathVerificationErrors(t *testing.T) { + root := t.TempDir() + + t.Run("source and destination lstat", func(t *testing.T) { + if err := verifySkillPathCopy(filepath.Join(root, "missing-src"), "dst"); !os.IsNotExist(err) { + t.Fatalf("source error = %v", err) + } + src := filepath.Join(root, "source") + if err := os.WriteFile(src, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, filepath.Join(root, "missing-dst")); !os.IsNotExist(err) { + t.Fatalf("destination error = %v", err) + } + }) + + t.Run("directory and file modes", func(t *testing.T) { + for _, tc := range []struct { + name string + dir bool + }{ + {name: "directory", dir: true}, + {name: "file"}, + } { + t.Run(tc.name, func(t *testing.T) { + src, dst := filepath.Join(t.TempDir(), "src"), filepath.Join(t.TempDir(), "dst") + if tc.dir { + if err := os.Mkdir(src, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(dst, 0o755); err != nil { + t.Fatal(err) + } + } else { + if err := os.WriteFile(src, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "权限") { + t.Fatalf("error = %v", err) + } + }) + } + }) + + t.Run("directory read errors", func(t *testing.T) { + for _, failDest := range []bool{false, true} { + t.Run(map[bool]string{false: "source", true: "destination"}[failDest], func(t *testing.T) { + base := t.TempDir() + src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst") + if err := os.Mkdir(src, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(dst, 0o700); err != nil { + t.Fatal(err) + } + original := skillPathReadDir + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + if (path == dst) == failDest { + return nil, os.ErrPermission + } + return original(path) + }) + if err := verifySkillPathCopy(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + } + }) + + t.Run("nested mismatch", func(t *testing.T) { + base := t.TempDir() + src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst") + if err := os.Mkdir(src, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(dst, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "child"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(dst, "child"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "类型") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("symlink reads", func(t *testing.T) { + base := t.TempDir() + src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst") + if err := os.Symlink("one", src); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + if err := os.Symlink("one", dst); err != nil { + t.Fatal(err) + } + for _, failDest := range []bool{false, true} { + t.Run(map[bool]string{false: "source", true: "destination"}[failDest], func(t *testing.T) { + original := skillPathReadlink + testseam.Swap(t, &skillPathReadlink, func(path string) (string, error) { + if (path == dst) == failDest { + return "", os.ErrPermission + } + return original(path) + }) + if err := verifySkillPathCopy(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + } + }) + + t.Run("file digest opens", func(t *testing.T) { + base := t.TempDir() + src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst") + if err := os.WriteFile(src, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + for _, failDest := range []bool{false, true} { + t.Run(map[bool]string{false: "source", true: "destination"}[failDest], func(t *testing.T) { + original := skillPathOpen + testseam.Swap(t, &skillPathOpen, func(path string) (*os.File, error) { + if (path == dst) == failDest { + return nil, os.ErrPermission + } + return original(path) + }) + if err := verifySkillPathCopy(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + } + }) + + t.Run("special path", func(t *testing.T) { + fake := skillPathFakeInfo{mode: os.ModeNamedPipe} + testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return fake, nil }) + if err := verifySkillPathCopy("src", "dst"); err == nil || !strings.Contains(err.Error(), "特殊") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("entry names", func(t *testing.T) { + if _, err := skillPathEntryNames(filepath.Join(root, "missing-dir")); !os.IsNotExist(err) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("digest open and read", func(t *testing.T) { + if _, err := skillPathFileDigest(filepath.Join(root, "missing-file")); !os.IsNotExist(err) { + t.Fatalf("open error = %v", err) + } + file := filepath.Join(root, "digest-file") + if err := os.WriteFile(file, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathCopyBytes, func(io.Writer, io.Reader) (int64, error) { return 0, errors.New("read failed") }) + if _, err := skillPathFileDigest(file); err == nil || !strings.Contains(err.Error(), "read failed") { + t.Fatalf("read error = %v", err) + } + }) +} + +type skillPathFakeInfo struct { + mode os.FileMode +} + +func (skillPathFakeInfo) Name() string { return "fake" } +func (skillPathFakeInfo) Size() int64 { return 0 } +func (info skillPathFakeInfo) Mode() os.FileMode { return info.mode } +func (skillPathFakeInfo) ModTime() time.Time { return time.Time{} } +func (info skillPathFakeInfo) IsDir() bool { return info.mode.IsDir() } +func (skillPathFakeInfo) Sys() any { return nil } + +type skillPathErrorDirEntry struct{} + +func (skillPathErrorDirEntry) Name() string { return "broken" } +func (skillPathErrorDirEntry) IsDir() bool { return true } +func (skillPathErrorDirEntry) Type() os.FileMode { return os.ModeDir } +func (skillPathErrorDirEntry) Info() (os.FileInfo, error) { return nil, errors.New("info failed") }