package helpers import ( "context" "errors" "io" "os" "path/filepath" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) // drivePullMockCaller 按工具名分发:list_files 返回可配置的树,download_file 返回预签名 URL。 type drivePullMockCaller struct{ listJSON string } func (m *drivePullMockCaller) CallTool(_ context.Context, _ string, tool string, _ map[string]any) (*edition.ToolResult, error) { text := `{"result":{"downloadUrl":"https://oss.example.com/dl"}}` if tool == "list_files" { text = m.listJSON } return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil } func (m *drivePullMockCaller) Format() string { return "raw" } func (m *drivePullMockCaller) DryRun() bool { return false } func (m *drivePullMockCaller) Fields() string { return "" } func (m *drivePullMockCaller) JQ() string { return "" } // ────────────────────────────────────────────────────────── // isSafeRemoteSegment — 远端名称逐段安全校验(拒绝逃逸成分) // ────────────────────────────────────────────────────────── func TestCrossPlatformCoverageIsSafeRemoteSegment(t *testing.T) { cases := []struct { name string ok bool }{ {"report.pdf", true}, {"报告 2024.xlsx", true}, {"a.b.c", true}, {"", false}, {".", false}, {"..", false}, {"a/b", false}, // 正斜杠分隔符 {`a\b`, false}, // 反斜杠分隔符(Windows) {"../etc", false}, // 相对上跳 {"foo/../bar", false}, {"/abs", false}, // 绝对路径成分 } for _, c := range cases { if got := isSafeRemoteSegment(c.name); got != c.ok { t.Errorf("isSafeRemoteSegment(%q) = %v, want %v", c.name, got, c.ok) } } } // ────────────────────────────────────────────────────────── // resolveLocalTarget — 拼接后必须仍位于本地根目录内 // ────────────────────────────────────────────────────────── func TestCrossPlatformCoverageResolveLocalTarget_withinRoot(t *testing.T) { root := filepath.Clean(t.TempDir()) got, err := resolveLocalTarget(root, "sub/a.txt") if err != nil { t.Fatalf("unexpected error: %v", err) } want := filepath.Join(root, "sub", "a.txt") if got != want { t.Errorf("target = %q, want %q", got, want) } } // 即便 isSafeRemoteSegment 被绕过,逐段拼出的逃逸路径也必须被 resolveLocalTarget 兜住。 func TestCrossPlatformCoverageResolveLocalTarget_escapeRejected(t *testing.T) { root := filepath.Clean(t.TempDir()) for _, rel := range []string{"../evil.txt", "../../etc/passwd", "sub/../../out.txt"} { if _, err := resolveLocalTarget(root, rel); err == nil { t.Errorf("resolveLocalTarget(%q) should reject escaping path", rel) } } } // 根目录内的目录符号链接指向外部时,词法检查会放过(root/sub 仍在 root 下), // 必须靠符号链接解析挡住:sub -> /outside 时 sub/a.txt 应被拒绝。 func TestCrossPlatformCoverageResolveLocalTarget_dirSymlinkEscapeRejected(t *testing.T) { root := filepath.Clean(t.TempDir()) outside := filepath.Clean(t.TempDir()) link := filepath.Join(root, "sub") if err := os.Symlink(outside, link); err != nil { t.Skipf("平台不支持创建符号链接: %v", err) } if _, err := resolveLocalTarget(root, "sub/a.txt"); err == nil { t.Fatal("root/sub -> 外部目录时,sub/a.txt 应被拒绝(防符号链接逃逸)") } // 对照:根目录内的真实子目录不应被误伤。 if err := os.MkdirAll(filepath.Join(root, "real"), 0o755); err != nil { t.Fatal(err) } if _, err := resolveLocalTarget(root, "real/a.txt"); err != nil { t.Errorf("真实子目录不应被拒绝: %v", err) } } // 不存在的本地根目录必须放行(不误判逃逸),由后续 MkdirAll 创建。 func TestCrossPlatformCoverageResolveLocalTarget_nonexistentRootAllowed(t *testing.T) { parent := filepath.Clean(t.TempDir()) absDir := filepath.Join(parent, "not", "created", "yet") // 尚不存在 got, err := resolveLocalTarget(absDir, "sub/a.txt") if err != nil { t.Fatalf("不存在的根目录不应被误判为逃逸: %v", err) } if want := filepath.Join(absDir, "sub", "a.txt"); got != want { t.Errorf("target = %q, want %q", got, want) } } // 回归:--local-folder 指向尚不存在的绝对路径时,pull 应自动创建目录并下载, // 而不是把每个远端文件都误判为符号链接逃逸而 failed。 func TestCrossPlatformCoverageDrivePull_nonexistentLocalRoot(t *testing.T) { parent := t.TempDir() absDir := filepath.Join(parent, "new", "repo") // 尚不存在 caller := &drivePullMockCaller{ listJSON: `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"F1","modifyTime":1000}],"nextToken":""}}`, } testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}}) swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error { return os.WriteFile(dest, []byte("REMOTE"), 0o644) }) root := &cobra.Command{Use: "dws"} root.PersistentFlags().BoolP("yes", "y", false, "") root.PersistentFlags().Bool("dry-run", false, "") root.AddCommand(newDriveCommand()) // pull/push 是 user_required 叶子,非交互环境需 --yes 才能越过统一确认门。 full := []string{"pull", "--local-folder", absDir, "--remote-folder", "ROOT", "--yes"} testseam.Swap(t, &os.Args, append([]string{"dws", "drive"}, full...)) root.SetArgs(append([]string{"drive"}, full...)) // 全部下载成功时 runDrivePull 返回 nil;若误判逃逸则会有 failed 并返回 partial_failure。 if err := root.Execute(); err != nil { t.Fatalf("pull 到不存在的本地根目录不应失败: %v", err) } got, rerr := os.ReadFile(filepath.Join(absDir, "a.txt")) if rerr != nil { t.Fatalf("目标文件应被创建: %v", rerr) } if string(got) != "REMOTE" { t.Errorf("目标内容 = %q, want REMOTE", got) } } // ────────────────────────────────────────────────────────── // detectTargetCollisions — 多个远端条目映射到同一本地目标 // ────────────────────────────────────────────────────────── // 大小写不敏感 FS:A.txt 与 a.txt 落到同一目标 → 两者都判冲突;无关文件不受影响。 func TestCrossPlatformCoverageDetectTargetCollisions_caseInsensitive(t *testing.T) { root := filepath.Clean(t.TempDir()) rels := []string{"A.txt", "a.txt", "b.txt", "sub/C.md", "sub/c.md"} collided := detectTargetCollisions(root, rels, true) for _, r := range []string{"A.txt", "a.txt", "sub/C.md", "sub/c.md"} { if !collided[r] { t.Errorf("%q 应被判为大小写冲突", r) } } if collided["b.txt"] { t.Error("b.txt 无冲突,不应被标记") } } // 大小写敏感 FS:A.txt 与 a.txt 是两个合法文件 → 不应误判冲突。 func TestCrossPlatformCoverageDetectTargetCollisions_caseSensitive(t *testing.T) { root := filepath.Clean(t.TempDir()) rels := []string{"A.txt", "a.txt", "b.txt"} collided := detectTargetCollisions(root, rels, false) if len(collided) != 0 { t.Errorf("大小写敏感 FS 不应有冲突, got %v", collided) } } // 平台名称规范化冲突:同名的 NFC 与 NFD 记法在不敏感 FS 上落到同一目标 → 冲突。 func TestCrossPlatformCoverageDetectTargetCollisions_unicodeNormalization(t *testing.T) { root := filepath.Clean(t.TempDir()) nfc := "café.txt" // café(预组合 é) nfd := "café.txt" // café(e + 组合尖音符) if nfc == nfd { t.Fatal("测试数据应为不同字节序列") } ci := detectTargetCollisions(root, []string{nfc, nfd}, true) if !ci[nfc] || !ci[nfd] { t.Errorf("NFC/NFD 异写在大小写不敏感 FS 上应判冲突, got %v", ci) } // 大小写/规范化敏感 FS 上视为两个不同文件,不冲突。 cs := detectTargetCollisions(root, []string{nfc, nfd}, false) if len(cs) != 0 { t.Errorf("敏感 FS 上 NFC/NFD 不应冲突, got %v", cs) } } // isCaseInsensitiveFS 应能对临时目录给出与实际探针一致的判定(本机自洽)。 func TestCrossPlatformCoverageIsCaseInsensitiveFS_selfConsistent(t *testing.T) { dir := t.TempDir() got := isCaseInsensitiveFS(dir) // 实测:写一个小写名,再用大写名 stat。 lower := filepath.Join(dir, "probe_case_xyz.txt") if err := os.WriteFile(lower, []byte("x"), 0o644); err != nil { t.Fatal(err) } _, err := os.Stat(filepath.Join(dir, "PROBE_CASE_XYZ.TXT")) actualInsensitive := err == nil if got != actualInsensitive { t.Errorf("isCaseInsensitiveFS=%v,但实测大写名可 stat=%v", got, actualInsensitive) } } // ────────────────────────────────────────────────────────── // pullOneFile — 覆盖下载中断不得破坏原文件(原子替换) // ────────────────────────────────────────────────────────── // 下载中途失败时:命令报告 failed,原有本地文件内容必须原封不动,且不留临时残file。 func TestCrossPlatformCoveragePullOneFile_downloadFailureKeepsOriginal(t *testing.T) { root := t.TempDir() target := filepath.Join(root, "a.txt") const original = "ORIGINAL-CONTENT-DO-NOT-LOSE" mustWrite(t, target, original) // download_file 的 MCP 调用走 mock;httpGetFile 注入为“写入部分内容后报错”。 testseam.Swap(t, &deps, &Deps{Caller: &drivePullMockCaller{}, Out: &Formatter{w: io.Discard}}) testseam.Swap(t, &os.Args, []string{"dws", "drive"}) swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error { // 模拟传输中断:先截断/写半截,再返回错误。 _ = os.WriteFile(dest, []byte("HALF"), 0o644) return errors.New("connection reset") }) rf := &remoteFile{RelPath: "a.txt", FileID: "F1"} action, err := pullOneFile(context.Background(), "", rf, target, ifExistsOverwrite) if action != pullActionFailed || err == nil { t.Fatalf("expected failed action with error, got action=%q err=%v", action, err) } // 原文件必须保持原内容。 got, rerr := os.ReadFile(target) if rerr != nil { t.Fatalf("原文件应仍存在: %v", rerr) } if string(got) != original { t.Errorf("原文件被破坏: got %q, want %q", got, original) } // 不应残留临时文件。 entries, _ := os.ReadDir(root) for _, e := range entries { if e.Name() != "a.txt" { t.Errorf("下载失败后残留了临时文件: %s", e.Name()) } } } // 下载成功时:临时文件被原子重命名为目标,内容与远端一致。 func TestCrossPlatformCoveragePullOneFile_downloadSuccessReplacesTarget(t *testing.T) { root := t.TempDir() target := filepath.Join(root, "a.txt") mustWrite(t, target, "OLD") testseam.Swap(t, &deps, &Deps{Caller: &drivePullMockCaller{}, Out: &Formatter{w: io.Discard}}) testseam.Swap(t, &os.Args, []string{"dws", "drive"}) swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error { return os.WriteFile(dest, []byte("NEW-CONTENT"), 0o644) }) rf := &remoteFile{RelPath: "a.txt", FileID: "F1"} action, err := pullOneFile(context.Background(), "", rf, target, ifExistsOverwrite) if err != nil || action != pullActionDownloaded { t.Fatalf("expected downloaded, got action=%q err=%v", action, err) } got, _ := os.ReadFile(target) if string(got) != "NEW-CONTENT" { t.Errorf("目标未被替换为新内容: got %q", got) } entries, _ := os.ReadDir(root) if len(entries) != 1 { t.Errorf("成功后目录应只有目标文件,got %d 项", len(entries)) } } // ────────────────────────────────────────────────────────── // parseDriveDownloadInfo — drive download_file 返回解析 // ────────────────────────────────────────────────────────── // drive 的正常返回:result.downloadUrl 是预签名 URL,无需额外 header。 func TestCrossPlatformCoverageParseDriveDownloadInfo_downloadUrl(t *testing.T) { text := `{"result":{"downloadType":"urlPreSignature","downloadUrl":"https://oss.example.com/f.file?Expires=1&Signature=xyz","fileName":"a.txt"},"success":true}` url, headers, err := parseDriveDownloadInfo(text) if err != nil { t.Fatalf("unexpected error: %v", err) } if url != "https://oss.example.com/f.file?Expires=1&Signature=xyz" { t.Errorf("url = %q", url) } if len(headers) != 0 { t.Errorf("预签名 URL 不应带 header, got %v", headers) } } // 兼容 flat resourceUrl 字段。 func TestCrossPlatformCoverageParseDriveDownloadInfo_resourceUrlFallback(t *testing.T) { text := `{"resourceUrl":"https://flat.example.com/dl"}` url, _, err := parseDriveDownloadInfo(text) if err != nil { t.Fatalf("unexpected error: %v", err) } if url != "https://flat.example.com/dl" { t.Errorf("url = %q", url) } } // 兼容 resourceUrls[].url 数组格式(在 result 包裹下)。 func TestCrossPlatformCoverageParseDriveDownloadInfo_resourceUrlsArrayFallback(t *testing.T) { text := `{"result":{"resourceUrls":[{"url":"https://arr.example.com/dl","headers":{}}]}}` url, _, err := parseDriveDownloadInfo(text) if err != nil { t.Fatalf("unexpected error: %v", err) } if url != "https://arr.example.com/dl" { t.Errorf("url = %q", url) } } func TestCrossPlatformCoverageParseDriveDownloadInfo_missingURL(t *testing.T) { text := `{"result":{"fileName":"a.txt"}}` if _, _, err := parseDriveDownloadInfo(text); err == nil { t.Fatal("expected error when downloadUrl is empty") } } func TestCrossPlatformCoverageParseDriveDownloadInfo_invalidJSON(t *testing.T) { if _, _, err := parseDriveDownloadInfo("not json"); err == nil { t.Fatal("expected error for invalid JSON") } } // ────────────────────────────────────────────────────────── // drivePartialFailure — pull 部分失败错误:exit=1,stderr 仅保留简短说明 // ────────────────────────────────────────────────────────── func TestCrossPlatformCoverageDrivePartialFailure(t *testing.T) { e := &drivePartialFailure{failed: 2} if e.ExitCode() != 1 { t.Errorf("ExitCode() = %d, want 1", e.ExitCode()) } want := "drive pull: 2 file(s) failed" if e.Error() != want || e.RawStderr() != want { t.Errorf("Error()/RawStderr() = %q / %q, want %q", e.Error(), e.RawStderr(), want) } }