diff --git a/internal/helpers/drive_pinned_root_identity_test.go b/internal/helpers/drive_pinned_root_identity_test.go new file mode 100644 index 00000000..1184f9e8 --- /dev/null +++ b/internal/helpers/drive_pinned_root_identity_test.go @@ -0,0 +1,77 @@ +package helpers + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +// Windows 会用已打开的目录句柄锁住目录,把「固定后目录被移走/换成软链」这类 +// TOCTOU 在该平台变成物理不可达:os.Rename 直接 ERROR_SHARING_VIOLATION。 +// 但被测的 fail-closed 分支必须在所有平台都覆盖,所以这里提供与真实替换等价的 +// 身份注入:pinnedPullRoot.verify() 与 verifyParent() 都只通过 pullPathStat / +// pullRootLstat 读取当前身份,让这两个 seam 指向另一个目录即可命中同一分支。 + +// forcePinnedFallbackForTest 打开后,「移走固定目录」的复现手法一律走身份注入降级。 +// 默认 false:Unix 上用真实移动,验证更强。只有 Windows 才会真的走降级分支,所以 +// 下面的注入回归测试会打开它,让那条路径在任何平台都能被验证。 +var forcePinnedFallbackForTest = false + +// swapPinnedRootIdentity 让 absDir 的根身份读数指向另一个目录,使随后的 +// pinnedPullRoot.verify() 判定「本地根目录在同步期间被替换」。 +func swapPinnedRootIdentity(t *testing.T, absDir string) { + t.Helper() + decoy, err := os.Stat(t.TempDir()) + if err != nil { + t.Fatal(err) + } + want := filepath.Clean(absDir) + testseam.Swap(t, &pullPathStat, func(path string) (os.FileInfo, error) { + if filepath.Clean(path) == want { + return decoy, nil + } + return os.Stat(path) + }) +} + +// 降级注入只有 Windows 会真的走到(Unix 的 rename 成功)。这里强制打开开关, +// 确保注入手段本身在任何平台都可回归:不移动目录,但固定根的校验必须随之失败, +// 且原有目录树不得被动过。 +func TestCrossPlatformCoveragePinnedRootFallbackInjectionFailsVerification(t *testing.T) { + testseam.Swap(t, &forcePinnedFallbackForTest, true) + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "a.txt"), "pinned-original") + root := newPinnedPullRootForCoverage(t, dir) + if err := root.verify(); err != nil { + t.Fatalf("baseline verify: %v", err) + } + + if moved := replacePinnedMirrorRoot(t, dir, filepath.Join(t.TempDir(), "moved")); moved { + t.Fatal("降级路径不应真的移动目录") + } + if err := root.verify(); err == nil || !strings.Contains(err.Error(), "本地根目录在同步期间被替换") { + t.Fatalf("身份注入后校验必须失败,got %v", err) + } + if b, err := os.ReadFile(filepath.Join(dir, "a.txt")); err != nil || string(b) != "pinned-original" { + t.Fatalf("降级必须保持原目录树不变: %q err=%v", string(b), err) + } +} + +// swapPinnedParentIdentity 让固定根下名为 rel 的父目录身份读数指向另一个目录, +// 使随后的 verifyParent() 判定「本地目标目录在下载期间被替换」。 +func swapPinnedParentIdentity(t *testing.T, rel string) { + t.Helper() + decoy, err := os.Lstat(t.TempDir()) + if err != nil { + t.Fatal(err) + } + testseam.Swap(t, &pullRootLstat, func(parent *os.Root, name string) (os.FileInfo, error) { + if filepath.ToSlash(filepath.Clean(name)) == rel { + return decoy, nil + } + return parent.Lstat(name) + }) +} diff --git a/internal/helpers/drive_pull_plan_coverage_test.go b/internal/helpers/drive_pull_plan_coverage_test.go index 0c155338..f78c6877 100644 --- a/internal/helpers/drive_pull_plan_coverage_test.go +++ b/internal/helpers/drive_pull_plan_coverage_test.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "path/filepath" + "runtime" "strings" "testing" "time" @@ -350,9 +351,13 @@ func TestCrossPlatformCoverageDrivePullPlanCallers(t *testing.T) { if name == "a.txt" && !moved { moved = true if renameErr := os.Rename(dir, dir+"-pinned"); renameErr != nil { - t.Fatal(renameErr) - } - if mkdirErr := os.Mkdir(dir, 0o755); mkdirErr != nil { + // Windows 在固定目录上持有句柄时会锁住它,移走于该平台物理 + // 不可达;注入等价的根身份变化,命中同一条二次校验分支。 + if runtime.GOOS != "windows" { + t.Fatal(renameErr) + } + swapPinnedRootIdentity(t, dir) + } else if mkdirErr := os.Mkdir(dir, 0o755); mkdirErr != nil { t.Fatal(mkdirErr) } } diff --git a/internal/helpers/drive_pull_toctou_test.go b/internal/helpers/drive_pull_toctou_test.go index d32f4a6b..ebb74df1 100644 --- a/internal/helpers/drive_pull_toctou_test.go +++ b/internal/helpers/drive_pull_toctou_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "runtime" "strings" "testing" @@ -30,8 +31,19 @@ func replacePullAncestorWithOutsideLink(t *testing.T, root, outside string) stri t.Helper() sub := filepath.Join(root, "sub") parked := filepath.Join(root, "sub-pinned") + if forcePinnedFallbackForTest { + swapPinnedParentIdentity(t, "sub") + return sub + } if err := os.Rename(sub, parked); err != nil { - t.Fatal(err) + // Windows 在目录内有已打开的句柄(如 pull 的临时文件)时会锁住该目录, + // 移走再换链于该平台物理不可达。退化为等价的父目录身份注入,命中 + // verifyParent 同一条 fail-closed 分支;原目录未移动,故返回 sub。 + if runtime.GOOS != "windows" { + t.Fatal(err) + } + swapPinnedParentIdentity(t, "sub") + return sub } if err := os.Symlink(outside, sub); err != nil { t.Skipf("平台不支持创建符号链接: %v", err) @@ -39,6 +51,27 @@ func replacePullAncestorWithOutsideLink(t *testing.T, root, outside string) stri return parked } +// replacePullCommandRootWithOutsideLink 把命令根整体移走,并在原名处放置指向根外的 +// 符号链接,复现「固定后命令根被换掉」的场景。Windows 在根目录持有已打开句柄时会 +// 锁住该目录,移走于该平台物理不可达,此时退化为等价的根身份注入。 +func replacePullCommandRootWithOutsideLink(t *testing.T, root, parked, outside string) { + t.Helper() + if forcePinnedFallbackForTest { + swapPinnedRootIdentity(t, root) + return + } + if err := os.Rename(root, parked); err != nil { + if runtime.GOOS != "windows" { + t.Fatal(err) + } + swapPinnedRootIdentity(t, root) + return + } + if err := os.Symlink(outside, root); err != nil { + t.Skipf("symlink unsupported: %v", err) + } +} + func runPullTOCTOUCase(t *testing.T, command string, root string) error { t.Helper() rf := &remoteFile{RelPath: "sub/a.txt", FileID: "F"} @@ -587,12 +620,7 @@ func TestCrossPlatformCoverageDrivePullCommandPinsRootAcrossAllFiles(t *testing. return err } if calls == 1 { - if err := os.Rename(root, parked); err != nil { - t.Fatal(err) - } - if err := os.Symlink(outside, root); err != nil { - t.Skipf("symlink unsupported: %v", err) - } + replacePullCommandRootWithOutsideLink(t, root, parked, outside) } return nil }) @@ -622,12 +650,7 @@ func TestCrossPlatformCoverageDrivePullPlansExistingRootBeforeRemoteRead(t *test if tool != "list_files" { t.Fatalf("root replacement must stop before %s", tool) } - if err := os.Rename(root, parked); err != nil { - t.Fatal(err) - } - if err := os.Symlink(outside, root); err != nil { - t.Skipf("symlink unsupported: %v", err) - } + replacePullCommandRootWithOutsideLink(t, root, parked, outside) return `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A"}]}}`, nil }} getCalls := 0 @@ -727,12 +750,7 @@ func TestCrossPlatformCoverageDriveSyncCommandPinsRootAcrossAllFiles(t *testing. return err } if calls == 1 { - if err := os.Rename(root, parked); err != nil { - t.Fatal(err) - } - if err := os.Symlink(outside, root); err != nil { - t.Skipf("symlink unsupported: %v", err) - } + replacePullCommandRootWithOutsideLink(t, root, parked, outside) } return nil }) diff --git a/internal/helpers/drive_push_toctou_test.go b/internal/helpers/drive_push_toctou_test.go index cfe0ccd3..876333d6 100644 --- a/internal/helpers/drive_push_toctou_test.go +++ b/internal/helpers/drive_push_toctou_test.go @@ -5,21 +5,37 @@ import ( "io" "os" "path/filepath" + "runtime" "strings" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" ) -func replacePinnedMirrorRoot(t *testing.T, root, moved string) { +// replacePinnedMirrorRoot 返回 true 表示原目录被真实移走、原名处已换成另一个目录; +// 返回 false 表示走了 Windows 身份注入降级,原目录仍留在原处。依赖「替代目录内容」 +// 的断言必须按返回值分流。 +func replacePinnedMirrorRoot(t *testing.T, root, moved string) bool { t.Helper() + if forcePinnedFallbackForTest { + swapPinnedRootIdentity(t, root) + return false + } if err := os.Rename(root, moved); err != nil { - t.Fatalf("move pinned root: %v", err) + // Windows 在目录内有已打开的句柄(如上传读取中的源文件)时会锁住该目录, + // 移走固定根于该平台物理不可达。退化为等价的根身份注入,命中同一条 + // fail-closed 分支。 + if runtime.GOOS != "windows" { + t.Fatalf("move pinned root: %v", err) + } + swapPinnedRootIdentity(t, root) + return false } if err := os.Mkdir(root, 0o755); err != nil { t.Fatalf("create replacement root: %v", err) } mustWrite(t, filepath.Join(root, "a.txt"), "outside-replacement") + return true } // 本地扫描完成后、任何 create_folder/get_upload_info/commit_upload 前若命令行根已 @@ -36,10 +52,11 @@ func TestCrossPlatformCoverageDrivePushSyncRejectRootReplacementBeforeRemoteWrit mustWrite(t, filepath.Join(root, "a.txt"), "pinned-original") replaced := false + realMove := false caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, _ int) (string, error) { if tool == "list_files" { if !replaced { - replacePinnedMirrorRoot(t, root, moved) + realMove = replacePinnedMirrorRoot(t, root, moved) replaced = true } return `{"result":{"items":[],"nextToken":""}}`, nil @@ -68,7 +85,12 @@ func TestCrossPlatformCoverageDrivePushSyncRejectRootReplacementBeforeRemoteWrit if putCalls != 0 { t.Fatalf("OSS PUT must not run after root replacement, got %d", putCalls) } - if b, err := os.ReadFile(filepath.Join(root, "a.txt")); err != nil || string(b) != "outside-replacement" { + want := "outside-replacement" + if !realMove { + // 身份注入降级:原目录仍在原处,内容同样不得被镜像流程改写。 + want = "pinned-original" + } + if b, err := os.ReadFile(filepath.Join(root, "a.txt")); err != nil || string(b) != want { t.Fatalf("replacement tree changed: %q err=%v", string(b), err) } }) @@ -100,9 +122,10 @@ func TestCrossPlatformCoverageDrivePushSyncUploadReadsPinnedFileHandle(t *testin var uploaded string putCalls := 0 + realMove := false testseam.Swap(t, &pushPutOpenedFile, func(_ context.Context, _ string, _ map[string]string, file *os.File, _ int64) error { putCalls++ - replacePinnedMirrorRoot(t, root, moved) + realMove = replacePinnedMirrorRoot(t, root, moved) body, err := io.ReadAll(file) if err != nil { return err @@ -125,10 +148,15 @@ func TestCrossPlatformCoverageDrivePushSyncUploadReadsPinnedFileHandle(t *testin if commits := caller.callsFor("commit_upload"); len(commits) != 0 { t.Fatalf("root replacement must prevent commit_upload: %v", commits) } - if b, readErr := os.ReadFile(filepath.Join(root, "a.txt")); readErr != nil || string(b) != "outside-replacement" { - t.Fatalf("replacement file was read or changed: %q err=%v", string(b), readErr) - } - if b, readErr := os.ReadFile(filepath.Join(moved, "a.txt")); readErr != nil || string(b) != "pinned-original" { + if realMove { + if b, readErr := os.ReadFile(filepath.Join(root, "a.txt")); readErr != nil || string(b) != "outside-replacement" { + t.Fatalf("replacement file was read or changed: %q err=%v", string(b), readErr) + } + if b, readErr := os.ReadFile(filepath.Join(moved, "a.txt")); readErr != nil || string(b) != "pinned-original" { + t.Fatalf("pinned original changed: %q err=%v", string(b), readErr) + } + } else if b, readErr := os.ReadFile(filepath.Join(root, "a.txt")); readErr != nil || string(b) != "pinned-original" { + // 身份注入降级:原目录仍在原处,内容同样不得被上传路径改写。 t.Fatalf("pinned original changed: %q err=%v", string(b), readErr) } })