From 1d4c51a4d38aa3a77ef860e875b3f61d9629d52d Mon Sep 17 00:00:00 2001 From: chichuan Date: Wed, 12 Aug 2026 11:37:47 +0800 Subject: [PATCH 01/86] feat(drive): add local/Drive folder status, pull, push and sync --- CHANGELOG.md | 14 + internal/helpers/drive.go | 107 ++++ internal/helpers/drive_pull.go | 291 ++++++++++ internal/helpers/drive_pull_test.go | 385 ++++++++++++ internal/helpers/drive_push.go | 406 +++++++++++++ internal/helpers/drive_push_test.go | 284 +++++++++ internal/helpers/drive_status.go | 549 ++++++++++++++++++ internal/helpers/drive_status_test.go | 400 +++++++++++++ internal/helpers/drive_sync.go | 542 +++++++++++++++++ internal/helpers/drive_sync_test.go | 417 +++++++++++++ skills/mono/SKILL.md | 2 +- skills/mono/references/products/drive.md | 223 +++++++ skills/multi/dingtalk-drive/SKILL.md | 2 +- .../multi/dingtalk-drive/references/drive.md | 222 +++++++ 14 files changed, 3842 insertions(+), 2 deletions(-) create mode 100644 internal/helpers/drive_pull.go create mode 100644 internal/helpers/drive_pull_test.go create mode 100644 internal/helpers/drive_push.go create mode 100644 internal/helpers/drive_push_test.go create mode 100644 internal/helpers/drive_status.go create mode 100644 internal/helpers/drive_status_test.go create mode 100644 internal/helpers/drive_sync.go create mode 100644 internal/helpers/drive_sync_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 02d4254e..5077b270 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +### Added + +- **Drive folder synchronization** — adds `dws drive status`, `dws drive pull`, + `dws drive push`, and `dws drive sync` for file-level comparison and transfer + between a local folder and a Drive folder. Differences come from exact MD5 by + default or from modification time with `--quick`; `status` is read-only, `pull` + and `push` are one-directional with `--if-exists skip|smart|overwrite`, and + `sync` is bidirectional with `--on-conflict remote-wins|local-wins|keep-both|ask`. + Only regular files are transferred — online documents and shortcuts are skipped, + neither side deletes extra files, downloads are staged through a temporary file + and committed with an atomic rename, and remote names that would escape + `--local-folder` are reported as failures instead of being written. Every command + prints a structured summary on stdout and exits non-zero when any item fails. + ## [1.0.58-beta.4] - 2026-08-12 ### Added diff --git a/internal/helpers/drive.go b/internal/helpers/drive.go index 752ab25d..f637b39f 100644 --- a/internal/helpers/drive.go +++ b/internal/helpers/drive.go @@ -3200,6 +3200,108 @@ func newDriveCommand() *cobra.Command { RegisterCrossProductAliases(child) } + driveStatusCmd := &cobra.Command{ + Use: "status", + Short: "比较本地文件夹与钉盘文件夹的差异", + Long: `比较本地文件夹与钉盘文件夹的差异:本地取 --local-folder(绝对路径),钉盘取 +--remote-folder(文件夹 dentryUuid)指向的文件夹,按精确 MD5(默认)或快速 +modified_time(--quick)逐文件比对。两侧各自递归遍历,rel_path 相对各自根目录。 + +输出五类差异: + new_local 仅本地存在 + new_remote 仅钉盘存在 + modified 两侧都存在且本次检测判定为已变更 + unchanged 两侧都存在且本次检测判定为未变更 + unknown 两侧都存在,但 exact 模式下远端无可靠 MD5、无法核对内容(不判 unchanged/modified) + +只比对钉盘 type=file 的二进制文件(跳过在线文档与快捷方式);本地只比对常规文件。`, + Example: ` dws drive status --local-folder /abs/path/repo --remote-folder + dws drive status --local-folder /abs/path/repo --remote-folder --space-id xxxx + dws drive status --local-folder /abs/path/repo --remote-folder --quick`, + RunE: runDriveStatus, + } + driveStatusCmd.Flags().String("local-folder", "", "本地文件夹绝对路径 (必填)") + driveStatusCmd.Flags().String("remote-folder", "", "钉盘文件夹 ID (dentryUuid) (必填)") + driveStatusCmd.Flags().String("space-id", "", "钉盘空间 ID,不传则使用「我的文件」(可选)") + driveStatusCmd.Flags().Bool("quick", false, "快速模式:只比较 modified_time,不计算 MD5 (可选)") + + drivePullCmd := &cobra.Command{ + Use: "pull", + Short: "把钉盘文件夹单向镜像到本地(Drive → 本地)", + Long: `递归下载钉盘 --remote-folder 文件夹下所有 type=file 的文件到本地 +--local-folder 对应路径(子目录自动创建),单向、文件级镜像。 + +已存在的本地文件按 --if-exists 处理: + overwrite 默认,总是下载覆盖(Drive 作为权威源) + smart 推荐增量同步:本地 modified_time 已 ≥ 远端时则跳过下载 + skip 本地已存在则保持不动 + +输出 summary(downloaded/skipped/failed)与逐文件 items。 +若有文件下载失败,命令以非零退出码退出(error.type=partial_failure)。`, + Example: ` dws drive pull --local-folder /abs/path/repo --remote-folder + dws drive pull --local-folder /abs/path/repo --remote-folder --if-exists smart + dws drive pull --local-folder /abs/path/repo --remote-folder --space-id xxxx`, + RunE: runDrivePull, + } + drivePullCmd.Flags().String("local-folder", "", "本地文件夹绝对路径 (必填)") + drivePullCmd.Flags().String("remote-folder", "", "钉盘文件夹 ID (dentryUuid) (必填)") + drivePullCmd.Flags().String("space-id", "", "钉盘空间 ID,不传则使用「我的文件」(可选)") + drivePullCmd.Flags().String("if-exists", "overwrite", "本地文件已存在时的策略: overwrite|smart|skip (可选)") + + drivePushCmd := &cobra.Command{ + Use: "push", + Short: "把本地文件夹单向镜像到钉盘(本地 → Drive)", + Long: `递归把本地 --local-folder 下的文件与子目录(含空目录)镜像到钉盘 +--remote-folder 文件夹:缺失的目录按需创建(已存在则复用,不重建),文件按 +--if-exists 处理。文件级镜像——只新增/覆盖,不删除远端多余文件。 + +已存在的远端文件按 --if-exists 处理: + skip 默认,安全:已存在则保持不动,只新增 + smart 增量同步:远端 modified_time 已 ≥ 本地时跳过,否则走覆盖路径 + overwrite 覆盖远端同名文件 + +输出 summary(uploaded/skipped/failed,uploaded 含新建与覆盖)与逐条 items +(含 folder_created)。若有文件失败,命令以非零退出码退出,结构化结果仍在 stdout。`, + Example: ` dws drive push --local-folder /abs/path/repo --remote-folder + dws drive push --local-folder /abs/path/repo --remote-folder --if-exists smart + dws drive push --local-folder /abs/path/repo --remote-folder --if-exists overwrite`, + RunE: runDrivePush, + } + drivePushCmd.Flags().String("local-folder", "", "本地文件夹绝对路径 (必填)") + drivePushCmd.Flags().String("remote-folder", "", "钉盘目标文件夹 ID (dentryUuid) (必填)") + drivePushCmd.Flags().String("space-id", "", "钉盘空间 ID,不传则使用「我的文件」(可选)") + drivePushCmd.Flags().String("if-exists", "skip", "远端文件已存在时的策略: skip|smart|overwrite (可选)") + + driveSyncCmd := &cobra.Command{ + Use: "sync", + Short: "本地文件夹与钉盘文件夹双向同步(本地 ⇄ Drive)", + Long: `把本地 --local-folder 与钉盘 --remote-folder 做文件级双向同步:先按精确 MD5 +(默认)或快速 modified_time(--quick)算出差异,再按方向执行: + new_local 仅本地存在 → 上传到钉盘(缺失的远端目录按需创建) + new_remote 仅钉盘存在 → 下载到本地 + modified 两侧都变更 → 按 --on-conflict 解决 + unchanged 两侧一致 → 不动 + +两侧都变更时的 --on-conflict 策略: + remote-wins 默认,拉取远端覆盖本地 + local-wins 上传本地覆盖远端 + keep-both 本地文件改名保留,再拉取远端到原路径 + ask 交互式逐个询问 + +exact 模式下远端无可靠 MD5、内容无法核对的文件归入 unknown 并跳过(可改用 --quick)。 +文件级同步——只新增/覆盖,不删除任何一侧的多余文件。输出 summary(pulled/pushed/ +skipped/failed)、diff 与逐条 items;有失败则以非零退出码退出,结构化结果仍在 stdout。`, + Example: ` dws drive sync --local-folder /abs/path/repo --remote-folder + dws drive sync --local-folder /abs/path/repo --remote-folder --on-conflict local-wins + dws drive sync --local-folder /abs/path/repo --remote-folder --quick --on-conflict keep-both`, + RunE: runDriveSync, + } + driveSyncCmd.Flags().String("local-folder", "", "本地文件夹绝对路径 (必填)") + driveSyncCmd.Flags().String("remote-folder", "", "钉盘文件夹 ID (dentryUuid) (必填)") + driveSyncCmd.Flags().String("space-id", "", "钉盘空间 ID,不传则使用「我的文件」(可选)") + driveSyncCmd.Flags().String("on-conflict", "remote-wins", "两侧都变更时的策略: remote-wins|local-wins|keep-both|ask (可选)") + driveSyncCmd.Flags().Bool("quick", false, "快速模式:只比较 modified_time,不计算 MD5 (可选)") + driveCmd.AddCommand( driveListCmd, driveListSpacesCmd, @@ -3222,6 +3324,11 @@ func newDriveCommand() *cobra.Command { drivePermissionCmd, drivePublishCmd, recycleCmd, + // 同步命令:status / pull / push / sync + driveStatusCmd, + drivePullCmd, + drivePushCmd, + driveSyncCmd, driveStarCmd, driveCoverCmd, driveRevertCmd, diff --git a/internal/helpers/drive_pull.go b/internal/helpers/drive_pull.go new file mode 100644 index 00000000..f86adac1 --- /dev/null +++ b/internal/helpers/drive_pull.go @@ -0,0 +1,291 @@ +package helpers + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" + "golang.org/x/text/unicode/norm" +) + +// ========================================================== +// drive pull — 把钉盘文件夹镜像到本地(Drive → 本地) +// ========================================================== +// ────────────────────────────────────────────────────────── +// dws drive pull — 把钉盘文件夹单向、文件级镜像到本地(Drive → 本地) +// +// 递归列出 --remote-folder 指向的钉盘文件夹下所有 type=FILE 的文件,逐一下载到 +// --local-folder 对应的相对路径。已存在的本地文件按 --if-exists 决定 +// overwrite / smart / skip。summary.failed > 0 时以非零退出码退出,并在 +// error.type=partial_failure / error.detail 里返回同一份 summary + items。 +// ────────────────────────────────────────────────────────── + +// --if-exists 的三种策略。 +const ( + ifExistsOverwrite = "overwrite" // 默认:总是下载覆盖(Drive 为权威源) + ifExistsSmart = "smart" // 推荐增量:本地 mtime 已 ≥ 远端 modified_time 则跳过 + ifExistsSkip = "skip" // 本地已存在则保持不动 +) + +// pull 动作分类。 +const ( + pullActionDownloaded = "downloaded" + pullActionSkipped = "skipped" + pullActionFailed = "failed" +) + +// drivePullItem 是输出 items[] 中每个文件的明细。 +type drivePullItem struct { + RelPath string `json:"rel_path"` + Action string `json:"action"` + Error string `json:"error,omitempty"` +} + +// drivePullSummary 是各动作的计数汇总。 +type drivePullSummary struct { + Downloaded int `json:"downloaded"` + Skipped int `json:"skipped"` + Failed int `json:"failed"` +} + +// drivePullResult 是 pull 命令的输出 schema。 +type drivePullResult struct { + Summary drivePullSummary `json:"summary"` + Items []drivePullItem `json:"items"` +} + +// drivePartialFailure 在 summary.failed > 0 时返回:以 exit=1 退出,并把 +// {"error":{"type":"partial_failure","detail":{summary,items}}} 原样透传出去。 +// 结构上满足 core 的 RawStderrError 与 ExitCoder 接口(无需 import internal 包)。 +type drivePartialFailure struct{ raw string } + +func (e *drivePartialFailure) Error() string { return e.raw } +func (e *drivePartialFailure) RawStderr() string { return e.raw } +func (e *drivePartialFailure) ExitCode() int { return 1 } + +// pathCollisionKey 把本地目标路径归一化成「目标文件系统下的等价键」,用于探测多个 +// 远端条目是否会落到同一个本地文件。caseInsensitive 为真时(Windows / 默认 macOS) +// 折叠大小写并做 Unicode NFC 规范化,从而把 A.txt/a.txt、NFC/NFD 记法视为同一目标; +// 大小写敏感文件系统(如 Linux ext4)则按精确路径区分,避免误判合法的异名文件。 +func pathCollisionKey(target string, caseInsensitive bool) string { + p := filepath.Clean(target) + if caseInsensitive { + p = norm.NFC.String(strings.ToLower(p)) + } + return p +} + +// isCaseInsensitiveFS 探测 dir 所在文件系统是否大小写不敏感:在 dir 下创建一个随机 +// 小写名探针文件,再用其大写名 stat;命中同一文件即为不敏感。dir 必须已存在。 +// 探测失败时回退到平台默认(Windows / macOS 视为不敏感,其它敏感)。 +func isCaseInsensitiveFS(dir string) bool { + platformDefault := runtime.GOOS == "windows" || runtime.GOOS == "darwin" + f, err := os.CreateTemp(dir, "dws-caseprobe-*") + if err != nil { + return platformDefault + } + name := f.Name() + _ = f.Close() + defer os.Remove(name) + base := filepath.Base(name) + upper := strings.ToUpper(base) + if upper == base { + return platformDefault // 名称无大小写差异,无法据此判定 + } + _, statErr := os.Stat(filepath.Join(dir, upper)) + return statErr == nil +} + +// detectTargetCollisions 按 caseInsensitive 指定的等价规则,找出会映射到同一本地目标 +// 的多个远端 rel_path,返回被判定冲突的 rel_path 集合(其中每个都不应落盘)。 +func detectTargetCollisions(absDir string, rels []string, caseInsensitive bool) map[string]bool { + groups := make(map[string][]string, len(rels)) + for _, rel := range rels { + target := filepath.Join(absDir, filepath.FromSlash(rel)) + key := pathCollisionKey(target, caseInsensitive) + groups[key] = append(groups[key], rel) + } + collided := make(map[string]bool) + for _, g := range groups { + if len(g) > 1 { + for _, r := range g { + collided[r] = true + } + } + } + return collided +} + +func runDrivePull(cmd *cobra.Command, _ []string) error { + if err := validateRequiredFlags(cmd, "local-folder", "remote-folder"); err != nil { + return err + } + localDir := mustGetFlag(cmd, "local-folder") + remoteDirID := mustGetFlag(cmd, "remote-folder") + // space-id 可选:不传则由 fetchRemoteDriveTree 使用「我的文件」对应的空间。 + spaceID := mustGetFlag(cmd, "space-id") + + ifExists, _ := cmd.Flags().GetString("if-exists") + if ifExists == "" { + ifExists = ifExistsOverwrite + } + switch ifExists { + case ifExistsOverwrite, ifExistsSmart, ifExistsSkip: + default: + return fmt.Errorf("--if-exists 取值非法: %s(可选 overwrite|smart|skip)", ifExists) + } + + absDir, err := validateLocalDirAbs(localDir) + if err != nil { + return err + } + + ctx := cmd.Context() + // 复用 status 的远端遍历:按 parentId 递归拿到所有 type=FILE 的文件(key 为 rel_path)。 + remote, err := fetchRemoteDriveTree(ctx, spaceID, remoteDirID, false) + if err != nil { + return err + } + + // 稳定顺序输出(rel_path 升序)。 + relPaths := make([]string, 0, len(remote)) + for rel := range remote { + relPaths = append(relPaths, rel) + } + sort.Strings(relPaths) + + // 落盘前先按目标文件系统的路径等价规则做一次全局冲突检查:大小写不敏感 FS 上 + // A.txt 与 a.txt(或 NFC/NFD 异写)会落到同一本地文件,默认 overwrite 会顺序覆盖、 + // 却把两项都报 downloaded 而静默丢文件。冲突的条目一律标记 failed、都不写入。 + // 探针需要根目录存在,这里按 pull「自动创建目标根」的语义先建好。 + if err := os.MkdirAll(absDir, 0o755); err != nil { + return fmt.Errorf("创建本地目录失败: %w", err) + } + collided := detectTargetCollisions(absDir, relPaths, isCaseInsensitiveFS(absDir)) + + res := drivePullResult{Items: make([]drivePullItem, 0, len(relPaths))} + for _, rel := range relPaths { + rf := remote[rel] + // 多个远端条目映射到同一本地目标 → 全部 failed,不写入任何一个,避免静默覆盖丢文件。 + if collided[rel] { + res.Summary.Failed++ + res.Items = append(res.Items, drivePullItem{ + RelPath: rel, Action: pullActionFailed, + Error: "多个远端条目在当前文件系统上映射到同一本地路径(大小写/规范化冲突),已跳过以避免覆盖丢失", + }) + continue + } + // 二次确认下载目标仍在本地根目录内,逃逸路径记为 failed 而非落盘。 + localPath, terr := resolveLocalTarget(absDir, rel) + if terr != nil { + res.Summary.Failed++ + res.Items = append(res.Items, drivePullItem{RelPath: rel, Action: pullActionFailed, Error: terr.Error()}) + continue + } + + action, perr := pullOneFile(ctx, spaceID, rf, localPath, ifExists) + item := drivePullItem{RelPath: rel, Action: action} + switch action { + case pullActionDownloaded: + res.Summary.Downloaded++ + case pullActionSkipped: + res.Summary.Skipped++ + case pullActionFailed: + res.Summary.Failed++ + if perr != nil { + item.Error = perr.Error() + } + } + res.Items = append(res.Items, item) + } + + if res.Summary.Failed > 0 { + // partial_failure:非零退出码,summary+items 一并放进 error.detail。 + payload := map[string]any{ + "error": map[string]any{ + "type": "partial_failure", + "detail": res, + }, + } + b, _ := json.Marshal(payload) + return &drivePartialFailure{raw: string(b)} + } + return deps.Out.PrintJSON(res) +} + +// pullOneFile 处理单个远端文件:按 --if-exists 决定是否跳过,否则下载到 localPath。 +// 返回动作分类(downloaded / skipped / failed)及失败时的 error。 +func pullOneFile(ctx context.Context, spaceID string, rf *remoteFile, localPath, ifExists string) (string, error) { + // 本地已存在常规文件时,按策略判断是否跳过下载。 + if fi, statErr := os.Stat(localPath); statErr == nil && fi.Mode().IsRegular() { + switch ifExists { + case ifExistsSkip: + return pullActionSkipped, nil + case ifExistsSmart: + // 远端时间可信且本地 mtime 已 ≥ 远端 → 视为已对齐,跳过; + // 时间缺失/非法时不盲跳,退回继续下载。 + if rf.ModifiedTimeValid && fi.ModTime().UnixMilli() >= rf.ModifiedTime { + return pullActionSkipped, nil + } + case ifExistsOverwrite: + // 总是下载覆盖。 + } + } + + // download_file → 拿到带签名的下载 URL 与请求头,再 HTTP GET 落盘。 + args := map[string]any{"fileId": rf.FileID} + if spaceID != "" { + args["spaceId"] = spaceID + } + text, err := callMCPToolReturnText(ctx, "download_file", args) + if err != nil { + return pullActionFailed, err + } + resourceURL, headers, err := parseDriveDownloadInfo(text) + if err != nil { + return pullActionFailed, err + } + + dir := filepath.Dir(localPath) + if err := os.MkdirAll(dir, 0o755); err != nil { + return pullActionFailed, fmt.Errorf("创建本地目录失败: %w", err) + } + + // 先下载到同目录下的临时文件,完整落盘后再原子 rename 覆盖目标:任何中途失败 + // (网络中断、超时、写盘错误)都不会截断或破坏已存在的原文件。同目录保证 + // rename 在同一文件系统内、可原子替换;rename 会替换目标符号链接本身而非跟随它。 + tmp, err := os.CreateTemp(dir, ".dws-pull-*") + if err != nil { + return pullActionFailed, fmt.Errorf("创建临时文件失败: %w", err) + } + tmpPath := tmp.Name() + _ = tmp.Close() // httpGetFile 会自行 os.Create 覆盖该临时文件 + // 除非成功 rename,否则始终清理临时文件,不留半成品。 + committed := false + defer func() { + if !committed { + _ = os.Remove(tmpPath) + } + }() + + if err := httpGetFile(ctx, resourceURL, headers, tmpPath); err != nil { + return pullActionFailed, err + } + // 对齐 mtime 放在 rename 之前,保证替换后的目标文件时间戳即为远端时间。 + if rf.ModifiedTimeValid { + t := time.UnixMilli(rf.ModifiedTime) + _ = os.Chtimes(tmpPath, t, t) + } + if err := os.Rename(tmpPath, localPath); err != nil { + return pullActionFailed, fmt.Errorf("替换目标文件失败: %w", err) + } + committed = true + return pullActionDownloaded, nil +} diff --git a/internal/helpers/drive_pull_test.go b/internal/helpers/drive_pull_test.go new file mode 100644 index 00000000..c913b9cb --- /dev/null +++ b/internal/helpers/drive_pull_test.go @@ -0,0 +1,385 @@ +package helpers + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "testing" + + "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 TestIsSafeRemoteSegment(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 TestResolveLocalTarget_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 TestResolveLocalTarget_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 TestResolveLocalTarget_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 TestResolveLocalTarget_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 TestDrivePull_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":""}}`, + } + + prevDeps := deps + prevArgs := os.Args + deps = &Deps{Caller: caller, Out: &Formatter{w: io.Discard}} + SetHTTPGetFile(func(_ context.Context, _ string, _ map[string]string, dest string) error { + return os.WriteFile(dest, []byte("REMOTE"), 0o644) + }) + t.Cleanup(func() { + deps = prevDeps + os.Args = prevArgs + SetHTTPGetFile(nil) + }) + + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().BoolP("yes", "y", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.AddCommand(newDriveCommand()) + + full := []string{"pull", "--local-folder", absDir, "--remote-folder", "ROOT"} + 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 TestDetectTargetCollisions_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 TestDetectTargetCollisions_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 TestDetectTargetCollisions_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 TestIsCaseInsensitiveFS_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 TestPullOneFile_downloadFailureKeepsOriginal(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "a.txt") + const original = "ORIGINAL-CONTENT-DO-NOT-LOSE" + mustWrite(t, target, original) + + prevDeps := deps + prevArgs := os.Args + // download_file 的 MCP 调用走 mock;httpGetFile 注入为“写入部分内容后报错”。 + deps = &Deps{Caller: &drivePullMockCaller{}, Out: &Formatter{w: io.Discard}} + os.Args = []string{"dws", "drive"} + SetHTTPGetFile(func(_ context.Context, _ string, _ map[string]string, dest string) error { + // 模拟传输中断:先截断/写半截,再返回错误。 + _ = os.WriteFile(dest, []byte("HALF"), 0o644) + return errors.New("connection reset") + }) + t.Cleanup(func() { + deps = prevDeps + os.Args = prevArgs + SetHTTPGetFile(nil) + }) + + 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 TestPullOneFile_downloadSuccessReplacesTarget(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "a.txt") + mustWrite(t, target, "OLD") + + prevDeps := deps + prevArgs := os.Args + deps = &Deps{Caller: &drivePullMockCaller{}, Out: &Formatter{w: io.Discard}} + os.Args = []string{"dws", "drive"} + SetHTTPGetFile(func(_ context.Context, _ string, _ map[string]string, dest string) error { + return os.WriteFile(dest, []byte("NEW-CONTENT"), 0o644) + }) + t.Cleanup(func() { + deps = prevDeps + os.Args = prevArgs + SetHTTPGetFile(nil) + }) + + 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 TestParseDriveDownloadInfo_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 TestParseDriveDownloadInfo_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 TestParseDriveDownloadInfo_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 TestParseDriveDownloadInfo_missingURL(t *testing.T) { + text := `{"result":{"fileName":"a.txt"}}` + if _, _, err := parseDriveDownloadInfo(text); err == nil { + t.Fatal("expected error when downloadUrl is empty") + } +} + +func TestParseDriveDownloadInfo_invalidJSON(t *testing.T) { + if _, _, err := parseDriveDownloadInfo("not json"); err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +// ────────────────────────────────────────────────────────── +// drivePartialFailure — pull 部分失败错误:exit=1,JSON 原样透传 +// ────────────────────────────────────────────────────────── + +func TestDrivePartialFailure(t *testing.T) { + raw := `{"error":{"type":"partial_failure","detail":{}}}` + e := &drivePartialFailure{raw: raw} + if e.ExitCode() != 1 { + t.Errorf("ExitCode() = %d, want 1", e.ExitCode()) + } + if e.Error() != raw || e.RawStderr() != raw { + t.Errorf("Error()/RawStderr() should return the raw JSON verbatim") + } +} diff --git a/internal/helpers/drive_push.go b/internal/helpers/drive_push.go new file mode 100644 index 00000000..52eb2827 --- /dev/null +++ b/internal/helpers/drive_push.go @@ -0,0 +1,406 @@ +package helpers + +import ( + "context" + "encoding/json" + "fmt" + "io/fs" + "log/slog" + "path/filepath" + "sort" + "strings" + + "github.com/spf13/cobra" +) + +// ========================================================== +// drive push — 把本地文件夹镜像到钉盘(本地 → Drive) +// ========================================================== +// ────────────────────────────────────────────────────────── +// dws drive push — 把本地目录单向、文件级镜像到钉盘(本地 → Drive) +// +// 递归遍历 --local-folder 下所有常规文件与子目录(含空目录),按相对路径在 +// --remote-folder 指向的钉盘文件夹里新建/覆盖/跳过。已存在的远端目录复用其 +// fileId、不重建;缺失的目录按需 create_folder。文件按 --if-exists 决定 +// skip / smart / overwrite。summary.failed > 0 时以非零退出码退出(结构化 +// summary + items 仍打印在 stdout 上)。 +// ────────────────────────────────────────────────────────── + +// push 动作分类。 +const ( + pushActionUploaded = "uploaded" // 新建上传 + pushActionOverwritten = "overwritten" // 覆盖已存在的远端文件 + pushActionSkipped = "skipped" // 按 --if-exists 跳过 + pushActionFolderCreated = "folder_created" // 新建远端目录(不计入 uploaded) + pushActionFailed = "failed" +) + +// localPushFile 描述一个待推送的本地常规文件。 +type localPushFile struct { + RelPath string + AbsPath string + ModTimeMillis int64 + Size int64 +} + +// drivePushItem 是输出 items[] 中每个条目的明细。 +type drivePushItem struct { + RelPath string `json:"rel_path"` + Action string `json:"action"` + SizeBytes *int64 `json:"size_bytes,omitempty"` + Error string `json:"error,omitempty"` +} + +// drivePushSummary 是各动作的计数汇总。uploaded 同时统计新建与覆盖。 +type drivePushSummary struct { + Uploaded int `json:"uploaded"` + Skipped int `json:"skipped"` + Failed int `json:"failed"` + Aborted bool `json:"aborted"` +} + +type drivePushResult struct { + Summary drivePushSummary `json:"summary"` + Items []drivePushItem `json:"items"` +} + +// drivePushFailure 在 summary.failed > 0 时返回:结构化结果已打印到 stdout, +// 这里只负责以 exit=1 退出并向 stderr 输出一行简短说明。 +type drivePushFailure struct{ failed int } + +func (e *drivePushFailure) Error() string { + return fmt.Sprintf("drive push: %d file(s) failed", e.failed) +} +func (e *drivePushFailure) RawStderr() string { return e.Error() } +func (e *drivePushFailure) ExitCode() int { return 1 } + +func runDrivePush(cmd *cobra.Command, _ []string) error { + if err := validateRequiredFlags(cmd, "local-folder", "remote-folder"); err != nil { + return err + } + localDir := mustGetFlag(cmd, "local-folder") + remoteDirID := mustGetFlag(cmd, "remote-folder") + spaceID := mustGetFlag(cmd, "space-id") + + // push 默认 skip(安全,只新增不覆盖)。 + ifExists, _ := cmd.Flags().GetString("if-exists") + if ifExists == "" { + ifExists = ifExistsSkip + } + switch ifExists { + case ifExistsSkip, ifExistsSmart, ifExistsOverwrite: + default: + return fmt.Errorf("--if-exists 取值非法: %s(可选 skip|smart|overwrite)", ifExists) + } + + absDir, err := validateLocalDirAbs(localDir) + if err != nil { + return err + } + + ctx := cmd.Context() + // 远端现状:已存在的文件(用于 --if-exists 判断)与目录(rel_path → fileId,用于复用/定位父目录)。 + remoteFiles, remoteFolders, err := fetchRemoteTreeForPush(ctx, spaceID, remoteDirID) + if err != nil { + return err + } + + localDirs, localFiles, err := walkLocalForPush(absDir) + if err != nil { + return fmt.Errorf("扫描本地目录失败: %w", err) + } + + res := drivePushResult{Items: make([]drivePushItem, 0, len(localDirs)+len(localFiles))} + + // 第一阶段:按需创建远端目录(浅层在前,保证父目录先于子目录存在)。 + for _, dir := range localDirs { + if _, ok := remoteFolders[dir]; ok { + continue // 远端已存在,复用 fileId,不留痕 + } + parentRel, name := splitRel(dir) + parentID, ok := remoteFolders[parentRel] + if !ok || parentID == "" { + res.Summary.Failed++ + res.Items = append(res.Items, drivePushItem{RelPath: dir, Action: pushActionFailed, Error: "父目录未能创建"}) + continue + } + fid, cerr := pushCreateFolder(ctx, spaceID, parentID, name) + if cerr != nil || fid == "" { + res.Summary.Failed++ + msg := "create_folder 未返回 fileId" + if cerr != nil { + msg = cerr.Error() + } + res.Items = append(res.Items, drivePushItem{RelPath: dir, Action: pushActionFailed, Error: msg}) + continue + } + remoteFolders[dir] = fid + res.Items = append(res.Items, drivePushItem{RelPath: dir, Action: pushActionFolderCreated}) + } + + // 第二阶段:上传/覆盖/跳过文件。 + for i := range localFiles { + lf := localFiles[i] + size := lf.Size + parentRel, name := splitRel(lf.RelPath) + parentID, ok := remoteFolders[parentRel] + if !ok || parentID == "" { + res.Summary.Failed++ + res.Items = append(res.Items, drivePushItem{RelPath: lf.RelPath, Action: pushActionFailed, SizeBytes: &size, Error: "父目录未能创建"}) + continue + } + + rf, exists := remoteFiles[lf.RelPath] + action := pushActionUploaded + overwriteID := "" + if exists { + switch ifExists { + case ifExistsSkip: + res.Summary.Skipped++ + res.Items = append(res.Items, drivePushItem{RelPath: lf.RelPath, Action: pushActionSkipped, SizeBytes: &size}) + continue + case ifExistsSmart: + // 远端时间可信且已 ≥ 本地 → 跳过;否则走覆盖路径。 + if rf.ModifiedTimeValid && rf.ModifiedTime >= lf.ModTimeMillis { + res.Summary.Skipped++ + res.Items = append(res.Items, drivePushItem{RelPath: lf.RelPath, Action: pushActionSkipped, SizeBytes: &size}) + continue + } + action = pushActionOverwritten + case ifExistsOverwrite: + action = pushActionOverwritten + } + // 覆盖分支必须走覆盖上传(传 overwriteFileId、不传 parentId), + // 否则会在同目录新建重名副本而非原地覆盖。 + if action == pushActionOverwritten { + overwriteID = rf.FileID + } + } + + if err := pushUploadFile(ctx, spaceID, parentID, overwriteID, name, lf.AbsPath, size); err != nil { + res.Summary.Failed++ + res.Items = append(res.Items, drivePushItem{RelPath: lf.RelPath, Action: pushActionFailed, SizeBytes: &size, Error: err.Error()}) + continue + } + res.Summary.Uploaded++ // uploaded 同时统计新建与覆盖 + res.Items = append(res.Items, drivePushItem{RelPath: lf.RelPath, Action: action, SizeBytes: &size}) + } + + // 结构化结果始终打印到 stdout;有失败则额外以非零退出码退出。 + if perr := deps.Out.PrintJSON(res); perr != nil { + return perr + } + if res.Summary.Failed > 0 { + return &drivePushFailure{failed: res.Summary.Failed} + } + return nil +} + +// fetchRemoteTreeForPush 递归拉取 rootID 下的远端现状:files(rel_path → *remoteFile, +// 用于 --if-exists 判断)与 folders(rel_path → fileId,rel_path "" 即 rootID 本身)。 +func fetchRemoteTreeForPush(ctx context.Context, spaceID, rootID string) (map[string]*remoteFile, map[string]string, error) { + files := make(map[string]*remoteFile) + folders := map[string]string{"": rootID} + if err := walkRemoteForPush(ctx, spaceID, rootID, "", files, folders, 0); err != nil { + return nil, nil, err + } + return files, folders, nil +} + +func walkRemoteForPush(ctx context.Context, spaceID, parentID, relBase string, files map[string]*remoteFile, folders map[string]string, depth int) error { + if depth > remoteMaxDepth { + return fmt.Errorf("drive 目录层级超过 %d 层,疑似循环引用,已中止", remoteMaxDepth) + } + nextToken := "" + for { + args := map[string]any{"maxResults": float64(driveListPageSize)} + if spaceID != "" { + args["spaceId"] = spaceID + } + if parentID != "" { + args["parentId"] = parentID + } + if nextToken != "" { + args["nextToken"] = nextToken + } + + text, err := callMCPToolReturnText(ctx, "list_files", args) + if err != nil { + return err + } + items, token, err := parseDriveList(text) + if err != nil { + return err + } + + for _, it := range items { + name := it.name() + if name == "" { + continue + } + // 与 walkRemoteDir 一致:非规范名称跳过,避免逃逸性 rel_path 进入索引。 + if !isSafeRemoteSegment(name) { + slog.Warn("overlay: 跳过含非法路径成分的远端条目", "name", name, "relBase", relBase) + continue + } + childRel := name + if relBase != "" { + childRel = relBase + "/" + name + } + if it.isFolder() { + folders[childRel] = it.id() + if err := walkRemoteForPush(ctx, spaceID, it.id(), childRel, files, folders, depth+1); err != nil { + return err + } + continue + } + if !it.isFile() { + continue + } + modMillis, modValid := it.modifiedMillis() + files[childRel] = &remoteFile{ + RelPath: childRel, + FileID: it.id(), + Hash: it.hash(), + ModifiedTime: modMillis, + ModifiedTimeValid: modValid, + } + } + + if token == "" || token == nextToken { + break + } + nextToken = token + } + return nil +} + +// walkLocalForPush 遍历本地根目录,返回所有子目录 rel_path(不含根本身,浅层在前) +// 与所有常规文件。dirs 升序排序保证父目录先于子目录被创建。 +func walkLocalForPush(root string) ([]string, []localPushFile, error) { + var dirs []string + var files []localPushFile + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, rerr := filepath.Rel(root, path) + if rerr != nil { + return rerr + } + if rel == "." { + return nil // 根目录本身不处理 + } + relSlash := filepath.ToSlash(rel) + if d.IsDir() { + dirs = append(dirs, relSlash) + return nil + } + info, ierr := d.Info() + if ierr != nil { + return ierr + } + // 只推送常规文件;符号链接、设备文件等忽略。 + if !info.Mode().IsRegular() { + return nil + } + files = append(files, localPushFile{ + RelPath: relSlash, + AbsPath: path, + ModTimeMillis: info.ModTime().UnixMilli(), + Size: info.Size(), + }) + return nil + }) + if err != nil { + return nil, nil, err + } + // 字典序即浅层在前("a" < "a/b" < "a/b/c"),父目录先于子目录。 + sort.Strings(dirs) + return dirs, files, nil +} + +// pushCreateFolder 在 parentID 下创建名为 name 的文件夹,返回新目录的 fileId。 +func pushCreateFolder(ctx context.Context, spaceID, parentID, name string) (string, error) { + args := map[string]any{"name": name} + if spaceID != "" { + args["spaceId"] = spaceID + } + if parentID != "" { + args["parentId"] = parentID + } + text, err := callMCPToolReturnText(ctx, "create_folder", args) + if err != nil { + return "", err + } + return parseNodeID(text), nil +} + +// pushUploadFile 走 get_upload_info → OSS PUT → commit_upload 三步,把本地文件 +// 上传到 parentID 目录下。复用 drive upload 的凭证解析与 HTTP PUT。 +// +// overwriteFileID 非空时走覆盖流程(与 uploadToDrive 一致):get_upload_info 与 +// commit_upload 两个阶段都传 overwriteFileId、都不传 parentId(服务端据此设置 +// conflictStrategy=OVERWRITE,原地覆盖而非在同目录新建重名副本)。 +func pushUploadFile(ctx context.Context, spaceID, parentID, overwriteFileID, fileName, filePath string, fileSize int64) error { + step1 := map[string]any{"fileName": fileName, "fileSize": float64(fileSize)} + if spaceID != "" { + step1["spaceId"] = spaceID + } + if overwriteFileID != "" { + step1["overwriteFileId"] = overwriteFileID + } else if parentID != "" { + step1["parentId"] = parentID + } + text, err := callMCPToolReturnText(ctx, "get_upload_info", step1) + if err != nil { + return err + } + resourceURL, uploadID, ossHeaders, err := parseDriveUploadInfo(text) + if err != nil { + return err + } + if err := httpPutFile(ctx, resourceURL, ossHeaders, filePath, fileSize); err != nil { + return err + } + commit := map[string]any{"fileName": fileName, "fileSize": float64(fileSize), "uploadId": uploadID} + if spaceID != "" { + commit["spaceId"] = spaceID + } + if overwriteFileID != "" { + commit["overwriteFileId"] = overwriteFileID + } else if parentID != "" { + commit["parentId"] = parentID + } + _, err = callMCPToolReturnText(ctx, "commit_upload", commit) + return err +} + +// parseNodeID 从 create_folder / commit 等返回里抽出节点 fileId(带 fallback)。 +func parseNodeID(text string) string { + var data map[string]any + if err := json.Unmarshal([]byte(text), &data); err != nil { + return "" + } + if r, ok := data["result"].(map[string]any); ok { + data = r + } + for _, k := range []string{"fileId", "dentryUuid", "dentryId", "id", "nodeId"} { + if s, ok := data[k].(string); ok && s != "" { + return s + } + } + return "" +} + +// splitRel 把 rel_path 拆成父路径与末段名:"a/b/c" → ("a/b","c"),"c" → ("","c")。 +func splitRel(rel string) (string, string) { + if i := strings.LastIndex(rel, "/"); i >= 0 { + return rel[:i], rel[i+1:] + } + return "", rel +} + +// checkFileNotDingTalkDoc 通过 get_file_info 检查文件类型, +// 若为钉钉在线文档 (adoc/axls/amind/adraw) 则返回错误,提示使用对应服务命令。 +// 探测失败(如无效 fileId)不阻断流程,让后续 MCP 工具自行报错。 diff --git a/internal/helpers/drive_push_test.go b/internal/helpers/drive_push_test.go new file mode 100644 index 00000000..c0d92ad1 --- /dev/null +++ b/internal/helpers/drive_push_test.go @@ -0,0 +1,284 @@ +package helpers + +import ( + "context" + "io" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +// ────────────────────────────────────────────────────────── +// splitRel — rel_path 拆父路径 / 末段名 +// ────────────────────────────────────────────────────────── + +func TestSplitRel(t *testing.T) { + cases := []struct { + in string + parent, base string + }{ + {"a/b/c", "a/b", "c"}, + {"a/b", "a", "b"}, + {"c", "", "c"}, + {"", "", ""}, + } + for _, c := range cases { + p, b := splitRel(c.in) + if p != c.parent || b != c.base { + t.Errorf("splitRel(%q) = (%q,%q), want (%q,%q)", c.in, p, b, c.parent, c.base) + } + } +} + +// ────────────────────────────────────────────────────────── +// parseNodeID — create_folder / commit 返回里抽 fileId +// ────────────────────────────────────────────────────────── + +func TestParseNodeID(t *testing.T) { + cases := []struct { + name string + text string + want string + }{ + {"result.fileId", `{"result":{"fileId":"F1","name":"x"},"success":true}`, "F1"}, + {"top-level fileId", `{"fileId":"F2"}`, "F2"}, + {"dentryUuid fallback", `{"result":{"dentryUuid":"D1"}}`, "D1"}, + {"dentryId fallback", `{"result":{"dentryId":"D2"}}`, "D2"}, + {"missing", `{"result":{"name":"x"}}`, ""}, + {"invalid json", `not json`, ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := parseNodeID(c.text); got != c.want { + t.Errorf("parseNodeID = %q, want %q", got, c.want) + } + }) + } +} + +// ────────────────────────────────────────────────────────── +// walkLocalForPush — 本地遍历(含空目录,父目录先于子目录) +// ────────────────────────────────────────────────────────── + +func TestWalkLocalForPush(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "a.txt"), "hello") // 5 bytes + mustWrite(t, filepath.Join(root, "sub1", "b.txt"), "worl") // 4 bytes, 建出 sub1 + if err := os.MkdirAll(filepath.Join(root, "sub1", "sub2"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, "empty"), 0o755); err != nil { + t.Fatal(err) + } + + dirs, files, err := walkLocalForPush(root) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // 目录:含空目录,不含根本身,且父目录先于子目录(字典序即可保证)。 + wantDirs := []string{"empty", "sub1", "sub1/sub2"} + if !sort.StringsAreSorted(dirs) { + t.Errorf("dirs should be sorted (parent before child): %v", dirs) + } + if !equalStrings(dirs, wantDirs) { + t.Errorf("dirs = %v, want %v", dirs, wantDirs) + } + + // 文件:rel_path 用 /,记录 size 与 mtime。 + byRel := map[string]localPushFile{} + for _, f := range files { + byRel[f.RelPath] = f + } + if len(byRel) != 2 { + t.Fatalf("expected 2 files, got %d: %v", len(byRel), files) + } + a, ok := byRel["a.txt"] + if !ok || a.Size != 5 { + t.Errorf("a.txt size = %d, want 5 (present=%v)", a.Size, ok) + } + if a.AbsPath == "" || a.ModTimeMillis == 0 { + t.Error("file should record AbsPath and mtime") + } + b, ok := byRel["sub1/b.txt"] + if !ok || b.Size != 4 { + t.Errorf("sub1/b.txt size = %d, want 4 (present=%v)", b.Size, ok) + } +} + +// ────────────────────────────────────────────────────────── +// drivePushFailure — push 部分失败错误:exit=1 +// ────────────────────────────────────────────────────────── + +func TestDrivePushFailure(t *testing.T) { + e := &drivePushFailure{failed: 3} + if e.ExitCode() != 1 { + t.Errorf("ExitCode() = %d, want 1", e.ExitCode()) + } + if e.Error() != e.RawStderr() { + t.Error("Error() 与 RawStderr() 应一致") + } + if !strings.Contains(e.Error(), "3") { + t.Errorf("Error() 应包含失败数, got %q", e.Error()) + } +} + +// ────────────────────────────────────────────────────────── +// push 覆盖流程 — get_upload_info / commit_upload 两阶段的 MCP 参数 +// ────────────────────────────────────────────────────────── + +// driveMCPCall 记录一次 MCP 工具调用。 +type driveMCPCall struct { + toolName string + args map[string]any +} + +// driveSyncMockCaller 按工具名分发返回,并记录全部调用,供断言 MCP 参数。 +type driveSyncMockCaller struct { + calls []driveMCPCall + listJSON string // list_files 返回体 + dryRun bool // --dry-run:只算差异,不执行任何写操作 +} + +func (m *driveSyncMockCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) { + m.calls = append(m.calls, driveMCPCall{toolName: toolName, args: args}) + var text string + switch toolName { + case "list_files": + text = m.listJSON + case "get_upload_info": + // 返回可被 parseDriveUploadInfo 解析的凭证。 + text = `{"result":{"resourceUrls":[{"url":"https://oss.example.com/put","headers":{}}],"uploadId":"U1"}}` + case "download_file": + // 返回可被 parseDriveDownloadInfo 解析的预签名下载链接。 + text = `{"result":{"downloadUrl":"https://oss.example.com/get","headers":{}}}` + default: // commit_upload 等 + text = `{"result":{"fileId":"NEW_FID"},"success":true}` + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil +} + +func (m *driveSyncMockCaller) Format() string { return "raw" } +func (m *driveSyncMockCaller) DryRun() bool { return m.dryRun } +func (m *driveSyncMockCaller) Fields() string { return "" } +func (m *driveSyncMockCaller) JQ() string { return "" } + +func (m *driveSyncMockCaller) callsFor(tool string) []driveMCPCall { + var out []driveMCPCall + for _, c := range m.calls { + if c.toolName == tool { + out = append(out, c) + } + } + return out +} + +// runDrivePushTest 用 mock caller 执行 `drive push`,返回捕获的调用。 +// 注入 httpPutFile 为 no-op,避免真实 OSS 上传。 +func runDrivePushTest(t *testing.T, caller *driveSyncMockCaller, localDir string, args ...string) error { + t.Helper() + + prevDeps := deps + prevOSArgs := os.Args + SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil }) + + deps = &Deps{Caller: caller, Out: &Formatter{w: io.Discard}} + + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().BoolP("yes", "y", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.AddCommand(newDriveCommand()) + + full := append([]string{"push", "--local-folder", localDir, "--remote-folder", "ROOT"}, args...) + os.Args = append([]string{"dws", "drive"}, full...) + root.SetArgs(append([]string{"drive"}, full...)) + + t.Cleanup(func() { + deps = prevDeps + os.Args = prevOSArgs + SetHTTPPutFile(nil) + }) + + return root.Execute() +} + +// 覆盖分支:get_upload_info 与 commit_upload 两阶段都必须传 overwriteFileId、都不传 parentId。 +func TestDrivePush_overwriteUsesOverwriteFileId(t *testing.T) { + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "a.txt"), "new-content") + + caller := &driveSyncMockCaller{ + // 远端根目录下已存在同名 a.txt,fileId=REMOTE_FID。 + listJSON: `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"REMOTE_FID","md5":"x","modifyTime":1000}],"nextToken":""}}`, + } + if err := runDrivePushTest(t, caller, dir, "--if-exists", "overwrite"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + up := caller.callsFor("get_upload_info") + if len(up) != 1 { + t.Fatalf("expected 1 get_upload_info call, got %d", len(up)) + } + if got := up[0].args["overwriteFileId"]; got != "REMOTE_FID" { + t.Errorf("get_upload_info overwriteFileId = %v, want REMOTE_FID", got) + } + if _, ok := up[0].args["parentId"]; ok { + t.Error("覆盖时 get_upload_info 不应传 parentId") + } + + commit := caller.callsFor("commit_upload") + if len(commit) != 1 { + t.Fatalf("expected 1 commit_upload call, got %d", len(commit)) + } + if got := commit[0].args["overwriteFileId"]; got != "REMOTE_FID" { + t.Errorf("commit_upload overwriteFileId = %v, want REMOTE_FID", got) + } + if _, ok := commit[0].args["parentId"]; ok { + t.Error("覆盖时 commit_upload 不应传 parentId") + } +} + +// 新建分支:远端不存在同名文件时走普通上传——两阶段都传 parentId、都不传 overwriteFileId。 +func TestDrivePush_newUploadUsesParentId(t *testing.T) { + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "b.txt"), "brand-new") + + caller := &driveSyncMockCaller{ + listJSON: `{"result":{"items":[],"nextToken":""}}`, // 远端空 + } + if err := runDrivePushTest(t, caller, dir, "--if-exists", "overwrite"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + up := caller.callsFor("get_upload_info") + if len(up) != 1 { + t.Fatalf("expected 1 get_upload_info call, got %d", len(up)) + } + if _, ok := up[0].args["overwriteFileId"]; ok { + t.Error("新建上传不应传 overwriteFileId") + } + if got := up[0].args["parentId"]; got != "ROOT" { + t.Errorf("get_upload_info parentId = %v, want ROOT", got) + } + commit := caller.callsFor("commit_upload") + if len(commit) != 1 || commit[0].args["parentId"] != "ROOT" { + t.Errorf("commit_upload should carry parentId=ROOT, got %v", commit) + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/helpers/drive_status.go b/internal/helpers/drive_status.go new file mode 100644 index 00000000..7e43f3db --- /dev/null +++ b/internal/helpers/drive_status.go @@ -0,0 +1,549 @@ +package helpers + +import ( + "context" + "crypto/md5" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "io/fs" + "log/slog" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/spf13/cobra" +) + +// ========================================================== +// drive status — 比较本地文件夹与钉盘文件夹的差异 +// ========================================================== +// ────────────────────────────────────────────────────────── +// dws drive status — 比较本地文件夹与钉盘文件夹的差异 +// +// 只读命令:本地取 --local-folder(绝对路径),钉盘取 --remote-folder +// (文件夹 dentryUuid)指向的文件夹,按精确 MD5(默认)或快速 modified_time +// (--quick)逐文件比对,输出 new_local / new_remote / modified / unchanged / +// unknown 五类差异(exact 模式远端无可靠 md5 的文件归入 unknown)。两侧各自递归 +// 遍历,rel_path 相对各自根目录。 +// +// 后端交互(按 parentId 递归拉取 list_files、只保留 type=file 的二进制文件、并为 +// 每个条目填充 hash 或者 modified_time),见 fetchRemoteDriveTree。 +// ────────────────────────────────────────────────────────── + +// driveStatusEntry 是输出 schema 中每个差异项的形态:{"rel_path": "..."}。 +type driveStatusEntry struct { + RelPath string `json:"rel_path"` +} + +// driveStatusResult 是 status 命令成功时的输出 schema。 +// 五个切片都初始化为非 nil,保证 JSON 序列化为 [] 而不是 null。 +// +// unknown:exact 模式下双端都存在、但远端未返回可靠 md5 的文件——此时无法核对 +// 内容,既不能判 unchanged(会误报未变更),也不宜硬判 modified(会误报变更), +// 如实归入 unknown,让降级模式在输出里显式可见。quick 模式不会产生 unknown。 +type driveStatusResult struct { + Detection string `json:"detection"` + NewLocal []driveStatusEntry `json:"new_local"` + NewRemote []driveStatusEntry `json:"new_remote"` + Modified []driveStatusEntry `json:"modified"` + Unchanged []driveStatusEntry `json:"unchanged"` + Unknown []driveStatusEntry `json:"unknown"` +} + +// localFile 描述一个本地常规文件。 +type localFile struct { + RelPath string + AbsPath string // 绝对路径,用于按需计算 MD5 + Hash string // MD5(base64);仅在双端都存在且非 quick 模式时按需计算 + Size int64 // 本地文件字节数 + ModTimeMillis int64 // 本地 mtime,Unix 毫秒 +} + +// remoteFile 描述一个云端 type=file 的二进制文件。 +// 由 fetchRemoteDriveTree 填充。 +type remoteFile struct { + RelPath string + FileID string // dentryUuid;pull 下载时作为 download_file 的 fileId + Hash string // 远端 md5(base64,exact 模式用;为空时该文件归入 unknown) + Size int64 // 远端文件字节数 + ModifiedTime int64 // 远端 modified_time,Unix 毫秒(quick / smart 模式比较用) + ModifiedTimeValid bool // 远端时间戳是否可信;不可信时走保守路径 +} + +// 每次 list_files 请求的分页大小。后端约束 maxResults 必须在 1..50 之间。 +const driveListPageSize = 50 + +// remoteMaxDepth 是递归遍历子文件夹的最大层级,防止云端目录出现环形引用时死循环。 +const remoteMaxDepth = 64 + +// fetchRemoteDriveTree 递归拉取云端文件夹 parentID 下的文件树,返回所有 type=FILE 的 +// 二进制文件索引(key 为 rel_path)。spaceID 为空时省略 spaceId 参数,由 MCP Server +// 默认到「我的文件」(与其它 drive 命令一致);parentID 为待比对的云端根文件夹 dentryUuid。 +// +// 实现要点: +// - 通过 drive 的 list_files MCP 工具,按 parentId(文件夹 dentryUuid)逐层遍历; +// - 单个目录文件过多时按 nextToken 分批拉取(maxResults ≤ 50); +// - 遇到子文件夹用其 dentryUuid 作为下一层的 parentId 递归进入; +// - rel_path 由各层文件夹名逐层拼接得到(相对 parentID 根),与本地相对路径对齐; +// - 只纳入明确 type=FILE 的条目,其余类型(在线文档、快捷方式等)一律跳过。 +func fetchRemoteDriveTree(ctx context.Context, spaceID, parentID string, quick bool) (map[string]*remoteFile, error) { + out := make(map[string]*remoteFile) + if err := walkRemoteDir(ctx, spaceID, parentID, "", out, 0); err != nil { + return nil, err + } + _ = quick // hash 与 modified_time 都会被填充,比对模式由 compareTrees 决定 + return out, nil +} + +// walkRemoteDir 遍历 parentID 指向的文件夹,把其中的二进制文件写入 out(key 为 rel_path, +// 同名后者覆盖),并递归进入子文件夹。relBase 是当前文件夹相对遍历起点的路径前缀, +// 用于拼接子项的 rel_path。 +func walkRemoteDir(ctx context.Context, spaceID, parentID, relBase string, out map[string]*remoteFile, depth int) error { + if depth > remoteMaxDepth { + return fmt.Errorf("drive 目录层级超过 %d 层,疑似循环引用,已中止", remoteMaxDepth) + } + + nextToken := "" + for { + args := map[string]any{"maxResults": float64(driveListPageSize)} + // space-id 为空则省略,交给 MCP Server 默认「我的文件」。 + if spaceID != "" { + args["spaceId"] = spaceID + } + // 按 parentId(文件夹 dentryUuid)导航;空则由 MCP Server 默认列空间根目录。 + if parentID != "" { + args["parentId"] = parentID + } + if nextToken != "" { + args["nextToken"] = nextToken + } + + text, err := callMCPToolReturnText(ctx, "list_files", args) + if err != nil { + return err + } + + items, token, err := parseDriveList(text) + if err != nil { + return err + } + + for _, it := range items { + name := it.name() + if name == "" { + continue + } + // 远端名称可能含 ..、分隔符或卷标,拼进 rel_path 会逃逸本地根目录: + // 非规范名称直接跳过(同名文件夹也不递归),不纳入索引。 + if !isSafeRemoteSegment(name) { + slog.Warn("overlay: 跳过含非法路径成分的远端条目", "name", name, "relBase", relBase) + continue + } + // rel_path 由文件夹名逐层拼接,统一用 / 分隔,与本地相对路径对齐。 + childRel := name + if relBase != "" { + childRel = relBase + "/" + name + } + + if it.isFolder() { + // 用子文件夹的 dentryUuid 作为下一层 parentId 递归进入。 + if err := walkRemoteDir(ctx, spaceID, it.id(), childRel, out, depth+1); err != nil { + return err + } + continue + } + // 只纳入明确 type=FILE 的条目;在线文档、快捷方式等其它类型一律跳过。 + if !it.isFile() { + continue + } + + modMillis, modValid := it.modifiedMillis() + out[childRel] = &remoteFile{ + RelPath: childRel, + FileID: it.id(), + Hash: it.hash(), + Size: it.size(), + ModifiedTime: modMillis, + ModifiedTimeValid: modValid, + } + } + + // 无下一页,或 token 未推进(防御性,避免服务端异常导致死循环)→ 结束。 + if token == "" || token == nextToken { + break + } + nextToken = token + } + return nil +} + +// driveItem 是 list_files 返回里的一个 dentry 条目。 +// 后端字段命名存在多种历史写法,这里用带 fallback 的访问器做防御式解析。 +type driveItem map[string]any + +func (d driveItem) str(keys ...string) string { + for _, k := range keys { + if s, ok := d[k].(string); ok && s != "" { + return s + } + } + return "" +} + +func (d driveItem) name() string { return d.str("name") } +func (d driveItem) id() string { return d.str("fileId") } +func (d driveItem) typ() string { return d.str("type") } +func (d driveItem) hash() string { return d.str("md5") } +func (d driveItem) path() string { return d.str("path") } + +// size 返回远端文件字节数(fileSize 字段),用于 exact 无 md5 时的降级比对。 +func (d driveItem) size() int64 { + switch v := d["fileSize"].(type) { + case float64: + return int64(v) + case json.Number: + if n, err := v.Int64(); err == nil { + return n + } + } + return 0 +} + +func (d driveItem) isFolder() bool { + return strings.ToUpper(d.typ()) == "FOLDER" +} + +func (d driveItem) isFile() bool { + return strings.ToUpper(d.typ()) == "FILE" +} + +func (d driveItem) modifiedMillis() (int64, bool) { + for _, k := range []string{"modifiedTime", "modifyTime", "modified_time", "gmtModified", "lastModifiedTime", "updateTime"} { + if v, ok := d[k]; ok { + if ms, ok := toMillis(v); ok { + return ms, true + } + } + } + return 0, false +} + +// parseDriveList 解析 list_files 返回文本,抽出 items 列表与 nextToken。 +// 兼容两种 result 形态: +// - {"result":{"items":[...],"nextToken":"..."}} +// - {"result":[...]}(result 直接是条目数组,无分页) +func parseDriveList(text string) ([]driveItem, string, error) { + var envelope struct { + Result json.RawMessage `json:"result"` + } + if err := json.Unmarshal([]byte(text), &envelope); err != nil { + return nil, "", fmt.Errorf("解析 list_files 返回失败: %w", err) + } + if len(envelope.Result) == 0 { + return nil, "", nil // 无 result → 空列表 + } + + toItems := func(ms []map[string]any) []driveItem { + items := make([]driveItem, 0, len(ms)) + for _, m := range ms { + items = append(items, driveItem(m)) + } + return items + } + + // 形态一:result 是对象 {items, nextToken} + var obj struct { + Items []map[string]any `json:"items"` + NextToken string `json:"nextToken"` + } + if err := json.Unmarshal(envelope.Result, &obj); err == nil { + return toItems(obj.Items), obj.NextToken, nil + } + + // 形态二:result 直接是条目数组 + var arr []map[string]any + if err := json.Unmarshal(envelope.Result, &arr); err == nil { + return toItems(arr), "", nil + } + + return nil, "", fmt.Errorf("解析 list_files 返回失败: result 既不是 {items:[]} 也不是数组") +} + +// validateLocalDirAbs 校验 --local-folder 必须是绝对路径,返回其清理后的绝对路径。 +func validateLocalDirAbs(localDir string) (string, error) { + if localDir == "" { + return "", fmt.Errorf("flag --local-folder is required") + } + if !filepath.IsAbs(localDir) { + return "", fmt.Errorf("--local-folder 必须是绝对路径: %s", localDir) + } + return filepath.Clean(localDir), nil +} + +// isSafeRemoteSegment 校验单个远端条目名称是否可安全用作一层相对路径段。 +// 拒绝空串、"."、".."、含卷标(如 Windows 的 "C:")以及任意平台的路径分隔符 +// (/ 与 \)——这些成分拼进 rel_path 后可能让下载目标逃逸出本地根目录。 +func isSafeRemoteSegment(name string) bool { + if name == "" || name == "." || name == ".." { + return false + } + // 任意平台分隔符都不允许出现在单层名称里。 + if strings.ContainsAny(name, `/\`) { + return false + } + // Windows 卷标("C:"、"C:foo")等含盘符的名称一律拒绝。 + if filepath.VolumeName(name) != "" { + return false + } + // Clean 后若发生变化,说明名称非规范(含隐藏的 . / .. / 分隔符)。 + if filepath.Clean(name) != name { + return false + } + return true +} + +// resolveLocalTarget 把远端 rel_path 拼到本地根目录 absDir 下,并确认结果仍位于 +// absDir 内。除词法检查(filepath.Rel)外,还解析已存在路径组件的符号链接,防止 +// root 内的目录软链(如 root/sub -> /outside)把落盘点指到根目录外。逃逸时返回错误。 +func resolveLocalTarget(absDir, rel string) (string, error) { + target := filepath.Join(absDir, filepath.FromSlash(rel)) + back, err := filepath.Rel(absDir, target) + if err != nil || back == ".." || strings.HasPrefix(back, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("远端路径 %q 逃逸出本地根目录", rel) + } + if err := verifyNoSymlinkEscape(absDir, target); err != nil { + return "", err + } + return target, nil +} + +// verifyNoSymlinkEscape 挡住「absDir 之内的目录软链指向外部」的逃逸:从 target 的父 +// 目录向上、在**不越过 absDir 边界**的前提下,找到最深的已存在祖先,解析其真实路径 +// (EvalSymlinks 展开整条软链),确认仍位于 absDir 真实路径之内。 +// +// absDir 尚不存在时直接放行:其下不可能有已存在的组件可供逃逸,后续 MkdirAll 只会 +// 创建真实目录、绝不新建软链——这也是 pull 自动创建目标根目录的正常路径。target 尚不 +// 存在的末段同理无软链可谈,故只需校验 [absDir, target] 之间的现有组件。 +func verifyNoSymlinkEscape(absDir, target string) error { + absDir = filepath.Clean(absDir) + realRoot, err := filepath.EvalSymlinks(absDir) + if err != nil { + // 根目录尚不存在(无软链可解析)→ 放行,交给 MkdirAll 按需创建真实目录。 + return nil + } + + ancestor := filepath.Dir(target) + for { + // 越过 absDir 边界(祖先跑到根目录之上)就停止:根目录之上不属于逃逸判定范围。 + rel, relErr := filepath.Rel(absDir, ancestor) + if relErr != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return nil + } + if _, statErr := os.Lstat(ancestor); statErr == nil { + real, evErr := filepath.EvalSymlinks(ancestor) + if evErr != nil { + return fmt.Errorf("解析路径 %q 失败: %w", ancestor, evErr) + } + back, bErr := filepath.Rel(realRoot, real) + if bErr != nil || back == ".." || strings.HasPrefix(back, ".."+string(filepath.Separator)) { + return fmt.Errorf("路径 %q 经符号链接逃逸出本地根目录", target) + } + return nil + } + parent := filepath.Dir(ancestor) + if parent == ancestor { + return nil + } + ancestor = parent + } +} + +// md5File 计算文件内容的 MD5,并按 base64 编码返回(与钉盘 list_files 返回的 +// md5 字段编码一致,便于直接字符串比较)。 +func md5File(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + + h := md5.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(h.Sum(nil)), nil +} + +// walkLocalTree 递归遍历本地目录,只收集常规文件(跳过符号链接、设备文件等)。 +// rel_path 统一使用 / 作为分隔符,相对于 root。 +// +// 这里只记录路径与 mtime,不计算 MD5:本地 hash 采用惰性策略,仅在文件双端 +// 都存在且非 quick 模式时,由 compareTrees 按需计算(见 judgeFileMatch)。 +func walkLocalTree(root string) (map[string]*localFile, error) { + files := make(map[string]*localFile) + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + info, err := d.Info() + if err != nil { + return err + } + // 只比对常规文件;符号链接、设备文件等被忽略。 + if !info.Mode().IsRegular() { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + relSlash := filepath.ToSlash(rel) + + files[relSlash] = &localFile{ + RelPath: relSlash, + AbsPath: path, + Size: info.Size(), + ModTimeMillis: info.ModTime().UnixMilli(), + } + return nil + }) + if err != nil { + return nil, err + } + return files, nil +} + +// 双端都存在文件的比对结论。 +const ( + matchUnchanged = "unchanged" + matchModified = "modified" + matchUnknown = "unknown" // exact 模式且远端无可靠 md5,内容无法核对 +) + +// judgeFileMatch 判定双端都存在的文件本次的比对结论:unchanged / modified / unknown。 +// +// exact 模式下,本地 MD5 在此处按需计算——只有走到这里的文件才是双端都存在的, +// local-only 文件不会触发 hash 计算。若远端未返回 md5(list_files 目前通常如此), +// 无法核对内容,则返回 unknown:不判 unchanged(避免把不同内容误报为未变更), +// 也不硬判 modified(避免把相同内容误报为已变更),让降级如实反映在输出中。 +func judgeFileMatch(lf *localFile, rf *remoteFile, quick bool) (string, error) { + if quick { + // quick:远端时间戳必须可信且与本地 mtime 相等才算 unchanged; + // 远端时间缺失/非法时走保守路径记为 modified。 + if rf.ModifiedTimeValid && lf.ModTimeMillis == rf.ModifiedTime { + return matchUnchanged, nil + } + return matchModified, nil + } + + // exact:远端缺少可靠 md5 时无法核对内容 → unknown(既不判 unchanged 也不判 modified)。 + if rf.Hash == "" { + return matchUnknown, nil + } + + // exact:按需计算本地 MD5(base64),再与远端 md5(钉盘也返回 base64)直接比较。 + if lf.Hash == "" { + h, err := md5File(lf.AbsPath) + if err != nil { + return "", fmt.Errorf("计算 %s 的 MD5 失败: %w", lf.RelPath, err) + } + lf.Hash = h + } + if lf.Hash == rf.Hash { + return matchUnchanged, nil + } + return matchModified, nil +} + +// compareTrees 比较本地与云端文件树,产出五类差异。 +func compareTrees(local map[string]*localFile, remote map[string]*remoteFile, detection string, quick bool) (driveStatusResult, error) { + res := driveStatusResult{ + Detection: detection, + NewLocal: []driveStatusEntry{}, + NewRemote: []driveStatusEntry{}, + Modified: []driveStatusEntry{}, + Unchanged: []driveStatusEntry{}, + Unknown: []driveStatusEntry{}, + } + + for rel, lf := range local { + rf, ok := remote[rel] + if !ok { + res.NewLocal = append(res.NewLocal, driveStatusEntry{RelPath: rel}) + continue + } + verdict, err := judgeFileMatch(lf, rf, quick) + if err != nil { + return res, err + } + switch verdict { + case matchUnchanged: + res.Unchanged = append(res.Unchanged, driveStatusEntry{RelPath: rel}) + case matchUnknown: + res.Unknown = append(res.Unknown, driveStatusEntry{RelPath: rel}) + default: + res.Modified = append(res.Modified, driveStatusEntry{RelPath: rel}) + } + } + for rel := range remote { + if _, ok := local[rel]; !ok { + res.NewRemote = append(res.NewRemote, driveStatusEntry{RelPath: rel}) + } + } + + sortEntries(res.NewLocal) + sortEntries(res.NewRemote) + sortEntries(res.Modified) + sortEntries(res.Unchanged) + sortEntries(res.Unknown) + return res, nil +} + +func sortEntries(entries []driveStatusEntry) { + sort.Slice(entries, func(i, j int) bool { return entries[i].RelPath < entries[j].RelPath }) +} + +func runDriveStatus(cmd *cobra.Command, _ []string) error { + if err := validateRequiredFlags(cmd, "local-folder", "remote-folder"); err != nil { + return err + } + localDir := mustGetFlag(cmd, "local-folder") + // remote-folder 必传:待比对的云端根文件夹 dentryUuid。 + remoteDirID := mustGetFlag(cmd, "remote-folder") + // space-id 可选:不传则由 fetchRemoteDriveTree 使用「我的文件」对应的空间。 + spaceID := mustGetFlag(cmd, "space-id") + + quick, _ := cmd.Flags().GetBool("quick") + detection := "exact" + if quick { + detection = "quick" + } + + absDir, err := validateLocalDirAbs(localDir) + if err != nil { + return err + } + + local, err := walkLocalTree(absDir) + if err != nil { + return fmt.Errorf("扫描本地目录失败: %w", err) + } + + ctx := cmd.Context() + remoteIndex, err := fetchRemoteDriveTree(ctx, spaceID, remoteDirID, quick) + if err != nil { + return err + } + + result, err := compareTrees(local, remoteIndex, detection, quick) + if err != nil { + return err + } + return deps.Out.PrintJSON(result) +} diff --git a/internal/helpers/drive_status_test.go b/internal/helpers/drive_status_test.go new file mode 100644 index 00000000..8bf5e8d7 --- /dev/null +++ b/internal/helpers/drive_status_test.go @@ -0,0 +1,400 @@ +package helpers + +import ( + "os" + "path/filepath" + "testing" +) + +// ────────────────────────────────────────────────────────── +// validateLocalDirAbs — 本地路径校验(必须为绝对路径) +// ────────────────────────────────────────────────────────── + +func TestValidateLocalDirAbs_empty(t *testing.T) { + if _, err := validateLocalDirAbs(""); err == nil { + t.Fatal("expected error for empty --local-folder") + } +} + +func TestValidateLocalDirAbs_relative(t *testing.T) { + if _, err := validateLocalDirAbs(filepath.Join(".", "repo")); err == nil { + t.Fatal("expected error for relative path") + } +} + +func TestValidateLocalDirAbs_ok(t *testing.T) { + abs := filepath.Join(t.TempDir(), "repo") + got, err := validateLocalDirAbs(abs) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != filepath.Clean(abs) { + t.Errorf("got %q, want %q", got, filepath.Clean(abs)) + } +} + +// ────────────────────────────────────────────────────────── +// walkLocalTree — 本地遍历 +// ────────────────────────────────────────────────────────── + +func TestWalkLocalTree_collectsRegularFiles(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "a.txt"), "hello") + mustWrite(t, filepath.Join(root, "sub", "b.txt"), "world") + + files, err := walkLocalTree(root) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(files) != 2 { + t.Fatalf("expected 2 files, got %d: %v", len(files), files) + } + // rel_path 用 / 分隔(跨平台一致) + if _, ok := files["sub/b.txt"]; !ok { + t.Errorf("expected key sub/b.txt, got keys %v", keys(files)) + } + // walk 阶段不计算 hash(惰性策略),但记录 AbsPath 与 mtime + if files["a.txt"].Hash != "" { + t.Error("walkLocalTree should not compute MD5 eagerly") + } + if files["a.txt"].AbsPath == "" { + t.Error("walkLocalTree should record AbsPath") + } + if files["a.txt"].ModTimeMillis == 0 { + t.Error("walkLocalTree should record mtime") + } +} + +// ────────────────────────────────────────────────────────── +// compareTrees — 四类差异分类 +// ────────────────────────────────────────────────────────── + +func TestCompareTrees_exact(t *testing.T) { + local := map[string]*localFile{ + "same.txt": {RelPath: "same.txt", Hash: "h1"}, + "diff.txt": {RelPath: "diff.txt", Hash: "local"}, + "onlyme.txt": {RelPath: "onlyme.txt", Hash: "h3"}, + } + remote := map[string]*remoteFile{ + "same.txt": {RelPath: "same.txt", Hash: "h1"}, + "diff.txt": {RelPath: "diff.txt", Hash: "remote"}, + "onlythem.txt": {RelPath: "onlythem.txt", Hash: "h4"}, + } + res, err := compareTrees(local, remote, "exact", false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertPaths(t, "unchanged", res.Unchanged, "same.txt") + assertPaths(t, "modified", res.Modified, "diff.txt") + assertPaths(t, "new_local", res.NewLocal, "onlyme.txt") + assertPaths(t, "new_remote", res.NewRemote, "onlythem.txt") + if res.Detection != "exact" { + t.Errorf("detection = %q, want exact", res.Detection) + } +} + +func TestCompareTrees_exact_unknownRemoteHashIsUnknown(t *testing.T) { + local := map[string]*localFile{"a.txt": {RelPath: "a.txt", Hash: "h1"}} + remote := map[string]*remoteFile{"a.txt": {RelPath: "a.txt", Hash: ""}} + res, err := compareTrees(local, remote, "exact", false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // 远端无 md5 → 无法核对内容,归入 unknown(既非 unchanged 也非 modified)。 + assertPaths(t, "unknown", res.Unknown, "a.txt") + if len(res.Unchanged) != 0 { + t.Error("unknown remote hash must not be judged unchanged") + } + if len(res.Modified) != 0 { + t.Error("unknown remote hash must not be judged modified") + } +} + +// TestCompareTrees_exact_hashesOnlyIntersection 验证本地 hash 只在双端都存在时才 +// 计算:local-only 文件即使 AbsPath 指向不存在的文件也不会触发 hash(不报错)。 +func TestCompareTrees_exact_hashesOnlyIntersection(t *testing.T) { + root := t.TempDir() + bothPath := filepath.Join(root, "both.txt") + mustWrite(t, bothPath, "same-content") + h, err := md5File(bothPath) + if err != nil { + t.Fatalf("md5File: %v", err) + } + + local := map[string]*localFile{ + "both.txt": {RelPath: "both.txt", AbsPath: bothPath}, + "local.txt": {RelPath: "local.txt", AbsPath: filepath.Join(root, "does-not-exist.txt")}, + } + remote := map[string]*remoteFile{ + "both.txt": {RelPath: "both.txt", Hash: h}, + } + res, err := compareTrees(local, remote, "exact", false) + if err != nil { + t.Fatalf("local-only file must not be hashed, got error: %v", err) + } + assertPaths(t, "unchanged", res.Unchanged, "both.txt") + assertPaths(t, "new_local", res.NewLocal, "local.txt") + // 双端都存在的 both.txt 被惰性 hash 后应回填 Hash + if local["both.txt"].Hash == "" { + t.Error("intersection file should have been hashed lazily") + } + if local["local.txt"].Hash != "" { + t.Error("local-only file should never be hashed") + } +} + +// TestCompareTrees_exact_noRemoteMD5AllUnknown 验证 exact 模式在远端无 md5 时 +// 一律归入 unknown——不再用 fileSize + modified_time 近似判 unchanged(否则大小与 +// mtime 恰好相同但内容不同的文件会被误报为未变更),无论大小/时间是否相同。 +func TestCompareTrees_exact_noRemoteMD5AllUnknown(t *testing.T) { + local := map[string]*localFile{ + "sameSizeTime.txt": {RelPath: "sameSizeTime.txt", Size: 100, ModTimeMillis: 1000}, + "diffsize.txt": {RelPath: "diffsize.txt", Size: 100, ModTimeMillis: 1000}, + "difftime.txt": {RelPath: "difftime.txt", Size: 100, ModTimeMillis: 1000}, + } + remote := map[string]*remoteFile{ + // 远端均无 md5(Hash 空)→ 全部 unknown,绝不判 unchanged。 + "sameSizeTime.txt": {RelPath: "sameSizeTime.txt", Size: 100, ModifiedTime: 1000, ModifiedTimeValid: true}, + "diffsize.txt": {RelPath: "diffsize.txt", Size: 200, ModifiedTime: 1000, ModifiedTimeValid: true}, + "difftime.txt": {RelPath: "difftime.txt", Size: 100, ModifiedTime: 2000, ModifiedTimeValid: true}, + } + res, err := compareTrees(local, remote, "exact", false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertPaths(t, "unknown", res.Unknown, "diffsize.txt", "difftime.txt", "sameSizeTime.txt") + if len(res.Unchanged) != 0 { + t.Errorf("远端无 md5 时不应有 unchanged, got %v", entryPaths(res.Unchanged)) + } + if len(res.Modified) != 0 { + t.Errorf("远端无 md5 时不应有 modified, got %v", entryPaths(res.Modified)) + } +} + +// TestCompareTrees_exact_base64RemoteMD5 验证 exact 用 base64 md5 比对:本地按 +// base64 算 md5,与钉盘返回的 base64 md5 直接相等 → unchanged。 +func TestCompareTrees_exact_base64RemoteMD5(t *testing.T) { + root := t.TempDir() + p := filepath.Join(root, "a.txt") + mustWrite(t, p, "hello root\n") // 11 字节,md5 base64 = 6y9K7B0b2K0AInNZZV/70A== + remoteB64 := "6y9K7B0b2K0AInNZZV/70A==" + local := map[string]*localFile{"a.txt": {RelPath: "a.txt", AbsPath: p}} + remote := map[string]*remoteFile{"a.txt": {RelPath: "a.txt", Hash: remoteB64}} + res, err := compareTrees(local, remote, "exact", false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertPaths(t, "unchanged", res.Unchanged, "a.txt") + if len(res.Modified) != 0 { + t.Errorf("同一 md5(base64) 不应判 modified, got modified=%d", len(res.Modified)) + } + // 本地按需 hash 后应回填为 base64 + if local["a.txt"].Hash != remoteB64 { + t.Errorf("本地 md5 应回填为 base64 %q, got %q", remoteB64, local["a.txt"].Hash) + } +} + +func TestCompareTrees_quick(t *testing.T) { + local := map[string]*localFile{ + "eq.txt": {RelPath: "eq.txt", ModTimeMillis: 1000}, + "ne.txt": {RelPath: "ne.txt", ModTimeMillis: 1000}, + "bad.txt": {RelPath: "bad.txt", ModTimeMillis: 1000}, + } + remote := map[string]*remoteFile{ + "eq.txt": {RelPath: "eq.txt", ModifiedTime: 1000, ModifiedTimeValid: true}, + "ne.txt": {RelPath: "ne.txt", ModifiedTime: 2000, ModifiedTimeValid: true}, + "bad.txt": {RelPath: "bad.txt", ModifiedTime: 1000, ModifiedTimeValid: false}, + } + res, err := compareTrees(local, remote, "quick", true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertPaths(t, "unchanged", res.Unchanged, "eq.txt") + // ne.txt 时间不等、bad.txt 远端时间不可信 → 都记为 modified + assertPaths(t, "modified", res.Modified, "bad.txt", "ne.txt") +} + +func TestCompareTrees_emptyRemote_allNewLocal(t *testing.T) { + local := map[string]*localFile{ + "a.txt": {RelPath: "a.txt", Hash: "h1"}, + "b.txt": {RelPath: "b.txt", Hash: "h2"}, + } + res, err := compareTrees(local, map[string]*remoteFile{}, "exact", false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertPaths(t, "new_local", res.NewLocal, "a.txt", "b.txt") + if len(res.NewRemote) != 0 || len(res.Modified) != 0 || len(res.Unchanged) != 0 { + t.Error("with empty remote, only new_local should be populated") + } +} + +// ────────────────────────────────────────────────────────── +// helpers +// ────────────────────────────────────────────────────────── + +func mustWrite(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func keys(m map[string]*localFile) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} + +func assertPaths(t *testing.T, label string, entries []driveStatusEntry, want ...string) { + t.Helper() + if len(entries) != len(want) { + t.Fatalf("%s: got %d entries %v, want %v", label, len(entries), entryPaths(entries), want) + } + for i, w := range want { + if entries[i].RelPath != w { + t.Errorf("%s[%d] = %q, want %q", label, i, entries[i].RelPath, w) + } + } +} + +func entryPaths(entries []driveStatusEntry) []string { + out := make([]string, len(entries)) + for i, e := range entries { + out[i] = e.RelPath + } + return out +} + +// ────────────────────────────────────────────────────────── +// parseDriveList — list_files 返回解析 +// ────────────────────────────────────────────────────────── + +func TestParseDriveList_resultItems(t *testing.T) { + text := `{"result":{"items":[{"name":"a.txt","type":"file"},{"name":"sub","type":"folder"}],"nextToken":"tok"}}` + items, token, err := parseDriveList(text) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(items) != 2 { + t.Fatalf("expected 2 items, got %d", len(items)) + } + if token != "tok" { + t.Errorf("token = %q, want tok", token) + } +} + +func TestParseDriveList_topLevelArray(t *testing.T) { + text := `{"result":[{"name":"a.txt","type":"file"}]}` + items, token, err := parseDriveList(text) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(items) != 1 || items[0].name() != "a.txt" { + t.Errorf("unexpected items: %v", items) + } + if token != "" { + t.Errorf("expected empty token, got %q", token) + } +} + +func TestParseDriveList_invalid(t *testing.T) { + if _, _, err := parseDriveList("not json"); err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +// ────────────────────────────────────────────────────────── +// driveItem — 字段访问器(带 fallback) +// ────────────────────────────────────────────────────────── + +func TestDriveItem_accessors(t *testing.T) { + it := driveItem{ + "name": "报告.pdf", + "type": "file", + "fileId": "d-123", + "md5": "abc", + "path": "/dir/报告.pdf", + "modifyTime": float64(1700000000000), + } + if it.name() != "报告.pdf" { + t.Errorf("name() = %q", it.name()) + } + if it.id() != "d-123" { + t.Errorf("id() = %q", it.id()) + } + if it.typ() != "file" { + t.Errorf("typ() = %q, want file", it.typ()) + } + if it.hash() != "abc" { + t.Errorf("hash() = %q", it.hash()) + } + if it.path() != "/dir/报告.pdf" { + t.Errorf("path() = %q", it.path()) + } + if it.isFolder() { + t.Error("type=file should not be a folder") + } + if !it.isFile() { + t.Error("type=file should be a file") + } + ms, ok := it.modifiedMillis() + if !ok || ms != 1700000000000 { + t.Errorf("modifiedMillis() = %d,%v", ms, ok) + } +} + +func TestDriveItem_folderAndFile(t *testing.T) { + folder := driveItem{"name": "sub", "type": "folder"} + if !folder.isFolder() || folder.isFile() { + t.Error("type=folder should be folder, not file") + } + file := driveItem{"name": "a.txt", "type": "file"} + if !file.isFile() || file.isFolder() { + t.Error("type=file should be file, not folder") + } + // 非 file / 非 folder 的类型(在线文档、快捷方式等)都不纳入比对。 + doc := driveItem{"name": "设计稿", "type": "docx"} + if doc.isFile() { + t.Error("docx should not be treated as file") + } + shortcut := driveItem{"name": "快捷方式", "type": "shortcut"} + if shortcut.isFile() { + t.Error("shortcut should not be treated as file") + } +} + +// ────────────────────────────────────────────────────────── +// toMillis — 时间字段归一化 +// ────────────────────────────────────────────────────────── + +func TestToMillis(t *testing.T) { + cases := []struct { + name string + in any + want int64 + ok bool + }{ + {"epoch-ms-number", float64(1700000000000), 1700000000000, true}, + {"epoch-ms-string", "1700000000000", 1700000000000, true}, + {"rfc3339", "2023-11-14T22:13:20Z", 1700000000000, true}, + {"zero", float64(0), 0, false}, + {"empty", "", 0, false}, + {"garbage", "not-a-time", 0, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, ok := toMillis(c.in) + if ok != c.ok || (ok && got != c.want) { + t.Errorf("toMillis(%v) = %d,%v; want %d,%v", c.in, got, ok, c.want, c.ok) + } + }) + } +} diff --git a/internal/helpers/drive_sync.go b/internal/helpers/drive_sync.go new file mode 100644 index 00000000..e86ad9a6 --- /dev/null +++ b/internal/helpers/drive_sync.go @@ -0,0 +1,542 @@ +package helpers + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/spf13/cobra" +) + +// ========================================================== +// drive sync — 本地与钉盘双向同步(本地 ⇄ Drive) +// ========================================================== +// ────────────────────────────────────────────────────────── +// dws drive sync — 把 --local-folder 与 --remote-folder 做文件级双向同步。 +// +// 复用 status 的差异判定(exact MD5 / --quick modified_time)先算出 diff,再按方向执行: +// new_local 仅本地存在 → push(缺失的远端目录按需创建,再上传) +// new_remote 仅钉盘存在 → pull(下载到本地对应路径) +// modified 两侧都变更 → 按 --on-conflict 解决: +// remote-wins 默认:拉取远端覆盖本地 +// local-wins :上传本地覆盖远端 +// keep-both :本地改名保留,再拉取远端到原路径 +// ask :交互式逐个询问 +// unknown exact 模式远端无可靠 md5、内容无法核对 → 跳过(记 skipped,提示改用 --quick) +// unchanged 两侧一致 → 不动 +// +// 文件级同步——只新增/覆盖,不删除任何一侧的多余文件。summary.failed > 0 时以非零 +// 退出码退出,结构化 summary + diff + items 仍打印在 stdout 上。 +// ────────────────────────────────────────────────────────── + +// --on-conflict 的四种冲突解决策略。 +const ( + syncConflictLocalWins = "local-wins" // 上传本地覆盖远端 + syncConflictRemoteWins = "remote-wins" // 默认:拉取远端覆盖本地 + syncConflictKeepBoth = "keep-both" // 本地改名保留,再拉取远端到原路径 + syncConflictAsk = "ask" // 交互式逐个询问 +) + +// sync 动作分类(direction 标注每条记录属于哪个方向)。 +const ( + syncDirectionPull = "pull" + syncDirectionPush = "push" + syncDirectionConflict = "conflict" + + syncActionDownloaded = "downloaded" + syncActionUploaded = "uploaded" + syncActionOverwritten = "overwritten" + syncActionFolderCreated = "folder_created" + syncActionRenamedLocal = "renamed_local" + syncActionSkipped = "skipped" + syncActionFailed = "failed" +) + +// driveSyncItem 是输出 items[] 中每条操作的明细。 +type driveSyncItem struct { + RelPath string `json:"rel_path"` + Action string `json:"action"` + Direction string `json:"direction,omitempty"` + Error string `json:"error,omitempty"` +} + +// driveSyncSummary 是各方向的计数汇总。 +type driveSyncSummary struct { + Pulled int `json:"pulled"` + Pushed int `json:"pushed"` + Skipped int `json:"skipped"` + Failed int `json:"failed"` +} + +// driveSyncDiff 是本次同步前算出的五类差异(与 status 同源)。 +type driveSyncDiff struct { + NewLocal []driveStatusEntry `json:"new_local"` + NewRemote []driveStatusEntry `json:"new_remote"` + Modified []driveStatusEntry `json:"modified"` + Unchanged []driveStatusEntry `json:"unchanged"` + Unknown []driveStatusEntry `json:"unknown"` +} + +// driveSyncResult 是 sync 命令的输出 schema。 +type driveSyncResult struct { + Detection string `json:"detection"` + Diff driveSyncDiff `json:"diff"` + Summary driveSyncSummary `json:"summary"` + Items []driveSyncItem `json:"items"` +} + +// driveSyncFailure 在 summary.failed > 0 时返回:结构化结果已打印到 stdout, +// 这里只负责以 exit=1 退出并向 stderr 输出一行简短说明(与 drivePushFailure 一致)。 +type driveSyncFailure struct{ failed int } + +func (e *driveSyncFailure) Error() string { + return fmt.Sprintf("drive sync: %d item(s) failed", e.failed) +} +func (e *driveSyncFailure) RawStderr() string { return e.Error() } +func (e *driveSyncFailure) ExitCode() int { return 1 } + +func runDriveSync(cmd *cobra.Command, _ []string) error { + if err := validateRequiredFlags(cmd, "local-folder", "remote-folder"); err != nil { + return err + } + localDir := mustGetFlag(cmd, "local-folder") + remoteDirID := mustGetFlag(cmd, "remote-folder") + // space-id 可选:不传则由底层使用「我的文件」对应的空间。 + spaceID := mustGetFlag(cmd, "space-id") + + onConflict, _ := cmd.Flags().GetString("on-conflict") + if onConflict == "" { + onConflict = syncConflictRemoteWins + } + switch onConflict { + case syncConflictLocalWins, syncConflictRemoteWins, syncConflictKeepBoth, syncConflictAsk: + default: + return fmt.Errorf("--on-conflict 取值非法: %s(可选 local-wins|remote-wins|keep-both|ask)", onConflict) + } + + quick, _ := cmd.Flags().GetBool("quick") + detection := "exact" + if quick { + detection = "quick" + } + + absDir, err := validateLocalDirAbs(localDir) + if err != nil { + return err + } + + ctx := cmd.Context() + + // 远端现状:文件(含 hash/mtime/fileId)与目录(rel_path → fileId,"" 即根本身)。 + remoteFiles, remoteFolders, err := fetchRemoteTreeForPush(ctx, spaceID, remoteDirID) + if err != nil { + return err + } + // 本地现状:子目录(含空目录,父目录先于子目录)与常规文件。 + localDirs, localFiles, err := walkLocalForPush(absDir) + if err != nil { + return fmt.Errorf("扫描本地目录失败: %w", err) + } + localByRel := make(map[string]localPushFile, len(localFiles)) + for _, f := range localFiles { + localByRel[f.RelPath] = f + } + + // 计算 diff:双端都存在的文件复用 judgeFileMatch,保持与 status 完全一致的判定。 + diff := driveSyncDiff{ + NewLocal: []driveStatusEntry{}, + NewRemote: []driveStatusEntry{}, + Modified: []driveStatusEntry{}, + Unchanged: []driveStatusEntry{}, + Unknown: []driveStatusEntry{}, + } + var newLocal, newRemote, modified, unknown []string + for rel, pf := range localByRel { + rf, ok := remoteFiles[rel] + if !ok { + newLocal = append(newLocal, rel) + continue + } + lf := &localFile{RelPath: pf.RelPath, AbsPath: pf.AbsPath, Size: pf.Size, ModTimeMillis: pf.ModTimeMillis} + verdict, jerr := judgeFileMatch(lf, rf, quick) + if jerr != nil { + return jerr + } + switch verdict { + case matchUnchanged: + diff.Unchanged = append(diff.Unchanged, driveStatusEntry{RelPath: rel}) + case matchUnknown: + unknown = append(unknown, rel) + default: + modified = append(modified, rel) + } + } + for rel := range remoteFiles { + if _, ok := localByRel[rel]; !ok { + newRemote = append(newRemote, rel) + } + } + sort.Strings(newLocal) + sort.Strings(newRemote) + sort.Strings(modified) + sort.Strings(unknown) + for _, rel := range newLocal { + diff.NewLocal = append(diff.NewLocal, driveStatusEntry{RelPath: rel}) + } + for _, rel := range newRemote { + diff.NewRemote = append(diff.NewRemote, driveStatusEntry{RelPath: rel}) + } + for _, rel := range modified { + diff.Modified = append(diff.Modified, driveStatusEntry{RelPath: rel}) + } + for _, rel := range unknown { + diff.Unknown = append(diff.Unknown, driveStatusEntry{RelPath: rel}) + } + sortEntries(diff.Unchanged) + + res := driveSyncResult{Detection: detection, Diff: diff, Items: []driveSyncItem{}} + + // --dry-run:只算差异、不执行任何同步动作。 + if deps.Caller.DryRun() { + deps.Out.PrintInfo("dry-run: 仅计算差异,未执行任何同步操作") + return deps.Out.PrintJSON(res) + } + + // unknown:exact 模式下远端无可靠 md5、内容无法核对,不擅自覆盖任何一侧,记 skipped。 + for _, rel := range unknown { + res.Summary.Skipped++ + res.Items = append(res.Items, driveSyncItem{ + RelPath: rel, Action: syncActionSkipped, Direction: syncDirectionConflict, + Error: "远端无可靠 md5,内容无法核对,已跳过(可改用 --quick 按 modified_time 比对)", + }) + } + + // ask 模式:先把所有 modified 的解决策略问全,再统一执行,避免边执行边交互。 + resolutions := make(map[string]string, len(modified)) + for _, rel := range modified { + strategy := onConflict + if strategy == syncConflictAsk { + strategy, err = driveSyncAskConflict(rel) + if err != nil { + return err + } + } + resolutions[rel] = strategy + } + + // 落盘前先确保本地根存在(供大小写探测与 pull 落盘),并探测目标文件系统大小写敏感性。 + if err := os.MkdirAll(absDir, 0o755); err != nil { + return fmt.Errorf("创建本地目录失败: %w", err) + } + caseInsensitive := isCaseInsensitiveFS(absDir) + + // 远端 → 本地的大小写/规范化冲突:多个远端条目在当前文件系统上映射到同一本地路径时, + // 命中的路径一律标记 failed、都不落盘,避免静默覆盖丢文件(与 pull 同策略)。 + remoteRels := make([]string, 0, len(remoteFiles)) + for rel := range remoteFiles { + remoteRels = append(remoteRels, rel) + } + collided := detectTargetCollisions(absDir, remoteRels, caseInsensitive) + + // occupied:keep-both 生成不冲突的本地重命名目标时用,覆盖两侧全部已知路径。 + occupied := make(map[string]bool) + for rel := range localByRel { + occupied[rel] = true + } + for _, d := range localDirs { + occupied[d] = true + } + for rel := range remoteFiles { + occupied[rel] = true + } + for rel := range remoteFolders { + if rel != "" { + occupied[rel] = true + } + } + + // 阶段 1:镜像本地目录结构到远端(缺失则创建),保证空目录与 push 目标的父目录先存在。 + for _, dir := range localDirs { + if _, ok := remoteFolders[dir]; ok { + continue // 远端已存在,复用 fileId + } + parentRel, name := splitRel(dir) + parentID, ok := remoteFolders[parentRel] + if !ok || parentID == "" { + res.Summary.Failed++ + res.Items = append(res.Items, driveSyncItem{RelPath: dir, Action: syncActionFailed, Direction: syncDirectionPush, Error: "父目录未能创建"}) + continue + } + fid, cerr := pushCreateFolder(ctx, spaceID, parentID, name) + if cerr != nil || fid == "" { + res.Summary.Failed++ + msg := "create_folder 未返回 fileId" + if cerr != nil { + msg = cerr.Error() + } + res.Items = append(res.Items, driveSyncItem{RelPath: dir, Action: syncActionFailed, Direction: syncDirectionPush, Error: msg}) + continue + } + remoteFolders[dir] = fid + res.Summary.Pushed++ + res.Items = append(res.Items, driveSyncItem{RelPath: dir, Action: syncActionFolderCreated, Direction: syncDirectionPush}) + } + + // 阶段 2:new_remote 下载到本地。 + for _, rel := range newRemote { + syncPullFile(&res, ctx, spaceID, remoteFiles[rel], absDir, rel, collided, syncDirectionPull) + } + + // 阶段 3:new_local 上传到远端。 + for _, rel := range newLocal { + pf := localByRel[rel] + syncPushFile(&res, ctx, spaceID, remoteFolders, pf, rel, "") + } + + // 阶段 4:modified 按 --on-conflict 解决。 + for _, rel := range modified { + pf := localByRel[rel] + rf := remoteFiles[rel] + switch resolutions[rel] { + case syncConflictRemoteWins: + syncPullFile(&res, ctx, spaceID, rf, absDir, rel, collided, syncDirectionPull) + case syncConflictLocalWins: + syncPushFile(&res, ctx, spaceID, remoteFolders, pf, rel, rf.FileID) + case syncConflictKeepBoth: + syncKeepBoth(&res, ctx, spaceID, rf, absDir, rel, collided, occupied) + default: // "" — ask 选择跳过 + res.Summary.Skipped++ + res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionSkipped, Direction: syncDirectionConflict}) + } + } + + // 结构化结果始终打印到 stdout;有失败则额外以非零退出码退出。 + if perr := deps.Out.PrintJSON(res); perr != nil { + return perr + } + if res.Summary.Failed > 0 { + return &driveSyncFailure{failed: res.Summary.Failed} + } + return nil +} + +// syncPullFile 下载单个远端文件到本地 rel 对应路径(总是覆盖,Drive 为该项权威源), +// 并把结果计入 res。命中大小写/规范化冲突或逃逸的路径记 failed、不落盘。 +func syncPullFile(res *driveSyncResult, ctx context.Context, spaceID string, rf *remoteFile, absDir, rel string, collided map[string]bool, direction string) { + if collided[rel] { + res.Summary.Failed++ + res.Items = append(res.Items, driveSyncItem{ + RelPath: rel, Action: syncActionFailed, Direction: direction, + Error: "多个远端条目在当前文件系统上映射到同一本地路径(大小写/规范化冲突),已跳过以避免覆盖丢失", + }) + return + } + localPath, terr := resolveLocalTarget(absDir, rel) + if terr != nil { + res.Summary.Failed++ + res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: direction, Error: terr.Error()}) + return + } + action, perr := pullOneFile(ctx, spaceID, rf, localPath, ifExistsOverwrite) + if action == pullActionDownloaded { + res.Summary.Pulled++ + res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionDownloaded, Direction: direction}) + return + } + res.Summary.Failed++ + item := driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: direction} + if perr != nil { + item.Error = perr.Error() + } + res.Items = append(res.Items, item) +} + +// syncPushFile 上传单个本地文件到远端;overwriteID 非空时走覆盖上传(原地覆盖同名远端文件)。 +func syncPushFile(res *driveSyncResult, ctx context.Context, spaceID string, remoteFolders map[string]string, pf localPushFile, rel, overwriteID string) { + parentRel, name := splitRel(rel) + parentID, ok := remoteFolders[parentRel] + if !ok || parentID == "" { + res.Summary.Failed++ + res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionPush, Error: "父目录未能创建"}) + return + } + if err := pushUploadFile(ctx, spaceID, parentID, overwriteID, name, pf.AbsPath, pf.Size); err != nil { + res.Summary.Failed++ + res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionPush, Error: err.Error()}) + return + } + res.Summary.Pushed++ + action := syncActionUploaded + direction := syncDirectionPush + if overwriteID != "" { + action = syncActionOverwritten + direction = syncDirectionConflict + } + res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: action, Direction: direction}) +} + +// syncKeepBoth 解决 modified 冲突的 keep-both 策略:先把本地文件改名保留(追加基于 +// 远端 fileId 的不冲突后缀),再把远端文件拉取到原 rel 路径。拉取失败则回滚改名。 +func syncKeepBoth(res *driveSyncResult, ctx context.Context, spaceID string, rf *remoteFile, absDir, rel string, collided, occupied map[string]bool) { + if collided[rel] { + res.Summary.Failed++ + res.Items = append(res.Items, driveSyncItem{ + RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, + Error: "多个远端条目在当前文件系统上映射到同一本地路径(大小写/规范化冲突),已跳过以避免覆盖丢失", + }) + return + } + oldAbs, e1 := resolveLocalTarget(absDir, rel) + if e1 != nil { + res.Summary.Failed++ + res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, Error: e1.Error()}) + return + } + // 以「不覆盖」语义原子占用一个本地重命名目标。occupied 的精确字符串查重覆盖不到 + // 大小写/NFC 等价的既有文件;用 O_CREATE|O_EXCL 让 OS 兜底判等价性,命中则换下一个 + // 后缀,杜绝 os.Rename 静默覆盖等价既有文件导致的数据丢失。 + suffixedRel, newAbs, e2 := reserveSyncKeepBothTarget(absDir, rel, rf.FileID, occupied) + if e2 != nil { + res.Summary.Failed++ + res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, Error: e2.Error()}) + return + } + // newAbs 此刻是我们刚用 O_EXCL 新建的空占位文件,覆盖它不会丢用户数据;且已确保它 + // 不与任何等价既有文件冲突。 + if err := os.Rename(oldAbs, newAbs); err != nil { + _ = os.Remove(newAbs) // 清理空占位,避免残留 + res.Summary.Failed++ + res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, Error: fmt.Sprintf("本地改名保留失败: %v", err)}) + return + } + occupied[suffixedRel] = true + + action, perr := pullOneFile(ctx, spaceID, rf, oldAbs, ifExistsOverwrite) + if action != pullActionDownloaded { + // 拉取失败:回滚改名,把本地文件恢复回原名。pullOneFile 是原子的(下载写临时文件、 + // 成功才 rename,失败时绝不触碰 oldAbs),故此处 oldAbs 必不存在,rename 可直接复原、 + // 无需像参考实现那样先清残留。回滚本身失败时如实上报——本地版本仍以改名后的名字保留、 + // 未丢数据,但需让用户知道文件名已变。 + res.Summary.Failed++ + msg := "" + if perr != nil { + msg = perr.Error() + } + if rbErr := os.Rename(newAbs, oldAbs); rbErr != nil { + if msg != "" { + msg += "; " + } + msg += fmt.Sprintf("回滚改名失败,本地版本保留为 %s: %v", suffixedRel, rbErr) + } + res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, Error: msg}) + return + } + res.Summary.Pulled++ + res.Items = append(res.Items, + driveSyncItem{RelPath: suffixedRel, Action: syncActionRenamedLocal, Direction: syncDirectionConflict}, + driveSyncItem{RelPath: rel, Action: syncActionDownloaded, Direction: syncDirectionPull}, + ) +} + +// syncKeepBothCandidate 生成 keep-both 的第 n 个候选相对路径(n=0 为首选):在扩展名前 +// 插入基于远端 fileId 末段的后缀(缺失时用 conflict),n>0 再追加序号消歧。 +func syncKeepBothCandidate(rel, fileID string, n int) string { + suffix := "conflict" + if fileID != "" { + s := fileID + if len(s) > 8 { + s = s[len(s)-8:] + } + suffix = "conflict-" + s + } + dir, base := splitRel(rel) + ext := filepath.Ext(base) // base 无路径分隔符,取末尾扩展名安全 + stem := strings.TrimSuffix(base, ext) + name := stem + "." + suffix + ext + if n > 0 { + name = fmt.Sprintf("%s.%s.%d%s", stem, suffix, n, ext) + } + if dir == "" { + return name + } + return dir + "/" + name +} + +// driveSyncSuffixedRel 返回首个不在 occupied(纯字符串查重)中的候选。仅用于快速路径与 +// 单元测试;keep-both 实际执行走 reserveSyncKeepBothTarget,以 OS 兜底等价性。 +func driveSyncSuffixedRel(rel, fileID string, occupied map[string]bool) string { + for n := 0; ; n++ { + if cand := syncKeepBothCandidate(rel, fileID, n); !occupied[cand] { + return cand + } + } +} + +// reserveSyncKeepBothTarget 生成并以「不覆盖」语义原子占用一个 keep-both 本地目标。 +// 逐个候选用 O_CREATE|O_EXCL 尝试创建:成功即占住该名,返回其相对路径与绝对路径(调用方 +// 随后可安全 os.Rename 覆盖这个空占位);EEXIST(含大小写/NFC 等价的既有文件,由 OS 判定) +// 则记入 occupied 并试下一个后缀,绝不覆盖任何既有文件。 +func reserveSyncKeepBothTarget(absDir, rel, fileID string, occupied map[string]bool) (string, string, error) { + for n := 0; ; n++ { + cand := syncKeepBothCandidate(rel, fileID, n) + if occupied[cand] { + continue // 本次运行内已知占用,快速跳过 + } + abs, err := resolveLocalTarget(absDir, cand) + if err != nil { + return "", "", err // 逃逸/符号链接等,直接失败 + } + f, oerr := os.OpenFile(abs, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + if oerr == nil { + _ = f.Close() + return cand, abs, nil + } + if os.IsExist(oerr) { + occupied[cand] = true // 等价目标已存在,记下避免重试,换下一个后缀 + continue + } + return "", "", oerr + } +} + +// syncAskStdin 是 --on-conflict=ask 交互提问的输入源,默认 os.Stdin;测试可替换它以 +// 注入非交互(EOF)场景。 +var syncAskStdin io.Reader = os.Stdin + +// driveSyncAskConflict 在 --on-conflict=ask 下交互式询问单个冲突文件的解决策略。 +// 返回四种具体策略之一,或 ""(跳过)。当 stdin 在给出选择前结束(管道/无 TTY 等非交互 +// 环境)时,按文档约定等价于「跳过」,返回 ""——绝不因此中止整个同步,new_local/new_remote +// 等其余差异仍会照常处理。 +func driveSyncAskConflict(rel string) (string, error) { + fmt.Fprintf(os.Stderr, "冲突: 本地与远端都修改了 %q。请选择 [R]远端优先 / [L]本地优先 / [K]保留两者 / [S]跳过 (默认 R): ", rel) + line, err := bufio.NewReader(syncAskStdin).ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", fmt.Errorf("读取冲突选择失败 (%s): %w", rel, err) + } + switch strings.TrimSpace(strings.ToLower(line)) { + case "r", "remote", "remote-wins": + return syncConflictRemoteWins, nil + case "l", "local", "local-wins": + return syncConflictLocalWins, nil + case "k", "keep", "keep-both": + return syncConflictKeepBoth, nil + case "s", "skip": + return "", nil + case "": + if errors.Is(err, io.EOF) { + // 非交互(管道/无 TTY):stdin 在给出选择前结束。按文档约定等价于跳过, + // 返回 "" 而非报错——否则单个冲突会让 runDriveSync 直接中止,连 + // new_local/new_remote 都不再处理。 + return "", nil + } + return syncConflictRemoteWins, nil // 交互式回车 → 默认远端优先 + default: + return "", fmt.Errorf("无效的冲突选择: %q(可选 remote/local/keep/skip)", strings.TrimSpace(line)) + } +} diff --git a/internal/helpers/drive_sync_test.go b/internal/helpers/drive_sync_test.go new file mode 100644 index 00000000..fed0a21b --- /dev/null +++ b/internal/helpers/drive_sync_test.go @@ -0,0 +1,417 @@ +package helpers + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// errTestDownload 用于模拟 httpGetFile 下载失败。 +var errTestDownload = errors.New("simulated download failure") + +// ────────────────────────────────────────────────────────── +// driveSyncFailure — sync 部分失败错误:exit=1 +// ────────────────────────────────────────────────────────── + +func TestDriveSyncFailure(t *testing.T) { + e := &driveSyncFailure{failed: 2} + if e.ExitCode() != 1 { + t.Errorf("ExitCode() = %d, want 1", e.ExitCode()) + } + if e.Error() != e.RawStderr() { + t.Error("Error() 与 RawStderr() 应一致") + } + if !strings.Contains(e.Error(), "2") { + t.Errorf("Error() 应包含失败数, got %q", e.Error()) + } +} + +// ────────────────────────────────────────────────────────── +// driveSyncSuffixedRel — keep-both 本地重命名目标生成 +// ────────────────────────────────────────────────────────── + +func TestDriveSyncSuffixedRel(t *testing.T) { + // 基本:扩展名前插入基于 fileId 末 8 位的后缀。 + got := driveSyncSuffixedRel("a/b.txt", "0123456789ABCDEF", map[string]bool{}) + if want := "a/b.conflict-89ABCDEF.txt"; got != want { + t.Errorf("suffixed = %q, want %q", got, want) + } + + // 根目录下、无扩展名。 + if got := driveSyncSuffixedRel("readme", "", map[string]bool{}); got != "readme.conflict" { + t.Errorf("suffixed = %q, want readme.conflict", got) + } + + // 首选目标已被占用时追加序号,直到不冲突。 + occupied := map[string]bool{ + "x.conflict-FID.log": true, + "x.conflict-FID.0.log": true, + } + got = driveSyncSuffixedRel("x.log", "FID", occupied) + if want := "x.conflict-FID.1.log"; got != want { + t.Errorf("suffixed with collisions = %q, want %q", got, want) + } +} + +// ────────────────────────────────────────────────────────── +// sync 双向流程 — new_local 上传 / new_remote 下载 +// ────────────────────────────────────────────────────────── + +// runDriveSyncTest 用 mock caller 执行 `drive sync`,注入 httpPutFile / httpGetFile 为 +// 可控 no-op,避免真实 OSS 传输。返回 root.Execute() 的结果。 +func runDriveSyncTest(t *testing.T, caller *driveSyncMockCaller, localDir string, args ...string) error { + // 默认下载:把远端内容写到目标路径,模拟成功落盘。 + return runDriveSyncTestWithGet(t, caller, func(_ context.Context, _ string, _ map[string]string, dest string) error { + return os.WriteFile(dest, []byte("remote-content"), 0o644) + }, localDir, args...) +} + +// runDriveSyncTestWithGet 同上,但允许自定义 httpGetFile 行为(例如模拟下载失败)。 +func runDriveSyncTestWithGet(t *testing.T, caller *driveSyncMockCaller, getFn func(context.Context, string, map[string]string, string) error, localDir string, args ...string) error { + t.Helper() + + prevDeps := deps + prevOSArgs := os.Args + SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil }) + SetHTTPGetFile(getFn) + + deps = &Deps{Caller: caller, Out: &Formatter{w: io.Discard}} + + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().BoolP("yes", "y", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.AddCommand(newDriveCommand()) + + full := append([]string{"sync", "--local-folder", localDir, "--remote-folder", "ROOT"}, args...) + os.Args = append([]string{"dws", "drive"}, full...) + root.SetArgs(append([]string{"drive"}, full...)) + + t.Cleanup(func() { + deps = prevDeps + os.Args = prevOSArgs + SetHTTPPutFile(nil) + SetHTTPGetFile(nil) + }) + + return root.Execute() +} + +// 双向:本地独有 local.txt 应上传;远端独有 remote.txt 应下载到本地。 +func TestDriveSync_bidirectional(t *testing.T) { + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "local.txt"), "local-only") + + caller := &driveSyncMockCaller{ + // 远端仅有 remote.txt(本地不存在)→ new_remote,应被下载。 + listJSON: `{"result":{"items":[{"name":"remote.txt","type":"file","fileId":"R_FID","md5":"z","modifyTime":2000}],"nextToken":""}}`, + } + if err := runDriveSyncTest(t, caller, dir); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // new_local:local.txt 走 get_upload_info(新建上传,传 parentId=ROOT、不传 overwriteFileId)。 + up := caller.callsFor("get_upload_info") + if len(up) != 1 { + t.Fatalf("expected 1 get_upload_info call, got %d", len(up)) + } + if got := up[0].args["parentId"]; got != "ROOT" { + t.Errorf("get_upload_info parentId = %v, want ROOT", got) + } + if _, ok := up[0].args["overwriteFileId"]; ok { + t.Error("新建上传不应传 overwriteFileId") + } + + // new_remote:remote.txt 走 download_file,并已落盘到本地。 + dl := caller.callsFor("download_file") + if len(dl) != 1 || dl[0].args["fileId"] != "R_FID" { + t.Fatalf("expected 1 download_file(fileId=R_FID), got %v", dl) + } + if _, err := os.Stat(filepath.Join(dir, "remote.txt")); err != nil { + t.Errorf("remote.txt 应被下载到本地: %v", err) + } +} + +// modified + remote-wins:quick 模式下两侧 mtime 不同判为 modified,默认拉取远端覆盖本地。 +func TestDriveSync_modifiedRemoteWins(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "f.txt") + mustWrite(t, p, "local-old") + // 本地 mtime 设为与远端不同,quick 模式判为 modified。 + local := readFileMTimeMillis(t, p) + + caller := &driveSyncMockCaller{ + listJSON: `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F_FID","modifyTime":` + + differentMillis(local) + `}],"nextToken":""}}`, + } + if err := runDriveSyncTest(t, caller, dir, "--quick"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // remote-wins → 下载覆盖本地,且不应发生上传。 + if dl := caller.callsFor("download_file"); len(dl) != 1 || dl[0].args["fileId"] != "F_FID" { + t.Errorf("expected download_file(fileId=F_FID), got %v", dl) + } + if up := caller.callsFor("get_upload_info"); len(up) != 0 { + t.Errorf("remote-wins 不应上传, got %d get_upload_info calls", len(up)) + } + if b, _ := os.ReadFile(p); string(b) != "remote-content" { + t.Errorf("本地文件应被远端内容覆盖, got %q", string(b)) + } +} + +// modified + local-wins:改用 local-wins 时应覆盖上传远端(传 overwriteFileId、不传 parentId)。 +func TestDriveSync_modifiedLocalWins(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "f.txt") + mustWrite(t, p, "local-new") + local := readFileMTimeMillis(t, p) + + caller := &driveSyncMockCaller{ + listJSON: `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F_FID","modifyTime":` + + differentMillis(local) + `}],"nextToken":""}}`, + } + if err := runDriveSyncTest(t, caller, dir, "--quick", "--on-conflict", "local-wins"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + up := caller.callsFor("get_upload_info") + if len(up) != 1 { + t.Fatalf("expected 1 get_upload_info call, got %d", len(up)) + } + if got := up[0].args["overwriteFileId"]; got != "F_FID" { + t.Errorf("local-wins 覆盖上传 overwriteFileId = %v, want F_FID", got) + } + if _, ok := up[0].args["parentId"]; ok { + t.Error("覆盖上传不应传 parentId") + } + if dl := caller.callsFor("download_file"); len(dl) != 0 { + t.Errorf("local-wins 不应下载, got %d download_file calls", len(dl)) + } +} + +// --dry-run:只计算差异,不触发任何 MCP 写操作与本地落盘。 +func TestDriveSync_dryRun(t *testing.T) { + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "local.txt"), "x") + + caller := &driveSyncMockCaller{ + listJSON: `{"result":{"items":[{"name":"remote.txt","type":"file","fileId":"R_FID","modifyTime":2000}],"nextToken":""}}`, + dryRun: true, + } + if err := runDriveSyncTest(t, caller, dir); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got := len(caller.callsFor("download_file")) + len(caller.callsFor("get_upload_info")); got != 0 { + t.Errorf("dry-run 不应触发下载/上传, got %d 次写操作调用", got) + } + if _, err := os.Stat(filepath.Join(dir, "remote.txt")); !os.IsNotExist(err) { + t.Error("dry-run 不应把远端文件落盘到本地") + } +} + +// remote-wins 下载失败:本地原文件必须原样保留(pullOneFile 先写临时文件、成功才原子 +// rename 覆盖),且命令以非零退出码退出。 +func TestDriveSync_pullFailureKeepsLocal(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "f.txt") + mustWrite(t, p, "local-original") + local := readFileMTimeMillis(t, p) + + caller := &driveSyncMockCaller{ + listJSON: `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F_FID","modifyTime":` + + differentMillis(local) + `}],"nextToken":""}}`, + } + failGet := func(context.Context, string, map[string]string, string) error { + return errTestDownload + } + err := runDriveSyncTestWithGet(t, caller, failGet, dir, "--quick") // 默认 remote-wins + if err == nil { + t.Fatal("下载失败时应以非零退出码退出") + } + if ec, ok := err.(*driveSyncFailure); !ok || ec.ExitCode() != 1 { + t.Fatalf("期望 driveSyncFailure(exit=1), got %T %v", err, err) + } + // 关键:原文件内容与存在性都不被破坏。 + if b, rerr := os.ReadFile(p); rerr != nil || string(b) != "local-original" { + t.Errorf("下载失败后本地原文件应原样保留, got %q err=%v", string(b), rerr) + } +} + +// keep-both 成功路径:本地既有文件改名保留(os.Rename 覆盖 O_EXCL 空占位),远端拉取到 +// 原路径。此测试在 Windows 主机上运行即验证 os.Rename 可替换既有目标(MOVEFILE_REPLACE_ +// EXISTING),覆盖 pull 覆盖与 keep-both 两条 rename 路径的平台行为。 +func TestDriveSync_keepBothSuccess(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "f.txt") + mustWrite(t, p, "local-new") + local := readFileMTimeMillis(t, p) + + caller := &driveSyncMockCaller{ + listJSON: `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F_FID","modifyTime":` + + differentMillis(local) + `}],"nextToken":""}}`, + } + if err := runDriveSyncTest(t, caller, dir, "--quick", "--on-conflict", "keep-both"); err != nil { + t.Fatalf("keep-both 成功路径不应报错: %v", err) + } + + // 原路径 f.txt 应为远端内容(拉取落盘)。 + if b, _ := os.ReadFile(p); string(b) != "remote-content" { + t.Errorf("f.txt 应为远端内容, got %q", string(b)) + } + // 本地版本改名保留为 f.conflict-F_FID.txt,内容不变。 + aside := filepath.Join(dir, "f.conflict-F_FID.txt") + if b, err := os.ReadFile(aside); err != nil || string(b) != "local-new" { + t.Errorf("本地版本应改名保留为 %s 且内容不变, got %q err=%v", filepath.Base(aside), string(b), err) + } + // 下载发生一次(拉取远端到原路径),不应有上传。 + if dl := caller.callsFor("download_file"); len(dl) != 1 { + t.Errorf("keep-both 应拉取远端一次, got %d download_file calls", len(dl)) + } + if up := caller.callsFor("get_upload_info"); len(up) != 0 { + t.Errorf("keep-both 不应上传, got %d get_upload_info calls", len(up)) + } +} + +// keep-both 下载失败:改名保留后拉取远端失败,应回滚改名——原文件恢复原名原内容, +// 不留下改名后的残留文件。 +func TestDriveSync_keepBothRollbackOnPullFailure(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "f.txt") + mustWrite(t, p, "local-original") + local := readFileMTimeMillis(t, p) + + caller := &driveSyncMockCaller{ + listJSON: `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F_FID","modifyTime":` + + differentMillis(local) + `}],"nextToken":""}}`, + } + failGet := func(context.Context, string, map[string]string, string) error { + return errTestDownload + } + err := runDriveSyncTestWithGet(t, caller, failGet, dir, "--quick", "--on-conflict", "keep-both") + if err == nil { + t.Fatal("下载失败时应以非零退出码退出") + } + // 原文件恢复原名原内容。 + if b, rerr := os.ReadFile(p); rerr != nil || string(b) != "local-original" { + t.Errorf("keep-both 拉取失败应回滚改名, 原文件未恢复: got %q err=%v", string(b), rerr) + } + // 目录下不应残留改名后的 *.conflict-* 文件。 + entries, _ := os.ReadDir(dir) + for _, e := range entries { + if strings.Contains(e.Name(), "conflict-") { + t.Errorf("回滚后不应残留改名文件: %s", e.Name()) + } + } +} + +// reserveSyncKeepBothTarget 不得占用「精确同名」的既有文件(occupied 未记录时), +// 应原子占位到下一个后缀,且既有文件内容不被破坏。此断言在任何文件系统上都成立。 +func TestReserveSyncKeepBothTarget_noOverwriteExact(t *testing.T) { + dir := t.TempDir() + // 预置一个与首选候选精确同名的既有文件(模拟未被 occupied 记录的外部残留)。 + first := "f.conflict-ABCD1234.txt" + mustWrite(t, filepath.Join(dir, first), "existing-data") + + rel, abs, err := reserveSyncKeepBothTarget(dir, "f.txt", "0000ABCD1234", map[string]bool{}) + if err != nil { + t.Fatal(err) + } + if rel == first { + t.Fatalf("不应占用既有文件 %s", first) + } + if want := "f.conflict-ABCD1234.1.txt"; rel != want { + t.Errorf("rel = %q, want %q", rel, want) + } + // 既有文件内容原样保留(未被覆盖)。 + if b, _ := os.ReadFile(filepath.Join(dir, first)); string(b) != "existing-data" { + t.Error("既有文件被覆盖,数据丢失") + } + // 返回的目标是新建的空占位文件。 + if fi, e := os.Stat(abs); e != nil || fi.Size() != 0 { + t.Errorf("占位文件应存在且为空: err=%v", e) + } +} + +// reserveSyncKeepBothTarget 在大小写不敏感文件系统上,不得覆盖与候选「大小写等价」的 +// 既有文件——O_EXCL 由 OS 兜底判等价性,occupied 的精确字符串键覆盖不到这种情况。 +func TestReserveSyncKeepBothTarget_noOverwriteCaseEquivalent(t *testing.T) { + dir := t.TempDir() + if !isCaseInsensitiveFS(dir) { + t.Skip("文件系统大小写敏感,跳过大小写等价覆盖测试") + } + // 既有文件用大写变体,occupied 为空(模拟精确键查重覆盖不到的等价文件)。 + existing := "f.CONFLICT-ABCD1234.TXT" + mustWrite(t, filepath.Join(dir, existing), "case-variant-data") + + rel, _, err := reserveSyncKeepBothTarget(dir, "f.txt", "0000abcd1234", map[string]bool{}) + if err != nil { + t.Fatal(err) + } + // 生成的小写候选与既有大写文件在不敏感 FS 上等价,本不应被占用。 + if strings.EqualFold(rel, "f.conflict-abcd1234.txt") { + t.Errorf("首选候选与既有文件等价,不应被占用: %q", rel) + } + // 既有文件内容原样保留。 + if b, _ := os.ReadFile(filepath.Join(dir, existing)); string(b) != "case-variant-data" { + t.Error("大小写等价的既有文件被覆盖,数据丢失") + } +} + +// 非交互 ask(stdin 立即 EOF):冲突项应被跳过而非中止整个同步, +// new_local 仍上传、new_remote 仍下载,且被跳过的 modified 本地文件不被改动。 +func TestDriveSync_askNonInteractiveSkipsConflict(t *testing.T) { + prev := syncAskStdin + syncAskStdin = strings.NewReader("") // 非交互:立即 EOF + t.Cleanup(func() { syncAskStdin = prev }) + + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "local.txt"), "local-only") // new_local → 应上传 + p := filepath.Join(dir, "f.txt") + mustWrite(t, p, "local-old") // modified → ask 非交互跳过 + local := readFileMTimeMillis(t, p) + + caller := &driveSyncMockCaller{ + listJSON: `{"result":{"items":[` + + `{"name":"f.txt","type":"file","fileId":"F_FID","modifyTime":` + differentMillis(local) + `},` + + `{"name":"remote.txt","type":"file","fileId":"R_FID","modifyTime":2000}` + + `],"nextToken":""}}`, + } + if err := runDriveSyncTest(t, caller, dir, "--quick", "--on-conflict", "ask"); err != nil { + t.Fatalf("非交互 ask 不应中止同步: %v", err) + } + + // new_local 上传、new_remote 下载都应照常发生。 + if up := caller.callsFor("get_upload_info"); len(up) != 1 { + t.Errorf("new_local 应被上传, got %d get_upload_info calls", len(up)) + } + if dl := caller.callsFor("download_file"); len(dl) != 1 || dl[0].args["fileId"] != "R_FID" { + t.Errorf("new_remote 应被下载, got %v", dl) + } + // modified 冲突被跳过:本地 f.txt 未被覆盖。 + if b, _ := os.ReadFile(p); string(b) != "local-old" { + t.Errorf("ask 跳过的 modified 文件不应被改动, got %q", string(b)) + } +} + +// readFileMTimeMillis 返回文件的 mtime(Unix 毫秒)。 +func readFileMTimeMillis(t *testing.T, path string) int64 { + t.Helper() + fi, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + return fi.ModTime().UnixMilli() +} + +// differentMillis 返回一个与 base 不同的毫秒数字符串,保证 quick 模式判为 modified。 +func differentMillis(base int64) string { + return strconv.FormatInt(base+60000, 10) // 差 1 分钟,足以区分 +} diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md index b839af98..2232a4ea 100644 --- a/skills/mono/SKILL.md +++ b/skills/mono/SKILL.md @@ -82,7 +82,7 @@ cli_version: ">=1.0.15" | `devdoc` | 开放平台文档:搜索开发文档 | [devdoc.md](./references/products/devdoc.md) | | `ding` | DING消息:发送/撤回(应用内/短信/电话) | [ding.md](./references/products/ding.md) | | `doc` | 钉钉文档:搜索/浏览/读写/块级编辑/评论/文件创建/复制/移动/重命名/**删除/导出 docx/权限管理/媒体上传下载** | [doc.md](./references/products/doc.md) | -| `drive` | 钉钉云盘:文件列表/元数据/文件夹/上传(两步)/下载 | [drive.md](./references/products/drive.md) | +| `drive` | 钉钉云盘:文件列表/元数据/文件夹/上传(两步)/下载/本地与钉盘文件夹差异比较(status)/拉取到本地(pull)/推送到钉盘(push)/双向同步(sync) | [drive.md](./references/products/drive.md) | | `hrbrain` | 组织大脑:人才池管理/员工档案专项模块查询(元数据/批量数据/标签/职业历程/绩效)/结构化高级人才搜索(原始条件表达式);区别于 `contact` 的基础通讯录档案与 `aisearch` 的通用语义找人 | [hrbrain.md](./references/products/hrbrain.md) | | `markdown` | 原生 Markdown 文件:读取/创建/全量覆盖/字面量或 RE2 局部替换 | [markdown.md](./references/products/markdown.md) | | `minutes` | AI听记:听记列表/摘要/关键词/转写/待办/思维导图/发言人/发言人段落总结/热词/录音控制/成员权限/上传 | [minutes.md](./references/products/minutes.md) | diff --git a/skills/mono/references/products/drive.md b/skills/mono/references/products/drive.md index 80e7e3ed..188e0936 100644 --- a/skills/mono/references/products/drive.md +++ b/skills/mono/references/products/drive.md @@ -343,6 +343,207 @@ Flags: > **注意**:还原操作可能是异步的(返回 `async=true` 和 `taskId`)。 +### 比较本地文件夹与钉盘文件夹的差异 + +只读命令:比较本地文件夹与钉盘文件夹的差异——本地取 `--local-folder`(**绝对路径**),钉盘取 `--remote-folder`(文件夹 dentryUuid,**必传**)指向的文件夹,按精确 MD5(默认)或快速 modified_time(`--quick`)逐文件比对。两侧各自递归遍历,`rel_path` 相对各自根目录。 + +``` +Usage: + dws drive status [flags] +Example: + dws drive status --local-folder /abs/path/repo --remote-folder + dws drive status --local-folder /abs/path/repo --remote-folder --space-id xxxx + dws drive status --local-folder /abs/path/repo --remote-folder --quick +Flags: + --local-folder string 本地文件夹绝对路径 (必填) + --remote-folder string 钉盘文件夹 ID (dentryUuid) (必填) + --space-id string 钉盘空间 ID,不传则使用「我的文件」(可选) + --quick 快速模式:只比较 modified_time,不计算 MD5 (可选) +``` + +输出五类差异(`rel_path` 始终以 `/` 分隔、相对各自根目录): + +| 字段 | 含义 | +|------|------| +| `new_local` | 仅本地存在 | +| `new_remote` | 仅钉盘存在 | +| `modified` | 两侧都存在且本次检测判定为已变更(exact 比 MD5,quick 比 modified_time) | +| `unchanged` | 两侧都存在且本次检测判定为未变更 | +| `unknown` | 两侧都存在,但 exact 模式下**远端未返回可靠 MD5**、无法核对内容——既不判 unchanged 也不判 modified,如实归入此类(quick 模式不会产生 unknown) | + +输出 schema: + +```json +{ + "detection": "exact", + "new_local": [{"rel_path": "..."}], + "new_remote": [{"rel_path": "..."}], + "modified": [{"rel_path": "..."}], + "unchanged": [{"rel_path": "..."}], + "unknown": [{"rel_path": "..."}] +} +``` + +注意事项: + +- 默认 `detection=exact`(比较 MD5);传 `--quick` 后 `detection=quick`(只比较 modified_time,best-effort)。 +- exact 模式**只在能拿到远端 MD5 时才判定 unchanged/modified**;远端缺失 MD5 的文件一律进入 `unknown`,绝不会因大小 / mtime 恰好相同而被误报为 unchanged。当前 `list_files` 通常不返回 MD5,因此这类文件多会落在 `unknown`——请据此决定是否用 `pull`/`push` 强制对齐。 +- 本地 hash 仅在文件双端都存在、远端有 MD5、且非 `--quick` 模式时才按需计算。 +- 远端条目名称若含 `..`、路径分隔符或盘符等非规范成分,会被直接跳过、不纳入比对(防止逃逸性路径)。 +- 只比对钉盘 `type=file` 的二进制文件;在线文档(docx/sheet/bitable/mindnote/slides)与快捷方式(shortcut)会被跳过。本地只比对常规文件(符号链接、设备文件忽略)。 +- `--local-folder` 必须是绝对路径(相对路径会被直接拒绝);`--remote-folder` 必传,是钉盘侧待比对文件夹的 dentryUuid(可用 `dws drive list` 查到)。 + +### 把钉盘文件夹拉取(镜像)到本地 + +只写本地命令:把 `--remote-folder` 指向的钉盘文件夹**单向、文件级**镜像到本地 `--local-folder`(Drive → 本地)。递归下载所有 `type=file` 的文件,子目录自动创建。 + +``` +Usage: + dws drive pull [flags] +Example: + dws drive pull --local-folder /abs/path/repo --remote-folder + dws drive pull --local-folder /abs/path/repo --remote-folder --if-exists smart + dws drive pull --local-folder /abs/path/repo --remote-folder --space-id xxxx +Flags: + --local-folder string 本地文件夹绝对路径 (必填) + --remote-folder string 钉盘文件夹 ID (dentryUuid) (必填) + --space-id string 钉盘空间 ID,不传则使用「我的文件」(可选) + --if-exists string 本地文件已存在时的策略: overwrite|smart|skip (默认 overwrite) +``` + +`--if-exists` 策略: + +| 值 | 行为 | +|----|------| +| `overwrite`(默认) | 总是下载覆盖(Drive 作为权威源) | +| `smart`(推荐增量同步) | 本地 `modified_time` 已 ≥ 远端 `modified_time` 则跳过;时间戳缺失/非法时退回安全路径继续下载 | +| `skip` | 本地已存在则保持不动 | + +输出 schema: + +```json +{ + "summary": {"downloaded": 0, "skipped": 0, "failed": 0}, + "items": [ + {"rel_path": "sub/a.txt", "action": "downloaded"}, + {"rel_path": "b.txt", "action": "skipped"}, + {"rel_path": "c.bin", "action": "failed", "error": "..."} + ] +} +``` + +注意事项: + +- 只下载钉盘 `type=file` 的二进制文件;在线文档与快捷方式会被跳过。`rel_path` 始终以 `/` 分隔。 +- 下载目标始终被约束在 `--local-folder` 之内:远端名称含 `..`、路径分隔符或盘符等非规范成分的条目会被跳过,拼接后仍逃逸出根目录的路径记为 `failed`、不会落盘。 +- 下载成功后本地文件 mtime 会对齐到远端 `modified_time`,便于后续 `--if-exists smart` 增量同步跳过。 +- `summary.failed > 0` 时命令以**非零退出码**退出(`error.type=partial_failure`),同一份 `summary + items` 在 `error.detail` 里返回;脚本/agent 直接看 exit code 即可判断成败。 + +### 把本地文件夹推送(镜像)到钉盘 + +只写远端命令:把本地 `--local-folder` **单向、文件级**镜像到钉盘 `--remote-folder` 文件夹(本地 → Drive)。递归遍历本地文件与子目录(含空目录),缺失的远端目录按需创建(已存在则复用、不重建),文件按 `--if-exists` 新建/覆盖/跳过。**只新增/覆盖,不删除远端多余文件。** + +``` +Usage: + dws drive push [flags] +Example: + dws drive push --local-folder /abs/path/repo --remote-folder + dws drive push --local-folder /abs/path/repo --remote-folder --if-exists smart + dws drive push --local-folder /abs/path/repo --remote-folder --if-exists overwrite +Flags: + --local-folder string 本地文件夹绝对路径 (必填) + --remote-folder string 钉盘目标文件夹 ID (dentryUuid) (必填) + --space-id string 钉盘空间 ID,不传则使用「我的文件」(可选) + --if-exists string 远端文件已存在时的策略: skip|smart|overwrite (默认 skip) +``` + +`--if-exists` 策略(注意默认与 pull 相反,push 默认 `skip` 更安全): + +| 值 | 行为 | +|----|------| +| `skip`(默认) | 远端已存在则保持不动,只新增 | +| `smart` | 增量同步:远端 `modified_time` 已 ≥ 本地则跳过,否则走覆盖路径 | +| `overwrite` | 覆盖远端同名文件(原地覆盖,保留 fileId,不产生重名副本) | + +输出 schema(`action`:`uploaded` / `overwritten` / `skipped` / `folder_created` / `failed`): + +```json +{ + "summary": {"uploaded": 0, "skipped": 0, "failed": 0, "aborted": false}, + "items": [ + {"rel_path": "sub", "action": "folder_created"}, + {"rel_path": "a.txt", "action": "uploaded", "size_bytes": 11}, + {"rel_path": "b.txt", "action": "overwritten", "size_bytes": 8}, + {"rel_path": "c.txt", "action": "skipped", "size_bytes": 5}, + {"rel_path": "d.bin", "action": "failed", "size_bytes": 0, "error": "..."} + ] +} +``` + +注意事项: + +- 只上传/覆盖 `type=file`;`summary.uploaded` 同时统计新建与覆盖,**不含目录**。 +- `overwrite` / `smart` 命中覆盖分支时走**覆盖上传**(`get_upload_info` 与 `commit_upload` 两阶段都携带远端 `overwriteFileId`、不传 `parentId`),在原文件上原地覆盖、保留 fileId,不会在同目录新建重名副本。 +- 本地子目录(含空目录)整体镜像:缺失的按需 `create_folder`(以 `folder_created` 留痕),已存在的远端目录复用其 fileId、不重建、不出现在 `items[]`。 +- `summary.failed > 0` 时命令以**非零退出码**退出;结构化 `summary + items` 仍打印在 stdout 上,脚本/agent 直接看 exit code 判断成败。 + +### 本地文件夹与钉盘文件夹双向同步 + +读写命令:把本地 `--local-folder` 与钉盘 `--remote-folder` 做**文件级双向同步**。先按 `status` 同源逻辑算出五类差异,再分别处理:`new_remote` 下载到本地、`new_local` 上传到钉盘、两侧都变更的 `modified` 按 `--on-conflict` 策略消解;`unchanged` 与 `unknown` 一律跳过、不动。**只新增/覆盖,两侧都不删除多余文件。** + +``` +Usage: + dws drive sync [flags] +Example: + dws drive sync --local-folder /abs/path/repo --remote-folder + dws drive sync --local-folder /abs/path/repo --remote-folder --on-conflict local-wins + dws drive sync --local-folder /abs/path/repo --remote-folder --on-conflict keep-both + dws drive sync --local-folder /abs/path/repo --remote-folder --quick +Flags: + --local-folder string 本地文件夹绝对路径 (必填) + --remote-folder string 钉盘文件夹 ID (dentryUuid) (必填) + --space-id string 钉盘空间 ID,不传则使用「我的文件」(可选) + --on-conflict string 两侧都变更时的策略: remote-wins|local-wins|keep-both|ask (默认 remote-wins) + --quick 快速模式:只比较 modified_time,不计算 MD5 (可选) +``` + +`--on-conflict` 仅作用于 `modified`(两侧都存在且都变更)的文件: + +| 值 | 行为 | +|----|------| +| `remote-wins`(默认) | 下载远端覆盖本地 | +| `local-wins` | 覆盖上传本地到远端(原地覆盖、保留 fileId) | +| `keep-both` | 本地先改名保留副本(`名.conflict-.扩展名`),再把远端拉到原名;拉取失败会回滚改名、恢复原文件 | +| `ask` | 逐个交互询问;`--dry-run` 或非交互环境下等价于跳过 | + +输出 schema(`action`:`downloaded` / `uploaded` / `overwritten` / `folder_created` / `renamed_local` / `skipped` / `failed`;`direction`:`pull` / `push` / `conflict`): + +```json +{ + "detection": "exact", + "diff": { + "new_local": [{"rel_path": "a.txt"}], + "new_remote": [{"rel_path": "b.txt"}], + "modified": [{"rel_path": "c.txt"}], + "unchanged": [], + "unknown": [] + }, + "summary": {"pulled": 1, "pushed": 1, "skipped": 0, "failed": 0}, + "items": [ + {"rel_path": "b.txt", "action": "downloaded", "direction": "pull"}, + {"rel_path": "a.txt", "action": "uploaded", "direction": "push"}, + {"rel_path": "c.txt", "action": "overwritten", "direction": "conflict"} + ] +} +``` + +注意事项: + +- 复用 `status`/`pull`/`push` 的全部安全约束:只处理 `type=file`(在线文档、快捷方式跳过);远端名称含 `..`、路径分隔符或盘符等非规范成分会被跳过,拼接后逃逸出 `--local-folder` 的路径记为 `failed` 不落盘;下载走「先写临时文件、成功才原子 rename」,失败绝不破坏本地原文件。 +- `--dry-run` 只算差异并打印 `diff`,不触发任何下载/上传/改名/落盘。 +- `unknown`(exact 模式远端无可靠 MD5)一律计入 `skipped`、不做任何写操作;需要强制对齐时改用单向的 `pull`/`push`。 +- `summary.failed > 0` 时命令以**非零退出码**退出,结构化结果仍打印在 stdout 上。 + ## 意图判断 用户说"我的文件/钉盘/网盘/云盘" → `list` @@ -365,6 +566,10 @@ Flags: 用户说"公开文件/互联网公开/设置公开/让互联网所有人可访问" → `publish set` 用户说"关闭公开/取消公开/取消互联网访问" → `publish unset` 用户说"查看公开状态/是否公开/发布状态" → `publish get` +用户说"比较本地和云盘/看哪些文件变了/同步差异/diff" → `status` +用户说"把钉盘文件夹拉到本地/下载整个文件夹/镜像/同步到本地/pull" → `pull` +用户说"把本地文件夹传到钉盘/推送整个文件夹/上传目录/同步到云端/push" → `push` +用户说"双向同步/两边同步/本地和云盘互相同步/让两边一致/sync" → `sync`(两侧都变更时用 `--on-conflict` 决定谁覆盖谁) 关键区分: drive(文件管理) vs doc(文档内容读写) vs wiki(空间管理) @@ -413,6 +618,23 @@ dws drive delete --node --yes --format json # 8. 查看回收站并还原文件 dws drive recycle list --format json dws drive recycle restore --id --format json + +# 9. 比较本地文件夹与钉盘文件夹的差异(只读;remote-folder 必传,用 list 查 dentryUuid) +dws drive status --local-folder /abs/path/repo --remote-folder --format json +dws drive status --local-folder /abs/path/repo --remote-folder --space-id --format json +dws drive status --local-folder /abs/path/repo --remote-folder --quick --format json + +# 10. 把钉盘文件夹镜像到本地(Drive → 本地;smart 为推荐的增量同步) +dws drive pull --local-folder /abs/path/repo --remote-folder --format json +dws drive pull --local-folder /abs/path/repo --remote-folder --if-exists smart --format json + +# 11. 把本地文件夹镜像到钉盘(本地 → Drive;默认 skip 只新增,smart 增量,overwrite 覆盖) +dws drive push --local-folder /abs/path/repo --remote-folder --format json +dws drive push --local-folder /abs/path/repo --remote-folder --if-exists smart --format json + +# 12. 本地与钉盘双向同步(两侧都变更时用 --on-conflict 消解,默认 remote-wins) +dws drive sync --local-folder /abs/path/repo --remote-folder --format json +dws drive sync --local-folder /abs/path/repo --remote-folder --on-conflict keep-both --format json ``` ## 文档空间管理命令 @@ -531,6 +753,7 @@ dws drive move --node <源文件dentryUuid> --folder <目标space的rootFolderId # ── 场景 C: 复制钉盘文件到钉盘 space 下的子文件夹 ── dws drive list --space-id --format json dws drive copy --node <源文件dentryUuid> --folder <目标文件夹fileId> --format json + ``` ## 上下文传递表 diff --git a/skills/multi/dingtalk-drive/SKILL.md b/skills/multi/dingtalk-drive/SKILL.md index b3ce3ba6..b22a7283 100644 --- a/skills/multi/dingtalk-drive/SKILL.md +++ b/skills/multi/dingtalk-drive/SKILL.md @@ -1,6 +1,6 @@ --- name: dingtalk-drive -description: 钉钉文件管理(存储层)。Use when 用户说 钉盘/上传文件/下载文件/文件夹/查文件/找文件/全局搜索文件/复制/移动/重命名/删除/回收站/还原删除文件/权限管理/普通文件下载。任何文件类型都适用;文档内容编辑走 dingtalk-doc,知识库空间和空间内节点管理走 dingtalk-wiki。命令前缀:dws drive。 +description: 钉钉文件管理(存储层)。Use when 用户说 钉盘/上传文件/下载文件/文件夹/查文件/找文件/全局搜索文件/复制/移动/重命名/删除/回收站/还原删除文件/权限管理/普通文件下载/本地与钉盘文件夹差异比较/整个文件夹拉到本地/整个文件夹推到钉盘/双向同步。任何文件类型都适用;文档内容编辑走 dingtalk-doc,知识库空间和空间内节点管理走 dingtalk-wiki。命令前缀:dws drive。 metadata: cli_version: ">=0.2.14" category: product diff --git a/skills/multi/dingtalk-drive/references/drive.md b/skills/multi/dingtalk-drive/references/drive.md index 29680fa2..0b35d622 100644 --- a/skills/multi/dingtalk-drive/references/drive.md +++ b/skills/multi/dingtalk-drive/references/drive.md @@ -328,6 +328,207 @@ Flags: - `hasMore` — 是否有更多数据 - `nextCursor` — 下次分页游标 +### 比较本地文件夹与钉盘文件夹的差异 + +只读命令:比较本地文件夹与钉盘文件夹的差异——本地取 `--local-folder`(**绝对路径**),钉盘取 `--remote-folder`(文件夹 dentryUuid,**必传**)指向的文件夹,按精确 MD5(默认)或快速 modified_time(`--quick`)逐文件比对。两侧各自递归遍历,`rel_path` 相对各自根目录。 + +``` +Usage: + dws drive status [flags] +Example: + dws drive status --local-folder /abs/path/repo --remote-folder + dws drive status --local-folder /abs/path/repo --remote-folder --space-id xxxx + dws drive status --local-folder /abs/path/repo --remote-folder --quick +Flags: + --local-folder string 本地文件夹绝对路径 (必填) + --remote-folder string 钉盘文件夹 ID (dentryUuid) (必填) + --space-id string 钉盘空间 ID,不传则使用「我的文件」(可选) + --quick 快速模式:只比较 modified_time,不计算 MD5 (可选) +``` + +输出五类差异(`rel_path` 始终以 `/` 分隔、相对各自根目录): + +| 字段 | 含义 | +|------|------| +| `new_local` | 仅本地存在 | +| `new_remote` | 仅钉盘存在 | +| `modified` | 两侧都存在且本次检测判定为已变更(exact 比 MD5,quick 比 modified_time) | +| `unchanged` | 两侧都存在且本次检测判定为未变更 | +| `unknown` | 两侧都存在,但 exact 模式下**远端未返回可靠 MD5**、无法核对内容——既不判 unchanged 也不判 modified,如实归入此类(quick 模式不会产生 unknown) | + +输出 schema: + +```json +{ + "detection": "exact", + "new_local": [{"rel_path": "..."}], + "new_remote": [{"rel_path": "..."}], + "modified": [{"rel_path": "..."}], + "unchanged": [{"rel_path": "..."}], + "unknown": [{"rel_path": "..."}] +} +``` + +注意事项: + +- 默认 `detection=exact`(比较 MD5);传 `--quick` 后 `detection=quick`(只比较 modified_time,best-effort)。 +- exact 模式**只在能拿到远端 MD5 时才判定 unchanged/modified**;远端缺失 MD5 的文件一律进入 `unknown`,绝不会因大小 / mtime 恰好相同而被误报为 unchanged。当前 `list_files` 通常不返回 MD5,因此这类文件多会落在 `unknown`——请据此决定是否用 `pull`/`push` 强制对齐。 +- 本地 hash 仅在文件双端都存在、远端有 MD5、且非 `--quick` 模式时才按需计算。 +- 远端条目名称若含 `..`、路径分隔符或盘符等非规范成分,会被直接跳过、不纳入比对(防止逃逸性路径)。 +- 只比对钉盘 `type=file` 的二进制文件;在线文档(docx/sheet/bitable/mindnote/slides)与快捷方式(shortcut)会被跳过。本地只比对常规文件(符号链接、设备文件忽略)。 +- `--local-folder` 必须是绝对路径(相对路径会被直接拒绝);`--remote-folder` 必传,是钉盘侧待比对文件夹的 dentryUuid(可用 `dws drive list` 查到)。 + +### 把钉盘文件夹拉取(镜像)到本地 + +只写本地命令:把 `--remote-folder` 指向的钉盘文件夹**单向、文件级**镜像到本地 `--local-folder`(Drive → 本地)。递归下载所有 `type=file` 的文件,子目录自动创建。 + +``` +Usage: + dws drive pull [flags] +Example: + dws drive pull --local-folder /abs/path/repo --remote-folder + dws drive pull --local-folder /abs/path/repo --remote-folder --if-exists smart + dws drive pull --local-folder /abs/path/repo --remote-folder --space-id xxxx +Flags: + --local-folder string 本地文件夹绝对路径 (必填) + --remote-folder string 钉盘文件夹 ID (dentryUuid) (必填) + --space-id string 钉盘空间 ID,不传则使用「我的文件」(可选) + --if-exists string 本地文件已存在时的策略: overwrite|smart|skip (默认 overwrite) +``` + +`--if-exists` 策略: + +| 值 | 行为 | +|----|------| +| `overwrite`(默认) | 总是下载覆盖(Drive 作为权威源) | +| `smart`(推荐增量同步) | 本地 `modified_time` 已 ≥ 远端 `modified_time` 则跳过;时间戳缺失/非法时退回安全路径继续下载 | +| `skip` | 本地已存在则保持不动 | + +输出 schema: + +```json +{ + "summary": {"downloaded": 0, "skipped": 0, "failed": 0}, + "items": [ + {"rel_path": "sub/a.txt", "action": "downloaded"}, + {"rel_path": "b.txt", "action": "skipped"}, + {"rel_path": "c.bin", "action": "failed", "error": "..."} + ] +} +``` + +注意事项: + +- 只下载钉盘 `type=file` 的二进制文件;在线文档与快捷方式会被跳过。`rel_path` 始终以 `/` 分隔。 +- 下载目标始终被约束在 `--local-folder` 之内:远端名称含 `..`、路径分隔符或盘符等非规范成分的条目会被跳过,拼接后仍逃逸出根目录的路径记为 `failed`、不会落盘。 +- 下载成功后本地文件 mtime 会对齐到远端 `modified_time`,便于后续 `--if-exists smart` 增量同步跳过。 +- `summary.failed > 0` 时命令以**非零退出码**退出(`error.type=partial_failure`),同一份 `summary + items` 在 `error.detail` 里返回;脚本/agent 直接看 exit code 即可判断成败。 + +### 把本地文件夹推送(镜像)到钉盘 + +只写远端命令:把本地 `--local-folder` **单向、文件级**镜像到钉盘 `--remote-folder` 文件夹(本地 → Drive)。递归遍历本地文件与子目录(含空目录),缺失的远端目录按需创建(已存在则复用、不重建),文件按 `--if-exists` 新建/覆盖/跳过。**只新增/覆盖,不删除远端多余文件。** + +``` +Usage: + dws drive push [flags] +Example: + dws drive push --local-folder /abs/path/repo --remote-folder + dws drive push --local-folder /abs/path/repo --remote-folder --if-exists smart + dws drive push --local-folder /abs/path/repo --remote-folder --if-exists overwrite +Flags: + --local-folder string 本地文件夹绝对路径 (必填) + --remote-folder string 钉盘目标文件夹 ID (dentryUuid) (必填) + --space-id string 钉盘空间 ID,不传则使用「我的文件」(可选) + --if-exists string 远端文件已存在时的策略: skip|smart|overwrite (默认 skip) +``` + +`--if-exists` 策略(注意默认与 pull 相反,push 默认 `skip` 更安全): + +| 值 | 行为 | +|----|------| +| `skip`(默认) | 远端已存在则保持不动,只新增 | +| `smart` | 增量同步:远端 `modified_time` 已 ≥ 本地则跳过,否则走覆盖路径 | +| `overwrite` | 覆盖远端同名文件(原地覆盖,保留 fileId,不产生重名副本) | + +输出 schema(`action`:`uploaded` / `overwritten` / `skipped` / `folder_created` / `failed`): + +```json +{ + "summary": {"uploaded": 0, "skipped": 0, "failed": 0, "aborted": false}, + "items": [ + {"rel_path": "sub", "action": "folder_created"}, + {"rel_path": "a.txt", "action": "uploaded", "size_bytes": 11}, + {"rel_path": "b.txt", "action": "overwritten", "size_bytes": 8}, + {"rel_path": "c.txt", "action": "skipped", "size_bytes": 5}, + {"rel_path": "d.bin", "action": "failed", "size_bytes": 0, "error": "..."} + ] +} +``` + +注意事项: + +- 只上传/覆盖 `type=file`;`summary.uploaded` 同时统计新建与覆盖,**不含目录**。 +- `overwrite` / `smart` 命中覆盖分支时走**覆盖上传**(`get_upload_info` 与 `commit_upload` 两阶段都携带远端 `overwriteFileId`、不传 `parentId`),在原文件上原地覆盖、保留 fileId,不会在同目录新建重名副本。 +- 本地子目录(含空目录)整体镜像:缺失的按需 `create_folder`(以 `folder_created` 留痕),已存在的远端目录复用其 fileId、不重建、不出现在 `items[]`。 +- `summary.failed > 0` 时命令以**非零退出码**退出;结构化 `summary + items` 仍打印在 stdout 上,脚本/agent 直接看 exit code 判断成败。 + +### 本地文件夹与钉盘文件夹双向同步 + +读写命令:把本地 `--local-folder` 与钉盘 `--remote-folder` 做**文件级双向同步**。先按 `status` 同源逻辑算出五类差异,再分别处理:`new_remote` 下载到本地、`new_local` 上传到钉盘、两侧都变更的 `modified` 按 `--on-conflict` 策略消解;`unchanged` 与 `unknown` 一律跳过、不动。**只新增/覆盖,两侧都不删除多余文件。** + +``` +Usage: + dws drive sync [flags] +Example: + dws drive sync --local-folder /abs/path/repo --remote-folder + dws drive sync --local-folder /abs/path/repo --remote-folder --on-conflict local-wins + dws drive sync --local-folder /abs/path/repo --remote-folder --on-conflict keep-both + dws drive sync --local-folder /abs/path/repo --remote-folder --quick +Flags: + --local-folder string 本地文件夹绝对路径 (必填) + --remote-folder string 钉盘文件夹 ID (dentryUuid) (必填) + --space-id string 钉盘空间 ID,不传则使用「我的文件」(可选) + --on-conflict string 两侧都变更时的策略: remote-wins|local-wins|keep-both|ask (默认 remote-wins) + --quick 快速模式:只比较 modified_time,不计算 MD5 (可选) +``` + +`--on-conflict` 仅作用于 `modified`(两侧都存在且都变更)的文件: + +| 值 | 行为 | +|----|------| +| `remote-wins`(默认) | 下载远端覆盖本地 | +| `local-wins` | 覆盖上传本地到远端(原地覆盖、保留 fileId) | +| `keep-both` | 本地先改名保留副本(`名.conflict-.扩展名`),再把远端拉到原名;拉取失败会回滚改名、恢复原文件 | +| `ask` | 逐个交互询问;`--dry-run` 或非交互环境下等价于跳过 | + +输出 schema(`action`:`downloaded` / `uploaded` / `overwritten` / `folder_created` / `renamed_local` / `skipped` / `failed`;`direction`:`pull` / `push` / `conflict`): + +```json +{ + "detection": "exact", + "diff": { + "new_local": [{"rel_path": "a.txt"}], + "new_remote": [{"rel_path": "b.txt"}], + "modified": [{"rel_path": "c.txt"}], + "unchanged": [], + "unknown": [] + }, + "summary": {"pulled": 1, "pushed": 1, "skipped": 0, "failed": 0}, + "items": [ + {"rel_path": "b.txt", "action": "downloaded", "direction": "pull"}, + {"rel_path": "a.txt", "action": "uploaded", "direction": "push"}, + {"rel_path": "c.txt", "action": "overwritten", "direction": "conflict"} + ] +} +``` + +注意事项: + +- 复用 `status`/`pull`/`push` 的全部安全约束:只处理 `type=file`(在线文档、快捷方式跳过);远端名称含 `..`、路径分隔符或盘符等非规范成分会被跳过,拼接后逃逸出 `--local-folder` 的路径记为 `failed` 不落盘;下载走「先写临时文件、成功才原子 rename」,失败绝不破坏本地原文件。 +- `--dry-run` 只算差异并打印 `diff`,不触发任何下载/上传/改名/落盘。 +- `unknown`(exact 模式远端无可靠 MD5)一律计入 `skipped`、不做任何写操作;需要强制对齐时改用单向的 `pull`/`push`。 +- `summary.failed > 0` 时命令以**非零退出码**退出,结构化结果仍打印在 stdout 上。 + ## 意图判断 用户说"我的文件/钉盘/网盘/云盘" → `list` @@ -357,6 +558,10 @@ Flags: 用户说"公开文件/互联网公开/设置公开/让互联网所有人可访问" → `publish set` 用户说"关闭公开/取消公开/取消互联网访问" → `publish unset` 用户说"查看公开状态/是否公开/发布状态" → `publish get` +用户说"比较本地和云盘/看哪些文件变了/同步差异/diff" → `status` +用户说"把钉盘文件夹拉到本地/下载整个文件夹/镜像/同步到本地/pull" → `pull` +用户说"把本地文件夹传到钉盘/推送整个文件夹/上传目录/同步到云端/push" → `push` +用户说"双向同步/两边同步/本地和云盘互相同步/让两边一致/sync" → `sync`(两侧都变更时用 `--on-conflict` 决定谁覆盖谁) 用户说"查任务状态/导出好了没/任务进度/导入状态" → 文档导出用 `dws doc export get --job-id `,导入用 `dws doc import get --task-id ` 关键区分: drive(文件管理) vs doc(文档内容读写) vs wiki(空间管理) @@ -412,6 +617,23 @@ dws drive star add --node --format json dws drive star remove --node --format json dws drive star list --format json dws drive star list --content-types doc,sheet --limit 10 --format json + +# 10. 比较本地文件夹与钉盘文件夹的差异(只读;remote-folder 必传,用 list 查 dentryUuid) +dws drive status --local-folder /abs/path/repo --remote-folder --format json +dws drive status --local-folder /abs/path/repo --remote-folder --space-id --format json +dws drive status --local-folder /abs/path/repo --remote-folder --quick --format json + +# 11. 把钉盘文件夹镜像到本地(Drive → 本地;smart 为推荐的增量同步) +dws drive pull --local-folder /abs/path/repo --remote-folder --format json +dws drive pull --local-folder /abs/path/repo --remote-folder --if-exists smart --format json + +# 12. 把本地文件夹镜像到钉盘(本地 → Drive;默认 skip 只新增,smart 增量,overwrite 覆盖) +dws drive push --local-folder /abs/path/repo --remote-folder --format json +dws drive push --local-folder /abs/path/repo --remote-folder --if-exists smart --format json + +# 13. 本地与钉盘双向同步(两侧都变更时用 --on-conflict 消解,默认 remote-wins) +dws drive sync --local-folder /abs/path/repo --remote-folder --format json +dws drive sync --local-folder /abs/path/repo --remote-folder --on-conflict keep-both --format json ``` ## 无权限引导申请工作流 From 185fbb1544a3fbe1403eef229bebb72de2693208 Mon Sep 17 00:00:00 2001 From: chichuan Date: Wed, 12 Aug 2026 12:43:44 +0800 Subject: [PATCH 02/86] chore(schema): record drive sync leaves as reviewed pending-review exclusions --- internal/cli/schema_command_exclusions.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/cli/schema_command_exclusions.go b/internal/cli/schema_command_exclusions.go index 328c1f42..3fb2158f 100644 --- a/internal/cli/schema_command_exclusions.go +++ b/internal/cli/schema_command_exclusions.go @@ -99,6 +99,10 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{ "ding message receiver-status", "ding message send-by-message", "ding message send-personal", + "drive pull", + "drive push", + "drive status", + "drive sync", "mail allow-list add", "mail allow-list list", "mail allow-list remove", From 01c1428b663f1985eac1fde43b0d098b608d332c Mon Sep 17 00:00:00 2001 From: chichuan Date: Wed, 12 Aug 2026 13:33:09 +0800 Subject: [PATCH 03/86] test(drive): cover sync family end-to-end paths and error branches --- internal/helpers/drive_pull_coverage_test.go | 260 ++++++++++ internal/helpers/drive_push_coverage_test.go | 463 ++++++++++++++++++ .../helpers/drive_status_coverage_test.go | 341 +++++++++++++ internal/helpers/drive_sync_coverage_test.go | 412 ++++++++++++++++ internal/helpers/drive_sync_edges_test.go | 302 ++++++++++++ 5 files changed, 1778 insertions(+) create mode 100644 internal/helpers/drive_pull_coverage_test.go create mode 100644 internal/helpers/drive_push_coverage_test.go create mode 100644 internal/helpers/drive_status_coverage_test.go create mode 100644 internal/helpers/drive_sync_coverage_test.go create mode 100644 internal/helpers/drive_sync_edges_test.go diff --git a/internal/helpers/drive_pull_coverage_test.go b/internal/helpers/drive_pull_coverage_test.go new file mode 100644 index 00000000..ef04e2d3 --- /dev/null +++ b/internal/helpers/drive_pull_coverage_test.go @@ -0,0 +1,260 @@ +package helpers + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// ────────────────────────────────────────────────────────── +// runDrivePull — 端到端:远端树 → 本地落盘 +// ────────────────────────────────────────────────────────── + +// pullListingCaller 返回固定的单层远端清单,download_file 给出预签名链接。 +func pullListingCaller(items string) *driveScriptCaller { + return &driveScriptCaller{reply: func(tool string, _ map[string]any, nth int) (string, error) { + switch tool { + case "list_files": + if nth > 0 { + return `{"result":{"items":[],"nextToken":""}}`, nil + } + return `{"result":{"items":[` + items + `],"nextToken":""}}`, nil + case "download_file": + return `{"result":{"downloadUrl":"https://oss.example.com/get","headers":{}}}`, nil + } + return `{"result":{},"success":true}`, nil + }} +} + +// 端到端 pull:远端两个文件都下载到本地,并自动创建缺失的本地根目录。 +func TestDrivePull_endToEndDownloadsAll(t *testing.T) { + root := filepath.Join(t.TempDir(), "created-by-pull") + caller := pullListingCaller( + `{"name":"a.txt","type":"file","fileId":"A","modifyTime":1000},` + + `{"name":"b.txt","type":"file","fileId":"B","modifyTime":2000}`) + + SetHTTPGetFile(func(_ context.Context, _ string, _ map[string]string, dest string) error { + return os.WriteFile(dest, []byte("remote"), 0o644) + }) + t.Cleanup(func() { SetHTTPGetFile(nil) }) + + if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, name := range []string{"a.txt", "b.txt"} { + if b, err := os.ReadFile(filepath.Join(root, name)); err != nil || string(b) != "remote" { + t.Errorf("%s not written: %v / %q", name, err, string(b)) + } + } +} + +// --if-exists skip:本地已存在则不下载。 +func TestDrivePull_ifExistsSkipKeepsLocal(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "a.txt"), "local-original") + caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":9000}`) + + SetHTTPGetFile(func(context.Context, string, map[string]string, string) error { + t.Error("skip must not download") + return nil + }) + t.Cleanup(func() { SetHTTPGetFile(nil) }) + + if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT", "--if-exists", "skip"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if b, _ := os.ReadFile(filepath.Join(root, "a.txt")); string(b) != "local-original" { + t.Errorf("local file changed: %q", string(b)) + } +} + +// --if-exists smart:本地 mtime 已 ≥ 远端 → 跳过;远端更新 → 下载。 +func TestDrivePull_ifExistsSmart(t *testing.T) { + t.Run("local newer skips", func(t *testing.T) { + root := t.TempDir() + p := filepath.Join(root, "a.txt") + mustWrite(t, p, "local") + local := readFileMTimeMillis(t, p) + caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":` + strconv.FormatInt(local-5000, 10) + `}`) + + SetHTTPGetFile(func(context.Context, string, map[string]string, string) error { + t.Error("smart must not download when local is newer") + return nil + }) + t.Cleanup(func() { SetHTTPGetFile(nil) }) + + if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT", "--if-exists", "smart"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if b, _ := os.ReadFile(p); string(b) != "local" { + t.Errorf("local file changed: %q", string(b)) + } + }) + + t.Run("remote newer downloads", func(t *testing.T) { + root := t.TempDir() + p := filepath.Join(root, "a.txt") + mustWrite(t, p, "local") + local := readFileMTimeMillis(t, p) + caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":` + strconv.FormatInt(local+60000, 10) + `}`) + + SetHTTPGetFile(func(_ context.Context, _ string, _ map[string]string, dest string) error { + return os.WriteFile(dest, []byte("fresh"), 0o644) + }) + t.Cleanup(func() { SetHTTPGetFile(nil) }) + + if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT", "--if-exists", "smart"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if b, _ := os.ReadFile(p); string(b) != "fresh" { + t.Errorf("expected download, got %q", string(b)) + } + }) +} + +// --if-exists 非法值直接拒绝。 +func TestDrivePull_invalidIfExistsRejected(t *testing.T) { + caller := pullListingCaller("") + err := runDriveCmd(t, caller, "pull", "--local-folder", t.TempDir(), "--remote-folder", "ROOT", "--if-exists", "bogus") + if err == nil || !strings.Contains(err.Error(), "--if-exists") { + t.Fatalf("expected --if-exists rejection, got %v", err) + } +} + +// space-id 透传到 download_file。 +func TestDrivePull_passesSpaceIDToDownload(t *testing.T) { + root := t.TempDir() + caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":1}`) + SetHTTPGetFile(func(_ context.Context, _ string, _ map[string]string, dest string) error { + return os.WriteFile(dest, []byte("x"), 0o644) + }) + t.Cleanup(func() { SetHTTPGetFile(nil) }) + + if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT", "--space-id", "SP9"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := caller.callsFor("download_file")[0].args["spaceId"]; got != "SP9" { + t.Errorf("download_file spaceId = %v, want SP9", got) + } +} + +// 下载失败 → 该项记 failed,命令以 partial_failure 非零退出。 +func TestDrivePull_downloadFailureIsPartialFailure(t *testing.T) { + root := t.TempDir() + caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":1}`) + SetHTTPGetFile(func(context.Context, string, map[string]string, string) error { return errTestDownload }) + t.Cleanup(func() { SetHTTPGetFile(nil) }) + + err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT") + var pf *drivePartialFailure + if !errors.As(err, &pf) { + t.Fatalf("expected drivePartialFailure, got %T %v", err, err) + } + if !strings.Contains(pf.raw, "partial_failure") { + t.Errorf("detail missing partial_failure: %s", pf.raw) + } +} + +// download_file 未返回下载链接 → failed(不是 panic,也不落盘)。 +func TestDrivePull_missingDownloadURLFails(t *testing.T) { + root := t.TempDir() + caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, nth int) (string, error) { + if tool == "list_files" { + if nth > 0 { + return `{"result":{"items":[],"nextToken":""}}`, nil + } + return `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A"}],"nextToken":""}}`, nil + } + return `{"result":{"fileId":"A"},"success":true}`, nil // 无 downloadUrl + }} + if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT"); err == nil { + t.Fatal("expected failure when download_file returns no URL") + } + if _, err := os.Stat(filepath.Join(root, "a.txt")); !os.IsNotExist(err) { + t.Error("nothing must be written when the download URL is missing") + } +} + +// download_file 的 MCP 调用本身失败 → failed。 +func TestDrivePull_downloadToolErrorFails(t *testing.T) { + root := t.TempDir() + caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, nth int) (string, error) { + if tool == "list_files" { + if nth > 0 { + return `{"result":{"items":[],"nextToken":""}}`, nil + } + return `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A"}],"nextToken":""}}`, nil + } + return "", errTestDownload + }} + if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT"); err == nil { + t.Fatal("expected failure when download_file errors") + } +} + +// 远端名称逃逸出本地根 → 记 failed,不落盘(resolveLocalTarget 二次确认)。 +func TestDrivePull_escapingRelPathIsFailed(t *testing.T) { + root := t.TempDir() + // 远端目录名合法,但目录内的软链把落盘点指到根目录外。 + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(root, "sub")); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + caller := &driveScriptCaller{reply: func(tool string, args map[string]any, _ int) (string, error) { + if tool != "list_files" { + return `{"result":{"downloadUrl":"https://oss.example.com/get"}}`, nil + } + switch args["parentId"] { + case "ROOT": + return `{"result":{"items":[{"name":"sub","type":"folder","fileId":"SUB"}],"nextToken":""}}`, nil + case "SUB": + return `{"result":{"items":[{"name":"leaked.txt","type":"file","fileId":"L"}],"nextToken":""}}`, nil + } + return `{"result":{"items":[],"nextToken":""}}`, nil + }} + SetHTTPGetFile(func(_ context.Context, _ string, _ map[string]string, dest string) error { + return os.WriteFile(dest, []byte("leak"), 0o644) + }) + t.Cleanup(func() { SetHTTPGetFile(nil) }) + + if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT"); err == nil { + t.Fatal("expected symlink escape to be reported as failure") + } + if _, err := os.Stat(filepath.Join(outside, "leaked.txt")); !os.IsNotExist(err) { + t.Error("file escaped the local root") + } +} + +// 目标父目录不可创建(同名常规文件占位)→ failed,不 panic。 +func TestPullOneFile_mkdirFailureIsFailed(t *testing.T) { + root := t.TempDir() + // "sub" 是普通文件,MkdirAll("sub") 必然失败。 + mustWrite(t, filepath.Join(root, "sub"), "occupied") + + prevDeps := deps + prevArgs := os.Args + deps = &Deps{Caller: pullListingCaller(""), Out: &Formatter{w: io.Discard}} + os.Args = []string{"dws", "drive"} + t.Cleanup(func() { + deps = prevDeps + os.Args = prevArgs + }) + + rf := &remoteFile{RelPath: "sub/x.txt", FileID: "X"} + action, err := pullOneFile(context.Background(), "", rf, filepath.Join(root, "sub", "x.txt"), ifExistsOverwrite) + if action != pullActionFailed || err == nil { + t.Fatalf("action=%q err=%v, want failed + error", action, err) + } +} + +// 探测目录不存在时 isCaseInsensitiveFS 回退平台默认,不 panic。 +func TestIsCaseInsensitiveFS_missingDirFallsBackToPlatformDefault(t *testing.T) { + missing := filepath.Join(t.TempDir(), "absent") + // 只断言不 panic 且返回布尔(具体值取决于平台默认)。 + _ = isCaseInsensitiveFS(missing) +} diff --git a/internal/helpers/drive_push_coverage_test.go b/internal/helpers/drive_push_coverage_test.go new file mode 100644 index 00000000..1b2774f6 --- /dev/null +++ b/internal/helpers/drive_push_coverage_test.go @@ -0,0 +1,463 @@ +package helpers + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +var errTestUpload = errors.New("simulated upload failure") + +// pushOKCaller 是 push 的“一切正常”mock:list_files 按 parentId 返回远端现状, +// create_folder 返回新 fileId,get_upload_info 返回可解析凭证,ln 提交成功。 +func pushOKCaller(listing map[string]string) *driveScriptCaller { + return &driveScriptCaller{reply: func(tool string, args map[string]any, _ int) (string, error) { + switch tool { + case "list_files": + parent, _ := args["parentId"].(string) + if body, ok := listing[parent]; ok { + return body, nil + } + return `{"result":{"items":[],"nextToken":""}}`, nil + case "create_folder": + return `{"result":{"fileId":"NEWDIR"},"success":true}`, nil + case "get_upload_info": + return `{"result":{"resourceUrls":[{"url":"https://oss.example.com/put","headers":{}}],"uploadId":"U1"}}`, nil + } + return `{"result":{"fileId":"NEWFILE"},"success":true}`, nil + }} +} + +func withNoopPut(t *testing.T) { + t.Helper() + SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil }) + t.Cleanup(func() { SetHTTPPutFile(nil) }) +} + +// ────────────────────────────────────────────────────────── +// runDrivePush — 端到端 +// ────────────────────────────────────────────────────────── + +// 本地子目录在远端缺失 → 按需 create_folder 并留 folder_created 痕迹,再上传其中文件。 +func TestDrivePush_createsMissingRemoteFolders(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(dir, "sub", "deep.txt"), "d") + withNoopPut(t) + + caller := pushOKCaller(map[string]string{"ROOT": `{"result":{"items":[],"nextToken":""}}`}) + if err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + cf := caller.callsFor("create_folder") + if len(cf) != 1 || cf[0].args["name"] != "sub" { + t.Fatalf("expected create_folder(sub), got %v", cf) + } + if cf[0].args["parentId"] != "ROOT" { + t.Errorf("create_folder parentId = %v, want ROOT", cf[0].args["parentId"]) + } + // 新建目录的 fileId 必须成为其中文件的 parentId。 + up := caller.callsFor("get_upload_info") + if len(up) != 1 || up[0].args["parentId"] != "NEWDIR" { + t.Errorf("upload parentId = %v, want NEWDIR", up[0].args["parentId"]) + } +} + +// 远端已存在同名目录 → 复用其 fileId,不重建、不出现在 items[]。 +func TestDrivePush_reusesExistingRemoteFolder(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(dir, "sub", "deep.txt"), "d") + withNoopPut(t) + + caller := pushOKCaller(map[string]string{ + "ROOT": `{"result":{"items":[{"name":"sub","type":"folder","fileId":"EXISTING"}],"nextToken":""}}`, + }) + if err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := caller.callsFor("create_folder"); len(got) != 0 { + t.Errorf("existing remote folder must not be recreated, got %v", got) + } + if up := caller.callsFor("get_upload_info"); up[0].args["parentId"] != "EXISTING" { + t.Errorf("upload parentId = %v, want EXISTING", up[0].args["parentId"]) + } +} + +// create_folder 失败 / 未返回 fileId → 该目录记 failed,其中文件因父目录缺失也 failed。 +func TestDrivePush_folderCreationFailureCascades(t *testing.T) { + for _, tc := range []struct { + name string + reply func(string, map[string]any, int) (string, error) + }{ + {"tool error", func(tool string, _ map[string]any, _ int) (string, error) { + if tool == "create_folder" { + return "", errors.New("create_folder boom") + } + return `{"result":{"items":[],"nextToken":""}}`, nil + }}, + {"no fileId", func(tool string, _ map[string]any, _ int) (string, error) { + if tool == "create_folder" { + return `{"result":{},"success":true}`, nil + } + return `{"result":{"items":[],"nextToken":""}}`, nil + }}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(dir, "sub", "deep.txt"), "d") + withNoopPut(t) + + caller := &driveScriptCaller{reply: tc.reply} + err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT") + var pf *drivePushFailure + if !errors.As(err, &pf) { + t.Fatalf("expected drivePushFailure, got %T %v", err, err) + } + // 目录 + 其中文件各记一次失败。 + if pf.failed != 2 { + t.Errorf("failed = %d, want 2 (folder + orphaned file)", pf.failed) + } + if got := caller.callsFor("get_upload_info"); len(got) != 0 { + t.Errorf("no upload may happen without a parent folder, got %v", got) + } + }) + } +} + +// --if-exists skip(默认):远端已存在 → 跳过,不上传。 +func TestDrivePush_ifExistsSkipIsDefault(t *testing.T) { + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "a.txt"), "local") + withNoopPut(t) + + caller := pushOKCaller(map[string]string{ + "ROOT": `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A","modifyTime":1}],"nextToken":""}}`, + }) + if err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := caller.callsFor("get_upload_info"); len(got) != 0 { + t.Errorf("default skip must not upload, got %v", got) + } +} + +// --if-exists smart:远端时间已 ≥ 本地 → 跳过;远端更旧 → 覆盖上传。 +func TestDrivePush_ifExistsSmart(t *testing.T) { + t.Run("remote newer skips", func(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "a.txt") + mustWrite(t, p, "local") + local := readFileMTimeMillis(t, p) + withNoopPut(t) + + caller := pushOKCaller(map[string]string{ + "ROOT": `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A","modifyTime":` + + strconv.FormatInt(local+60000, 10) + `}],"nextToken":""}}`, + }) + if err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT", "--if-exists", "smart"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := caller.callsFor("get_upload_info"); len(got) != 0 { + t.Errorf("smart must skip when remote is newer, got %v", got) + } + }) + + t.Run("remote older overwrites in place", func(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "a.txt") + mustWrite(t, p, "local") + local := readFileMTimeMillis(t, p) + withNoopPut(t) + + caller := pushOKCaller(map[string]string{ + "ROOT": `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A","modifyTime":` + + strconv.FormatInt(local-60000, 10) + `}],"nextToken":""}}`, + }) + if err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT", "--if-exists", "smart"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + up := caller.callsFor("get_upload_info") + if len(up) != 1 || up[0].args["overwriteFileId"] != "A" { + t.Fatalf("smart overwrite must pass overwriteFileId=A, got %v", up) + } + if _, has := up[0].args["parentId"]; has { + t.Error("overwrite upload must not carry parentId") + } + }) + + t.Run("remote time invalid overwrites", func(t *testing.T) { + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "a.txt"), "local") + withNoopPut(t) + + // 远端无 modifyTime → 时间不可信,smart 不敢跳过,走覆盖。 + caller := pushOKCaller(map[string]string{ + "ROOT": `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A"}],"nextToken":""}}`, + }) + if err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT", "--if-exists", "smart"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if up := caller.callsFor("get_upload_info"); len(up) != 1 { + t.Fatalf("expected overwrite upload, got %v", up) + } + }) +} + +// 上传失败 → 记 failed 并以非零退出码退出,结构化结果仍打印。 +func TestDrivePush_uploadFailureExitsNonZero(t *testing.T) { + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "a.txt"), "local") + SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return errTestUpload }) + t.Cleanup(func() { SetHTTPPutFile(nil) }) + + caller := pushOKCaller(nil) + err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT") + var pf *drivePushFailure + if !errors.As(err, &pf) || pf.failed != 1 { + t.Fatalf("expected 1 push failure, got %T %v", err, err) + } +} + +// --if-exists 非法值直接拒绝。 +func TestDrivePush_invalidIfExistsRejected(t *testing.T) { + err := runDriveCmd(t, pushOKCaller(nil), "push", "--local-folder", t.TempDir(), "--remote-folder", "ROOT", "--if-exists", "bogus") + if err == nil || !strings.Contains(err.Error(), "--if-exists") { + t.Fatalf("expected --if-exists rejection, got %v", err) + } +} + +// space-id 必须透传到 create_folder / get_upload_info / ln 三处。 +func TestDrivePush_passesSpaceIDEverywhere(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(dir, "sub", "a.txt"), "x") + withNoopPut(t) + + caller := pushOKCaller(nil) + if err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT", "--space-id", "SP7"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, tool := range []string{"list_files", "create_folder", "get_upload_info"} { + calls := caller.callsFor(tool) + if len(calls) == 0 { + t.Fatalf("%s was never called", tool) + } + if calls[0].args["spaceId"] != "SP7" { + t.Errorf("%s spaceId = %v, want SP7", tool, calls[0].args["spaceId"]) + } + } +} + +// 远端列举失败 → push 直接报错,不做任何上传。 +func TestDrivePush_remoteListFailurePropagates(t *testing.T) { + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "a.txt"), "x") + caller := &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) { return "", errTestList }} + if err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT"); err == nil { + t.Fatal("expected remote listing failure to propagate") + } +} + +// 本地根目录不存在 → 扫描失败。 +func TestDrivePush_missingLocalRootFails(t *testing.T) { + missing := filepath.Join(t.TempDir(), "absent") + err := runDriveCmd(t, pushOKCaller(nil), "push", "--local-folder", missing, "--remote-folder", "ROOT") + if err == nil || !strings.Contains(err.Error(), "扫描本地目录失败") { + t.Fatalf("expected local scan failure, got %v", err) + } +} + +// ────────────────────────────────────────────────────────── +// walkRemoteForPush — 分页、递归、深度上限、非法/空名跳过 +// ────────────────────────────────────────────────────────── + +func TestWalkRemoteForPush_paginationRecursionAndSkips(t *testing.T) { + // 递归会插在 ROOT 的两页之间,所以按 (parentId, nextToken) 判定而非调用序号。 + caller := &driveScriptCaller{reply: func(_ string, args map[string]any, _ int) (string, error) { + parent, _ := args["parentId"].(string) + token, _ := args["nextToken"].(string) + if parent == "SUB" { + return `{"result":{"items":[{"name":"deep.txt","type":"file","fileId":"D"}],"nextToken":""}}`, nil + } + if parent == "ROOT" && token == "" { + return `{"result":{"items":[{"name":"sub","type":"folder","fileId":"SUB"}],"nextToken":"P2"}}`, nil + } + if parent == "ROOT" && token == "P2" { + return `{"result":{"items":[` + + `{"name":"","type":"file","fileId":"EMPTY"},` + + `{"name":"../bad.txt","type":"file","fileId":"BAD"},` + + `{"name":"ok.txt","type":"file","fileId":"OK"}` + + `],"nextToken":""}}`, nil + } + return `{"result":{"items":[],"nextToken":""}}`, nil + }} + + prevDeps, prevArgs := deps, os.Args + deps = &Deps{Caller: caller, Out: &Formatter{w: io.Discard}} + os.Args = []string{"dws", "drive"} + t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs }) + + files, folders, err := fetchRemoteTreeForPush(context.Background(), "", "ROOT") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := folders["sub"]; !ok { + t.Error("sub folder must be indexed") + } + if _, ok := files["sub/deep.txt"]; !ok { + t.Error("nested file must be indexed with joined rel_path") + } + if _, ok := files["ok.txt"]; !ok { + t.Error("second page file must be indexed") + } + for _, bad := range []string{"", "../bad.txt"} { + if _, ok := files[bad]; ok { + t.Errorf("unsafe/empty name %q must be skipped", bad) + } + } +} + +func TestWalkRemoteForPush_depthLimitAborts(t *testing.T) { + caller := &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) { + return `{"result":{"items":[{"name":"loop","type":"folder","fileId":"LOOP"}],"nextToken":""}}`, nil + }} + prevDeps, prevArgs := deps, os.Args + deps = &Deps{Caller: caller, Out: &Formatter{w: io.Discard}} + os.Args = []string{"dws", "drive"} + t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs }) + + if _, _, err := fetchRemoteTreeForPush(context.Background(), "", "ROOT"); err == nil || + !strings.Contains(err.Error(), "循环引用") { + t.Fatalf("expected depth-limit abort, got %v", err) + } +} + +// ────────────────────────────────────────────────────────── +// walkLocalForPush — 非常规文件与错误分支 +// ────────────────────────────────────────────────────────── + +func TestWalkLocalForPush_skipsIrregularAndMissingRoot(t *testing.T) { + if _, _, err := walkLocalForPush(filepath.Join(t.TempDir(), "absent")); err == nil { + t.Fatal("expected error for missing root") + } + + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "real.txt"), "x") + if err := os.Symlink(filepath.Join(dir, "absent"), filepath.Join(dir, "dangling")); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + _, files, err := walkLocalForPush(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, f := range files { + if f.RelPath == "dangling" { + t.Error("dangling symlink must not be pushed") + } + } +} + +// ────────────────────────────────────────────────────────── +// pushUploadFile — 凭证解析与提交失败分支 +// ────────────────────────────────────────────────────────── + +func TestPushUploadFile_failureBranches(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "a.txt") + mustWrite(t, p, "payload") + + cases := []struct { + name string + reply func(string, map[string]any, int) (string, error) + put func(context.Context, string, map[string]string, string, int64) error + }{ + {"get_upload_info tool error", + func(tool string, _ map[string]any, _ int) (string, error) { + if tool == "get_upload_info" { + return "", errTestUpload + } + return `{"result":{},"success":true}`, nil + }, nil}, + {"unparsable upload credential", + func(tool string, _ map[string]any, _ int) (string, error) { + if tool == "get_upload_info" { + return `{"result":{}}`, nil // 无 resourceUrls + } + return `{"result":{},"success":true}`, nil + }, nil}, + {"oss put fails", + func(string, map[string]any, int) (string, error) { + return `{"result":{"resourceUrls":[{"url":"https://oss.example.com/put","headers":{}}],"uploadId":"U1"}}`, nil + }, + func(context.Context, string, map[string]string, string, int64) error { return errTestUpload }}, + {"commit fails", + func(tool string, _ map[string]any, _ int) (string, error) { + if tool == "get_upload_info" { + return `{"result":{"resourceUrls":[{"url":"https://oss.example.com/put","headers":{}}],"uploadId":"U1"}}`, nil + } + return "", errTestUpload + }, nil}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + prevDeps, prevArgs := deps, os.Args + deps = &Deps{Caller: &driveScriptCaller{reply: tc.reply}, Out: &Formatter{w: io.Discard}} + os.Args = []string{"dws", "drive"} + put := tc.put + if put == nil { + put = func(context.Context, string, map[string]string, string, int64) error { return nil } + } + SetHTTPPutFile(put) + t.Cleanup(func() { + deps, os.Args = prevDeps, prevArgs + SetHTTPPutFile(nil) + }) + + if err := pushUploadFile(context.Background(), "SP", "PARENT", "", "a.txt", p, 7); err == nil { + t.Fatal("expected upload failure") + } + }) + } +} + +// pushCreateFolder:MCP 失败上抛,成功时从返回体提取 fileId。 +func TestPushCreateFolder_errorAndSuccess(t *testing.T) { + prevDeps, prevArgs := deps, os.Args + os.Args = []string{"dws", "drive"} + t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs }) + + deps = &Deps{Caller: &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) { + return "", errTestUpload + }}, Out: &Formatter{w: io.Discard}} + if _, err := pushCreateFolder(context.Background(), "", "P", "n"); err == nil { + t.Fatal("expected create_folder error to propagate") + } + + ok := &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) { + return `{"result":{"fileId":"FID42"},"success":true}`, nil + }} + deps = &Deps{Caller: ok, Out: &Formatter{w: io.Discard}} + got, err := pushCreateFolder(context.Background(), "SP", "P", "n") + if err != nil || got != "FID42" { + t.Fatalf("pushCreateFolder = (%q, %v), want (FID42, nil)", got, err) + } + if ok.calls[0].args["spaceId"] != "SP" || ok.calls[0].args["parentId"] != "P" { + t.Errorf("create_folder args = %v", ok.calls[0].args) + } +} diff --git a/internal/helpers/drive_status_coverage_test.go b/internal/helpers/drive_status_coverage_test.go new file mode 100644 index 00000000..e999a6a6 --- /dev/null +++ b/internal/helpers/drive_status_coverage_test.go @@ -0,0 +1,341 @@ +package helpers + +import ( + "context" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +// ────────────────────────────────────────────────────────── +// driveScriptCaller — 可编程 MCP mock:按 (toolName, 第几次调用) 决定返回, +// 覆盖分页、递归、错误分支等 walkRemoteDir / runDriveStatus 路径。 +// ────────────────────────────────────────────────────────── + +type driveScriptCaller struct { + calls []driveMCPCall + reply func(toolName string, args map[string]any, nth int) (string, error) + dryRun bool +} + +func (m *driveScriptCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) { + nth := 0 + for _, c := range m.calls { + if c.toolName == toolName { + nth++ + } + } + m.calls = append(m.calls, driveMCPCall{toolName: toolName, args: args}) + text, err := m.reply(toolName, args, nth) + if err != nil { + return nil, err + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil +} + +func (m *driveScriptCaller) Format() string { return "raw" } +func (m *driveScriptCaller) DryRun() bool { return m.dryRun } +func (m *driveScriptCaller) Fields() string { return "" } +func (m *driveScriptCaller) JQ() string { return "" } + +func (m *driveScriptCaller) callsFor(tool string) []driveMCPCall { + var out []driveMCPCall + for _, c := range m.calls { + if c.toolName == tool { + out = append(out, c) + } + } + return out +} + +var errTestList = errors.New("list_files boom") + +// runDriveCmd 以给定 caller 执行 `dws drive `,返回命令错误。 +func runDriveCmd(t *testing.T, caller edition.ToolCaller, args ...string) error { + t.Helper() + + prevDeps := deps + prevOSArgs := os.Args + deps = &Deps{Caller: caller, Out: &Formatter{w: io.Discard}} + + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().BoolP("yes", "y", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.AddCommand(newDriveCommand()) + + os.Args = append([]string{"dws", "drive"}, args...) + root.SetArgs(append([]string{"drive"}, args...)) + + t.Cleanup(func() { + deps = prevDeps + os.Args = prevOSArgs + }) + return root.Execute() +} + +// ────────────────────────────────────────────────────────── +// runDriveStatus — 端到端:本地 + 远端树 → diff +// ────────────────────────────────────────────────────────── + +// 端到端 status:递归进入子文件夹、拼接 rel_path、跳过非 file 类型。 +func TestDriveStatus_endToEndNestedTree(t *testing.T) { + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "root.txt"), "r") + if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(dir, "sub", "nested.txt"), "n") + + caller := &driveScriptCaller{reply: func(_ string, args map[string]any, _ int) (string, error) { + switch args["parentId"] { + case "ROOT": + return `{"result":{"items":[` + + `{"name":"root.txt","type":"file","fileId":"R1","modifyTime":1000},` + + `{"name":"sub","type":"folder","fileId":"SUB"},` + + `{"name":"online.adoc","type":"document","fileId":"D1"},` + + `{"name":"","type":"file","fileId":"EMPTY"},` + + `{"name":"../escape.txt","type":"file","fileId":"BAD"}` + + `],"nextToken":""}}`, nil + case "SUB": + return `{"result":{"items":[{"name":"nested.txt","type":"file","fileId":"N1","modifyTime":2000}],"nextToken":""}}`, nil + } + return `{"result":{"items":[],"nextToken":""}}`, nil + }} + + if err := runDriveCmd(t, caller, "status", "--local-folder", dir, "--remote-folder", "ROOT", "--quick"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // 递归进入 sub 一次;空名/非法名/在线文档都不产生额外调用。 + if got := len(caller.callsFor("list_files")); got != 2 { + t.Errorf("expected 2 list_files calls (ROOT + SUB), got %d", got) + } +} + +// 分页:首页返回 nextToken,第二页取完;token 未推进时必须终止而非死循环。 +func TestDriveStatus_paginationAndStalledToken(t *testing.T) { + dir := t.TempDir() + + caller := &driveScriptCaller{reply: func(_ string, args map[string]any, nth int) (string, error) { + switch nth { + case 0: + if _, ok := args["nextToken"]; ok { + t.Error("first page must not carry nextToken") + } + return `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A"}],"nextToken":"T1"}}`, nil + case 1: + if args["nextToken"] != "T1" { + t.Errorf("second page nextToken = %v, want T1", args["nextToken"]) + } + // 返回同一个 token → 未推进,必须 break。 + return `{"result":{"items":[{"name":"b.txt","type":"file","fileId":"B"}],"nextToken":"T1"}}`, nil + } + return `{"result":{"items":[],"nextToken":""}}`, nil + }} + + if err := runDriveCmd(t, caller, "status", "--local-folder", dir, "--remote-folder", "ROOT", "--quick"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := len(caller.callsFor("list_files")); got != 2 { + t.Fatalf("stalled token must stop paging after 2 calls, got %d", got) + } +} + +// space-id 显式传入时必须透传到 list_files。 +func TestDriveStatus_passesSpaceID(t *testing.T) { + dir := t.TempDir() + caller := &driveScriptCaller{reply: func(_ string, _ map[string]any, _ int) (string, error) { + return `{"result":{"items":[],"nextToken":""}}`, nil + }} + if err := runDriveCmd(t, caller, "status", "--local-folder", dir, "--remote-folder", "ROOT", "--space-id", "SP1"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := caller.callsFor("list_files")[0].args["spaceId"]; got != "SP1" { + t.Errorf("spaceId = %v, want SP1", got) + } +} + +// list_files 失败 → status 直接报错。 +func TestDriveStatus_listFailurePropagates(t *testing.T) { + dir := t.TempDir() + caller := &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) { return "", errTestList }} + if err := runDriveCmd(t, caller, "status", "--local-folder", dir, "--remote-folder", "ROOT"); err == nil { + t.Fatal("expected list_files error to propagate") + } +} + +// 返回体无法解析 → status 报解析失败。 +func TestDriveStatus_unparsableListPropagates(t *testing.T) { + dir := t.TempDir() + caller := &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) { + return `{"result":"not-a-list"}`, nil + }} + err := runDriveCmd(t, caller, "status", "--local-folder", dir, "--remote-folder", "ROOT") + if err == nil || !strings.Contains(err.Error(), "list_files") { + t.Fatalf("expected list_files parse error, got %v", err) + } +} + +// exact 模式下本地文件不可读 → MD5 计算失败要作为错误上抛,不能静默当成一致。 +func TestDriveStatus_unreadableLocalFileFailsExactCompare(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("chmod-based unreadable file needs POSIX perms and a non-root user") + } + dir := t.TempDir() + p := filepath.Join(dir, "secret.txt") + mustWrite(t, p, "data") + if err := os.Chmod(p, 0o000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(p, 0o644) }) + + caller := &driveScriptCaller{reply: func(_ string, _ map[string]any, _ int) (string, error) { + return `{"result":{"items":[{"name":"secret.txt","type":"file","fileId":"S","md5":"abc"}],"nextToken":""}}`, nil + }} + err := runDriveCmd(t, caller, "status", "--local-folder", dir, "--remote-folder", "ROOT") + if err == nil || !strings.Contains(err.Error(), "MD5") { + t.Fatalf("expected MD5 failure, got %v", err) + } +} + +// --local-folder 不是绝对路径 → 校验拦下,不发起任何 MCP 调用。 +func TestDriveStatus_relativeLocalFolderRejected(t *testing.T) { + caller := &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) { + t.Error("must not call MCP when --local-folder is rejected") + return "", nil + }} + if err := runDriveCmd(t, caller, "status", "--local-folder", "relative/path", "--remote-folder", "ROOT"); err == nil { + t.Fatal("expected relative --local-folder to be rejected") + } +} + +// ────────────────────────────────────────────────────────── +// walkRemoteDir — 深度上限 +// ────────────────────────────────────────────────────────── + +// 远端目录自引用时深度上限必须中止遍历,避免无限递归。 +func TestWalkRemoteDir_depthLimitAborts(t *testing.T) { + caller := &driveScriptCaller{reply: func(_ string, _ map[string]any, _ int) (string, error) { + // 每层都返回一个同名子文件夹 → 无限深。 + return `{"result":{"items":[{"name":"loop","type":"folder","fileId":"LOOP"}],"nextToken":""}}`, nil + }} + prevDeps := deps + prevOSArgs := os.Args + deps = &Deps{Caller: caller, Out: &Formatter{w: io.Discard}} + os.Args = []string{"dws", "drive"} // 工具路由需要命令上下文 + t.Cleanup(func() { + deps = prevDeps + os.Args = prevOSArgs + }) + + _, err := fetchRemoteDriveTree(context.Background(), "", "ROOT", true) + if err == nil || !strings.Contains(err.Error(), "循环引用") { + t.Fatalf("expected depth-limit abort, got %v", err) + } +} + +// ────────────────────────────────────────────────────────── +// isSafeRemoteSegment / resolveLocalTarget 的剩余分支 +// ────────────────────────────────────────────────────────── + +func TestIsSafeRemoteSegment_volumeAndNonCanonical(t *testing.T) { + if runtime.GOOS == "windows" { + if isSafeRemoteSegment(`C:name.txt`) { + t.Error("volume-qualified name must be rejected") + } + } + // 非规范形式(Clean 后不等于自身)必须拒绝。 + for _, name := range []string{"./a.txt", "a/../b.txt", "a//b.txt"} { + if isSafeRemoteSegment(name) { + t.Errorf("non-canonical %q must be rejected", name) + } + } +} + +// ────────────────────────────────────────────────────────── +// md5File / walkLocalTree 错误分支 +// ────────────────────────────────────────────────────────── + +func TestMD5File_missingFile(t *testing.T) { + if _, err := md5File(filepath.Join(t.TempDir(), "nope.bin")); err == nil { + t.Fatal("expected error for missing file") + } +} + +func TestMD5File_directoryIsNotReadable(t *testing.T) { + // 目录不是常规文件:io.Copy 读取时必然报错。 + if _, err := md5File(t.TempDir()); err == nil { + t.Fatal("expected error when hashing a directory") + } +} + +func TestWalkLocalTree_missingRootErrors(t *testing.T) { + if _, err := walkLocalTree(filepath.Join(t.TempDir(), "absent")); err == nil { + t.Fatal("expected error for missing local root") + } +} + +// 非常规文件(FIFO/符号链接目标缺失等)不计入本地索引。 +func TestWalkLocalTree_skipsIrregularEntries(t *testing.T) { + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "real.txt"), "x") + if err := os.Symlink(filepath.Join(dir, "absent"), filepath.Join(dir, "dangling")); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + got, err := walkLocalTree(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := got["dangling"]; ok { + t.Error("dangling symlink must not be indexed") + } + if _, ok := got["real.txt"]; !ok { + t.Error("regular file must be indexed") + } +} + +// ────────────────────────────────────────────────────────── +// toMillis 的 json.Number / float64 分支 +// ────────────────────────────────────────────────────────── + +func TestDriveItem_modifiedMillisFromNumericForms(t *testing.T) { + if got, ok := (driveItem{"modifyTime": float64(1500)}).modifiedMillis(); !ok || got != 1500 { + t.Errorf("float64 modifyTime = (%d,%v), want (1500,true)", got, ok) + } + if got, ok := (driveItem{"modifyTime": json.Number("2500")}).modifiedMillis(); !ok || got != 2500 { + t.Errorf("json.Number modifyTime = (%d,%v), want (2500,true)", got, ok) + } + if _, ok := (driveItem{"modifyTime": json.Number("nope")}).modifiedMillis(); ok { + t.Error("malformed json.Number modifyTime must be invalid") + } + if _, ok := (driveItem{"modifyTime": float64(0)}).modifiedMillis(); ok { + t.Error("non-positive modifyTime must be invalid") + } + if _, ok := (driveItem{}).modifiedMillis(); ok { + t.Error("missing modifyTime must be invalid") + } +} + +// fileSize 的 float64 / json.Number 两种形态,以及缺失时的兜底。 +func TestDriveItem_sizeForms(t *testing.T) { + if got := (driveItem{"fileSize": float64(42)}).size(); got != 42 { + t.Errorf("float fileSize = %d, want 42", got) + } + if got := (driveItem{"fileSize": json.Number("77")}).size(); got != 77 { + t.Errorf("json.Number fileSize = %d, want 77", got) + } + if got := (driveItem{"fileSize": json.Number("not-a-number")}).size(); got != 0 { + t.Errorf("malformed json.Number fileSize = %d, want 0", got) + } + if got := (driveItem{}).size(); got != 0 { + t.Errorf("missing fileSize = %d, want 0", got) + } +} diff --git a/internal/helpers/drive_sync_coverage_test.go b/internal/helpers/drive_sync_coverage_test.go new file mode 100644 index 00000000..069e89bc --- /dev/null +++ b/internal/helpers/drive_sync_coverage_test.go @@ -0,0 +1,412 @@ +package helpers + +import ( + "context" + "errors" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// syncCaller 组合 push 与 pull 两侧:list_files 按 parentId 返回远端现状, +// create_folder / get_upload_info / ln 走 push 路径,download_file 走 pull 路径。 +func syncCaller(listing map[string]string) *driveScriptCaller { + return &driveScriptCaller{reply: func(tool string, args map[string]any, _ int) (string, error) { + switch tool { + case "list_files": + parent, _ := args["parentId"].(string) + if body, ok := listing[parent]; ok { + return body, nil + } + return `{"result":{"items":[],"nextToken":""}}`, nil + case "create_folder": + return `{"result":{"fileId":"NEWDIR"},"success":true}`, nil + case "get_upload_info": + return `{"result":{"resourceUrls":[{"url":"https://oss.example.com/put","headers":{}}],"uploadId":"U1"}}`, nil + case "download_file": + return `{"result":{"downloadUrl":"https://oss.example.com/get","headers":{}}}`, nil + } + return `{"result":{"fileId":"NEWFILE"},"success":true}`, nil + }} +} + +// withSyncTransport 注入成功的上传与下载。 +func withSyncTransport(t *testing.T, body string) { + t.Helper() + SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil }) + SetHTTPGetFile(func(_ context.Context, _ string, _ map[string]string, dest string) error { + return os.WriteFile(dest, []byte(body), 0o644) + }) + t.Cleanup(func() { + SetHTTPPutFile(nil) + SetHTTPGetFile(nil) + }) +} + +// ────────────────────────────────────────────────────────── +// --on-conflict 取值处理 +// ────────────────────────────────────────────────────────── + +// 显式传空 --on-conflict 时回落默认 remote-wins(而非报非法取值)。 +func TestDriveSync_emptyOnConflictDefaultsToRemoteWins(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "f.txt") + mustWrite(t, p, "local-old") + local := readFileMTimeMillis(t, p) + withSyncTransport(t, "remote-new") + + caller := syncCaller(map[string]string{ + "ROOT": `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F","modifyTime":` + + differentMillis(local) + `}],"nextToken":""}}`, + }) + if err := runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT", + "--quick", "--on-conflict", ""); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if b, _ := os.ReadFile(p); string(b) != "remote-new" { + t.Errorf("empty --on-conflict must behave as remote-wins, got %q", string(b)) + } +} + +func TestDriveSync_invalidOnConflictRejected(t *testing.T) { + err := runDriveCmd(t, syncCaller(nil), "sync", "--local-folder", t.TempDir(), + "--remote-folder", "ROOT", "--on-conflict", "bogus") + if err == nil || !strings.Contains(err.Error(), "--on-conflict") { + t.Fatalf("expected --on-conflict rejection, got %v", err) + } +} + +// ────────────────────────────────────────────────────────── +// unchanged / unknown 分类 +// ────────────────────────────────────────────────────────── + +// quick 模式下两侧 mtime 相同 → unchanged,不做任何读写。 +func TestDriveSync_unchangedIsUntouched(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "same.txt") + mustWrite(t, p, "identical") + local := readFileMTimeMillis(t, p) + withSyncTransport(t, "should-not-be-used") + + caller := syncCaller(map[string]string{ + "ROOT": `{"result":{"items":[{"name":"same.txt","type":"file","fileId":"S","modifyTime":` + + strconv.FormatInt(local, 10) + `}],"nextToken":""}}`, + }) + if err := runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT", "--quick"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := len(caller.callsFor("download_file")) + len(caller.callsFor("get_upload_info")); got != 0 { + t.Errorf("unchanged file must not be transferred, got %d write calls", got) + } + if b, _ := os.ReadFile(p); string(b) != "identical" { + t.Errorf("unchanged file was modified: %q", string(b)) + } +} + +// exact 模式远端无可靠 md5 → unknown,一律计入 skipped 且不动任何一侧。 +func TestDriveSync_unknownIsSkipped(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "u.txt") + mustWrite(t, p, "local") + withSyncTransport(t, "remote") + + // 无 md5 字段 → exact 模式无法核对内容。 + caller := syncCaller(map[string]string{ + "ROOT": `{"result":{"items":[{"name":"u.txt","type":"file","fileId":"U","modifyTime":1}],"nextToken":""}}`, + }) + if err := runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := len(caller.callsFor("download_file")) + len(caller.callsFor("get_upload_info")); got != 0 { + t.Errorf("unknown must not be transferred, got %d write calls", got) + } + if b, _ := os.ReadFile(p); string(b) != "local" { + t.Errorf("unknown file was modified: %q", string(b)) + } +} + +// exact 模式下本地不可读 → judgeFileMatch 的 MD5 失败要中止整个 sync。 +func TestDriveSync_md5FailureAborts(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("chmod-based unreadable file needs a non-root user") + } + dir := t.TempDir() + p := filepath.Join(dir, "secret.txt") + mustWrite(t, p, "data") + if err := os.Chmod(p, 0o000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(p, 0o644) }) + + caller := syncCaller(map[string]string{ + "ROOT": `{"result":{"items":[{"name":"secret.txt","type":"file","fileId":"S","md5":"abc"}],"nextToken":""}}`, + }) + if err := runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT"); err == nil { + t.Fatal("expected MD5 failure to abort sync") + } +} + +// ────────────────────────────────────────────────────────── +// new_local 推送:按需建目录 +// ────────────────────────────────────────────────────────── + +// 本地子目录在远端缺失 → 建目录(记 folder_created + pushed)再上传其中文件。 +func TestDriveSync_createsRemoteFolderForNewLocal(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(dir, "sub", "deep.txt"), "d") + withSyncTransport(t, "unused") + + caller := syncCaller(nil) + if err := runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT", "--quick"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + cf := caller.callsFor("create_folder") + if len(cf) != 1 || cf[0].args["name"] != "sub" { + t.Fatalf("expected create_folder(sub), got %v", cf) + } + up := caller.callsFor("get_upload_info") + if len(up) != 1 || up[0].args["parentId"] != "NEWDIR" { + t.Errorf("upload parentId = %v, want NEWDIR", up[0].args["parentId"]) + } +} + +// 远端已有同名目录 → 复用,不重建。 +func TestDriveSync_reusesExistingRemoteFolder(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(dir, "sub", "deep.txt"), "d") + withSyncTransport(t, "unused") + + caller := syncCaller(map[string]string{ + "ROOT": `{"result":{"items":[{"name":"sub","type":"folder","fileId":"EXISTING"}],"nextToken":""}}`, + }) + if err := runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT", "--quick"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := caller.callsFor("create_folder"); len(got) != 0 { + t.Errorf("existing folder must be reused, got %v", got) + } +} + +// create_folder 失败 → 目录与其中文件都记 failed,命令非零退出。 +func TestDriveSync_folderCreationFailureCascades(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(dir, "sub", "deep.txt"), "d") + withSyncTransport(t, "unused") + + caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, _ int) (string, error) { + if tool == "create_folder" { + return "", errors.New("create_folder boom") + } + return `{"result":{"items":[],"nextToken":""}}`, nil + }} + err := runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT", "--quick") + var sf *driveSyncFailure + if !errors.As(err, &sf) || sf.failed != 2 { + t.Fatalf("expected 2 failures (folder + orphaned file), got %T %v", err, err) + } +} + +// new_local 上传失败 → 记 failed 并非零退出。 +func TestDriveSync_pushUploadFailureIsFailed(t *testing.T) { + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "only-local.txt"), "x") + SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return errTestUpload }) + t.Cleanup(func() { SetHTTPPutFile(nil) }) + + caller := syncCaller(nil) + err := runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT", "--quick") + var sf *driveSyncFailure + if !errors.As(err, &sf) || sf.failed != 1 { + t.Fatalf("expected 1 push failure, got %T %v", err, err) + } +} + +// ────────────────────────────────────────────────────────── +// --on-conflict ask 的每个交互答案 +// ────────────────────────────────────────────────────────── + +func TestDriveSyncAskConflict_answers(t *testing.T) { + cases := []struct { + input string + want string + isErr bool + }{ + {"r\n", syncConflictRemoteWins, false}, + {"remote\n", syncConflictRemoteWins, false}, + {"l\n", syncConflictLocalWins, false}, + {"local-wins\n", syncConflictLocalWins, false}, + {"k\n", syncConflictKeepBoth, false}, + {"keep\n", syncConflictKeepBoth, false}, + {"s\n", "", false}, + {"skip\n", "", false}, + {"\n", syncConflictRemoteWins, false}, // 交互式回车 → 默认远端优先 + {"nonsense\n", "", true}, + } + for _, tc := range cases { + t.Run(strings.TrimSpace(tc.input), func(t *testing.T) { + prev := syncAskStdin + syncAskStdin = strings.NewReader(tc.input) + t.Cleanup(func() { syncAskStdin = prev }) + + got, err := driveSyncAskConflict("f.txt") + if tc.isErr { + if err == nil { + t.Fatalf("expected error for %q", tc.input) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Errorf("driveSyncAskConflict(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} + +// ask 选到无效值时整个 sync 中止(错误上抛,不是静默跳过)。 +func TestDriveSync_askInvalidChoiceAborts(t *testing.T) { + prev := syncAskStdin + syncAskStdin = strings.NewReader("nonsense\n") + t.Cleanup(func() { syncAskStdin = prev }) + + dir := t.TempDir() + p := filepath.Join(dir, "f.txt") + mustWrite(t, p, "local") + local := readFileMTimeMillis(t, p) + withSyncTransport(t, "remote") + + caller := syncCaller(map[string]string{ + "ROOT": `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F","modifyTime":` + + differentMillis(local) + `}],"nextToken":""}}`, + }) + if err := runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT", + "--quick", "--on-conflict", "ask"); err == nil { + t.Fatal("expected invalid ask choice to abort sync") + } +} + +// ask 选 keep-both:本地改名保留副本,远端拉到原名。 +func TestDriveSync_askKeepBothViaStdin(t *testing.T) { + prev := syncAskStdin + syncAskStdin = strings.NewReader("k\n") + t.Cleanup(func() { syncAskStdin = prev }) + + dir := t.TempDir() + p := filepath.Join(dir, "f.txt") + mustWrite(t, p, "local-version") + local := readFileMTimeMillis(t, p) + withSyncTransport(t, "remote-version") + + caller := syncCaller(map[string]string{ + "ROOT": `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"FID12345678","modifyTime":` + + differentMillis(local) + `}],"nextToken":""}}`, + }) + if err := runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT", + "--quick", "--on-conflict", "ask"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if b, _ := os.ReadFile(p); string(b) != "remote-version" { + t.Errorf("original path must hold the remote version, got %q", string(b)) + } + // 本地副本以 .conflict- 保留。 + entries, _ := os.ReadDir(dir) + var found bool + for _, e := range entries { + if strings.Contains(e.Name(), ".conflict-") { + found = true + if b, _ := os.ReadFile(filepath.Join(dir, e.Name())); string(b) != "local-version" { + t.Errorf("conflict copy content = %q, want local-version", string(b)) + } + } + } + if !found { + t.Errorf("no .conflict-* copy kept, dir = %v", entries) + } +} + +// keep-both 改名失败(目标被目录占位)→ 记 failed,本地原文件保持不动。 +func TestDriveSync_keepBothRenameFailureKeepsLocal(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "f.txt") + mustWrite(t, p, "local-version") + local := readFileMTimeMillis(t, p) + withSyncTransport(t, "remote-version") + + // 让候选改名目标 f.conflict-FID12345.txt 被一个目录占住 → O_EXCL 建占位失败。 + if err := os.MkdirAll(filepath.Join(dir, "f.conflict-ID123456.txt"), 0o755); err != nil { + t.Fatal(err) + } + + caller := syncCaller(map[string]string{ + "ROOT": `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"FID123456","modifyTime":` + + differentMillis(local) + `}],"nextToken":""}}`, + }) + // 无论走到哪个失败分支,本地原文件都不能被破坏。 + _ = runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT", + "--quick", "--on-conflict", "keep-both") + if _, err := os.Stat(p); err != nil { + t.Fatalf("local file must still exist: %v", err) + } +} + +// ────────────────────────────────────────────────────────── +// dry-run 与 unknown 组合 +// ────────────────────────────────────────────────────────── + +// dry-run 下即使有 new_local / new_remote / modified 也不产生任何写调用。 +func TestDriveSync_dryRunSkipsEveryWrite(t *testing.T) { + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "local.txt"), "l") + p := filepath.Join(dir, "both.txt") + mustWrite(t, p, "local") + local := readFileMTimeMillis(t, p) + withSyncTransport(t, "remote") + + caller := syncCaller(map[string]string{ + "ROOT": `{"result":{"items":[` + + `{"name":"both.txt","type":"file","fileId":"B","modifyTime":` + differentMillis(local) + `},` + + `{"name":"remote.txt","type":"file","fileId":"R","modifyTime":2000}` + + `],"nextToken":""}}`, + }) + caller.dryRun = true + + if err := runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT", "--quick"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := len(caller.callsFor("download_file")) + len(caller.callsFor("get_upload_info")) + + len(caller.callsFor("create_folder")); got != 0 { + t.Errorf("dry-run must not write, got %d calls", got) + } + if _, err := os.Stat(filepath.Join(dir, "remote.txt")); !os.IsNotExist(err) { + t.Error("dry-run must not materialize remote files locally") + } +} + +// 远端列举失败 → sync 直接报错。 +func TestDriveSync_remoteListFailurePropagates(t *testing.T) { + caller := &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) { return "", errTestList }} + if err := runDriveCmd(t, caller, "sync", "--local-folder", t.TempDir(), "--remote-folder", "ROOT"); err == nil { + t.Fatal("expected remote listing failure to propagate") + } +} + +// 本地根目录不存在 → 扫描失败上抛。 +func TestDriveSync_missingLocalRootFails(t *testing.T) { + missing := filepath.Join(t.TempDir(), "absent") + if err := runDriveCmd(t, syncCaller(nil), "sync", "--local-folder", missing, "--remote-folder", "ROOT"); err == nil { + t.Fatal("expected missing local root to fail") + } +} diff --git a/internal/helpers/drive_sync_edges_test.go b/internal/helpers/drive_sync_edges_test.go new file mode 100644 index 00000000..99f5ab3f --- /dev/null +++ b/internal/helpers/drive_sync_edges_test.go @@ -0,0 +1,302 @@ +package helpers + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +var errTestRead = errors.New("simulated read failure") + +// errReader 让 bufio 读取返回非 EOF 错误,覆盖 ask 的读取失败分支。 +type errReader struct{} + +func (errReader) Read([]byte) (int, error) { return 0, errTestRead } + +// findDriveSubcommand 取出 drive 下的指定子命令,便于直接调用其 RunE。 +func findDriveSubcommand(t *testing.T, name string) *cobra.Command { + t.Helper() + for _, c := range newDriveCommand().Commands() { + if c.Name() == name { + return c + } + } + t.Fatalf("drive subcommand %q not found", name) + return nil +} + +// mustSetFlags 设置一组 flag,任何失败都直接终止用例。 +func mustSetFlags(t *testing.T, cmd *cobra.Command, kv map[string]string) { + t.Helper() + for k, v := range kv { + if err := cmd.Flags().Set(k, v); err != nil { + t.Fatalf("set --%s=%s: %v", k, v, err) + } + } +} + +// ────────────────────────────────────────────────────────── +// --if-exists 显式空值回落默认 +// ────────────────────────────────────────────────────────── + +func TestDrivePull_emptyIfExistsDefaultsToOverwrite(t *testing.T) { + root := t.TempDir() + p := filepath.Join(root, "a.txt") + mustWrite(t, p, "local-old") + caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":1}`) + SetHTTPGetFile(func(_ context.Context, _ string, _ map[string]string, dest string) error { + return os.WriteFile(dest, []byte("remote-new"), 0o644) + }) + t.Cleanup(func() { SetHTTPGetFile(nil) }) + + if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT", "--if-exists", ""); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if b, _ := os.ReadFile(p); string(b) != "remote-new" { + t.Errorf("empty --if-exists must behave as overwrite, got %q", string(b)) + } +} + +func TestDrivePush_emptyIfExistsDefaultsToSkip(t *testing.T) { + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "a.txt"), "local") + withNoopPut(t) + + caller := pushOKCaller(map[string]string{ + "ROOT": `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A","modifyTime":1}],"nextToken":""}}`, + }) + if err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT", "--if-exists", ""); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := caller.callsFor("get_upload_info"); len(got) != 0 { + t.Errorf("empty --if-exists must behave as skip, got %v", got) + } +} + +// ────────────────────────────────────────────────────────── +// pullOneFile 的落盘失败分支 +// ────────────────────────────────────────────────────────── + +// 目标目录不可写 → 创建临时文件失败。 +func TestPullOneFile_tempFileCreationFailure(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("read-only dir enforcement needs POSIX perms and a non-root user") + } + root := t.TempDir() + if err := os.Chmod(root, 0o500); err != nil { // r-x:可遍历不可写 + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(root, 0o755) }) + + prevDeps, prevArgs := deps, os.Args + deps = &Deps{Caller: pullListingCaller(""), Out: &Formatter{w: io.Discard}} + os.Args = []string{"dws", "drive"} + t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs }) + + action, err := pullOneFile(context.Background(), "", &remoteFile{RelPath: "a.txt", FileID: "A"}, + filepath.Join(root, "a.txt"), ifExistsOverwrite) + if action != pullActionFailed || err == nil { + t.Fatalf("action=%q err=%v, want failed + error", action, err) + } +} + +// 目标路径是目录 → 最后的原子 rename 失败。 +func TestPullOneFile_renameOntoDirectoryFails(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "a.txt") + if err := os.MkdirAll(target, 0o755); err != nil { // 目标被目录占位 + t.Fatal(err) + } + + prevDeps, prevArgs := deps, os.Args + deps = &Deps{Caller: pullListingCaller(""), Out: &Formatter{w: io.Discard}} + os.Args = []string{"dws", "drive"} + SetHTTPGetFile(func(_ context.Context, _ string, _ map[string]string, dest string) error { + return os.WriteFile(dest, []byte("payload"), 0o644) + }) + t.Cleanup(func() { + deps, os.Args = prevDeps, prevArgs + SetHTTPGetFile(nil) + }) + + action, err := pullOneFile(context.Background(), "", &remoteFile{RelPath: "a.txt", FileID: "A"}, + target, ifExistsOverwrite) + if action != pullActionFailed || err == nil { + t.Fatalf("action=%q err=%v, want failed + error", action, err) + } +} + +// ────────────────────────────────────────────────────────── +// PrintJSON 失败分支 +// ────────────────────────────────────────────────────────── + +func TestDrivePush_printFailurePropagates(t *testing.T) { + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "a.txt"), "x") + withNoopPut(t) + + prevDeps, prevArgs := deps, os.Args + deps = &Deps{Caller: pushOKCaller(nil), Out: &Formatter{w: failingWriter{}}} + os.Args = []string{"dws", "drive", "push"} + t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs }) + + cmd := findDriveSubcommand(t, "push") + mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"}) + if err := runDrivePush(cmd, nil); err == nil { + t.Fatal("expected the PrintJSON writer failure to propagate") + } +} + +func TestDriveSync_printFailurePropagates(t *testing.T) { + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "a.txt"), "x") + SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil }) + t.Cleanup(func() { SetHTTPPutFile(nil) }) + + prevDeps, prevArgs := deps, os.Args + deps = &Deps{Caller: syncCaller(nil), Out: &Formatter{w: failingWriter{}}} + os.Args = []string{"dws", "drive", "sync"} + t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs }) + + cmd := findDriveSubcommand(t, "sync") + mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT", "quick": "true"}) + if err := runDriveSync(cmd, nil); err == nil { + t.Fatal("expected the PrintJSON writer failure to propagate") + } +} + +// ────────────────────────────────────────────────────────── +// walkLocalForPush / walkLocalTree 的 WalkDir 错误回调 +// ────────────────────────────────────────────────────────── + +func TestWalkLocal_unreadableSubdirectoryErrors(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("unreadable dir needs POSIX perms and a non-root user") + } + dir := t.TempDir() + sub := filepath.Join(dir, "locked") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(sub, "inner.txt"), "x") + if err := os.Chmod(sub, 0o000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(sub, 0o755) }) + + if _, _, err := walkLocalForPush(dir); err == nil { + t.Error("walkLocalForPush must surface the WalkDir error") + } + if _, err := walkLocalTree(dir); err == nil { + t.Error("walkLocalTree must surface the WalkDir error") + } +} + +// ────────────────────────────────────────────────────────── +// verifyNoSymlinkEscape 的剩余分支 +// ────────────────────────────────────────────────────────── + +// 目标位于 absDir 之外 → 走到根目录之上即放行(不是逃逸判定范围)。 +func TestVerifyNoSymlinkEscape_targetOutsideRootIsIgnored(t *testing.T) { + root := t.TempDir() + outside := filepath.Join(t.TempDir(), "elsewhere", "x.txt") + if err := verifyNoSymlinkEscape(root, outside); err != nil { + t.Errorf("target above the root must be ignored, got %v", err) + } +} + +// 已存在路径含符号链接环 → EvalSymlinks 失败要如实上报。 +func TestVerifyNoSymlinkEscape_symlinkLoopErrors(t *testing.T) { + root := t.TempDir() + loop := filepath.Join(root, "loop") + if err := os.Symlink(loop, loop); err != nil { + t.Skipf("self-symlink unsupported: %v", err) + } + err := verifyNoSymlinkEscape(root, filepath.Join(loop, "child.txt")) + if err == nil || !strings.Contains(err.Error(), "解析路径") { + t.Fatalf("expected EvalSymlinks failure, got %v", err) + } +} + +// ────────────────────────────────────────────────────────── +// reserveSyncKeepBothTarget 的失败分支 +// ────────────────────────────────────────────────────────── + +// 候选目标逃逸出本地根(父目录是指向外部的软链)→ 直接失败。 +func TestReserveSyncKeepBothTarget_escapeFails(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(root, "sub")); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + if _, _, err := reserveSyncKeepBothTarget(root, "sub/f.txt", "FID12345678", map[string]bool{}); err == nil { + t.Fatal("expected escaping keep-both target to fail") + } +} + +// 候选目标所在目录不可写 → OpenFile 以非 EEXIST 错误失败,直接上报。 +func TestReserveSyncKeepBothTarget_unwritableDirFails(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("read-only dir enforcement needs POSIX perms and a non-root user") + } + root := t.TempDir() + if err := os.Chmod(root, 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(root, 0o755) }) + + if _, _, err := reserveSyncKeepBothTarget(root, "f.txt", "FID12345678", map[string]bool{}); err == nil { + t.Fatal("expected unwritable directory to fail") + } +} + +// occupied 中已知占用的首选候选被跳过,改用下一个后缀。 +func TestReserveSyncKeepBothTarget_skipsKnownOccupied(t *testing.T) { + root := t.TempDir() + first := syncKeepBothCandidate("f.txt", "FID12345678", 0) + occupied := map[string]bool{first: true} + + got, _, err := reserveSyncKeepBothTarget(root, "f.txt", "FID12345678", occupied) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == first { + t.Errorf("known-occupied candidate %q must be skipped", first) + } +} + +// ────────────────────────────────────────────────────────── +// ask 读取失败(非 EOF) +// ────────────────────────────────────────────────────────── + +func TestDriveSyncAskConflict_readErrorPropagates(t *testing.T) { + prev := syncAskStdin + syncAskStdin = errReader{} + t.Cleanup(func() { syncAskStdin = prev }) + + if _, err := driveSyncAskConflict("f.txt"); err == nil { + t.Fatal("expected read failure to propagate") + } +} + +// ────────────────────────────────────────────────────────── +// sync:本地根不可创建 +// ────────────────────────────────────────────────────────── + +// --local-folder 指向一个常规文件 → MkdirAll 失败。 +func TestDriveSync_localRootIsFileFails(t *testing.T) { + dir := t.TempDir() + asFile := filepath.Join(dir, "not-a-dir") + mustWrite(t, asFile, "x") + + if err := runDriveCmd(t, syncCaller(nil), "sync", "--local-folder", asFile, "--remote-folder", "ROOT", "--quick"); err == nil { + t.Fatal("expected a regular-file local root to fail") + } +} From 488411615f712ccf2ff4278ef205e0dfe351a6a4 Mon Sep 17 00:00:00 2001 From: chichuan Date: Wed, 12 Aug 2026 13:38:06 +0800 Subject: [PATCH 04/86] test(drive): cover parent-folder cascades and keep-both rollback paths --- internal/helpers/drive_sync_conflict_test.go | 248 +++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 internal/helpers/drive_sync_conflict_test.go diff --git a/internal/helpers/drive_sync_conflict_test.go b/internal/helpers/drive_sync_conflict_test.go new file mode 100644 index 00000000..148d7d94 --- /dev/null +++ b/internal/helpers/drive_sync_conflict_test.go @@ -0,0 +1,248 @@ +package helpers + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// failFolderCaller 让指定名字的 create_folder 失败,其余一切正常, +// 用于构造「父目录未能创建 → 子目录/子文件连锁失败」。 +func failFolderCaller(failName string) *driveScriptCaller { + return &driveScriptCaller{reply: func(tool string, args map[string]any, _ int) (string, error) { + switch tool { + case "list_files": + return `{"result":{"items":[],"nextToken":""}}`, nil + case "create_folder": + if args["name"] == failName { + return "", errors.New("create_folder rejected: " + failName) + } + return `{"result":{"fileId":"NEWDIR"},"success":true}`, nil + case "get_upload_info": + return `{"result":{"resourceUrls":[{"url":"https://oss.example.com/put","headers":{}}],"uploadId":"U1"}}`, nil + case "download_file": + return `{"result":{"downloadUrl":"https://oss.example.com/get","headers":{}}}`, nil + } + return `{"result":{"fileId":"NEWFILE"},"success":true}`, nil + }} +} + +// mkNested 建出 a/b/deep.txt 结构。 +func mkNested(t *testing.T) string { + t.Helper() + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "a", "b"), 0o755); err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(dir, "a", "b", "deep.txt"), "d") + return dir +} + +// 顶层目录 a 建失败 → 子目录 a/b 因父目录缺失也 failed,其中文件同样 failed。 +func TestDrivePush_nestedDirFailsWhenParentMissing(t *testing.T) { + dir := mkNested(t) + withNoopPut(t) + + caller := failFolderCaller("a") + err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT") + var pf *drivePushFailure + if !errors.As(err, &pf) { + t.Fatalf("expected drivePushFailure, got %T %v", err, err) + } + // a(建失败)+ a/b(父缺失)+ a/b/deep.txt(父缺失)= 3 + if pf.failed != 3 { + t.Errorf("failed = %d, want 3", pf.failed) + } + if got := caller.callsFor("get_upload_info"); len(got) != 0 { + t.Errorf("nothing may upload without a parent folder, got %v", got) + } +} + +func TestDriveSync_nestedDirFailsWhenParentMissing(t *testing.T) { + dir := mkNested(t) + withNoopPut(t) + + caller := failFolderCaller("a") + err := runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT", "--quick") + var sf *driveSyncFailure + if !errors.As(err, &sf) { + t.Fatalf("expected driveSyncFailure, got %T %v", err, err) + } + if sf.failed != 3 { + t.Errorf("failed = %d, want 3", sf.failed) + } +} + +// new_remote 落盘目标经软链逃逸出本地根 → 记 failed,不写到根目录之外。 +func TestDriveSync_newRemoteEscapeIsFailed(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(root, "sub")); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + withSyncTransport(t, "leak") + + caller := syncCaller(map[string]string{ + "ROOT": `{"result":{"items":[{"name":"sub","type":"folder","fileId":"SUB"}],"nextToken":""}}`, + "SUB": `{"result":{"items":[{"name":"leaked.txt","type":"file","fileId":"L","modifyTime":1}],"nextToken":""}}`, + }) + err := runDriveCmd(t, caller, "sync", "--local-folder", root, "--remote-folder", "ROOT", "--quick") + var sf *driveSyncFailure + if !errors.As(err, &sf) { + t.Fatalf("expected driveSyncFailure, got %T %v", err, err) + } + if _, statErr := os.Stat(filepath.Join(outside, "leaked.txt")); !os.IsNotExist(statErr) { + t.Error("file escaped the local root") + } +} + +// ────────────────────────────────────────────────────────── +// keep-both 的失败与回滚分支 +// ────────────────────────────────────────────────────────── + +// syncKeepBoth 在 rel 逃逸时直接 failed(resolveLocalTarget 报错)。 +func TestSyncKeepBoth_escapingRelIsFailed(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(root, "sub")); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + res := &driveSyncResult{} + syncKeepBoth(res, context.Background(), "", &remoteFile{RelPath: "sub/f.txt", FileID: "FID12345678"}, + root, "sub/f.txt", map[string]bool{}, map[string]bool{}) + if res.Summary.Failed != 1 { + t.Fatalf("failed = %d, want 1", res.Summary.Failed) + } +} + +// collided 命中时 keep-both 直接 failed,不做任何改名。 +func TestSyncKeepBoth_collidedIsFailed(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "f.txt"), "local") + res := &driveSyncResult{} + syncKeepBoth(res, context.Background(), "", &remoteFile{RelPath: "f.txt", FileID: "FID12345678"}, + root, "f.txt", map[string]bool{"f.txt": true}, map[string]bool{}) + if res.Summary.Failed != 1 { + t.Fatalf("failed = %d, want 1", res.Summary.Failed) + } + if b, _ := os.ReadFile(filepath.Join(root, "f.txt")); string(b) != "local" { + t.Errorf("collided keep-both must not touch the local file, got %q", string(b)) + } +} + +// 占用改名目标失败(目录不可写)→ failed。 +func TestSyncKeepBoth_reserveFailureIsFailed(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("read-only dir enforcement needs POSIX perms and a non-root user") + } + root := t.TempDir() + mustWrite(t, filepath.Join(root, "f.txt"), "local") + if err := os.Chmod(root, 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(root, 0o755) }) + + res := &driveSyncResult{} + syncKeepBoth(res, context.Background(), "", &remoteFile{RelPath: "f.txt", FileID: "FID12345678"}, + root, "f.txt", map[string]bool{}, map[string]bool{}) + if res.Summary.Failed != 1 { + t.Fatalf("failed = %d, want 1", res.Summary.Failed) + } +} + +// 改名本身失败(本地原文件其实是目录)→ failed,并清理刚建的空占位。 +func TestSyncKeepBoth_renameFailureCleansPlaceholder(t *testing.T) { + root := t.TempDir() + // rel 指向一个非空目录:os.Rename(dir, emptyFile) 会失败。 + if err := os.MkdirAll(filepath.Join(root, "f.txt", "inner"), 0o755); err != nil { + t.Fatal(err) + } + res := &driveSyncResult{} + occupied := map[string]bool{} + syncKeepBoth(res, context.Background(), "", &remoteFile{RelPath: "f.txt", FileID: "FID12345678"}, + root, "f.txt", map[string]bool{}, occupied) + + if res.Summary.Failed != 1 { + t.Fatalf("failed = %d, want 1", res.Summary.Failed) + } + // 空占位必须被清理,不留残留。 + if _, err := os.Stat(filepath.Join(root, syncKeepBothCandidate("f.txt", "FID12345678", 0))); !os.IsNotExist(err) { + t.Error("empty placeholder must be removed after a failed rename") + } +} + +// 拉取失败 → 回滚改名,本地文件恢复原名与原内容。 +func TestSyncKeepBoth_pullFailureRollsBackRename(t *testing.T) { + root := t.TempDir() + p := filepath.Join(root, "f.txt") + mustWrite(t, p, "local-version") + + prevDeps, prevArgs := deps, os.Args + deps = &Deps{Caller: syncCaller(nil), Out: &Formatter{w: io.Discard}} + os.Args = []string{"dws", "drive"} + SetHTTPGetFile(func(context.Context, string, map[string]string, string) error { return errTestDownload }) + t.Cleanup(func() { + deps, os.Args = prevDeps, prevArgs + SetHTTPGetFile(nil) + }) + + res := &driveSyncResult{} + syncKeepBoth(res, context.Background(), "", &remoteFile{RelPath: "f.txt", FileID: "FID12345678"}, + root, "f.txt", map[string]bool{}, map[string]bool{}) + + if res.Summary.Failed != 1 { + t.Fatalf("failed = %d, want 1", res.Summary.Failed) + } + if b, err := os.ReadFile(p); err != nil || string(b) != "local-version" { + t.Fatalf("rename must be rolled back: %v / %q", err, string(b)) + } + if _, err := os.Stat(filepath.Join(root, syncKeepBothCandidate("f.txt", "FID12345678", 0))); !os.IsNotExist(err) { + t.Error("renamed copy must not survive a successful rollback") + } +} + +// 拉取失败且回滚也失败 → 如实上报“本地版本保留为 <改名>”,不谎称成功。 +func TestSyncKeepBoth_rollbackFailureIsReported(t *testing.T) { + root := t.TempDir() + p := filepath.Join(root, "f.txt") + mustWrite(t, p, "local-version") + + prevDeps, prevArgs := deps, os.Args + deps = &Deps{Caller: syncCaller(nil), Out: &Formatter{w: io.Discard}} + os.Args = []string{"dws", "drive"} + // 下载失败前先把原名占住一个目录,使回滚 rename 无法复原。 + SetHTTPGetFile(func(context.Context, string, map[string]string, string) error { + if err := os.MkdirAll(filepath.Join(p, "blocker"), 0o755); err != nil { + return err + } + return errTestDownload + }) + t.Cleanup(func() { + deps, os.Args = prevDeps, prevArgs + SetHTTPGetFile(nil) + }) + + res := &driveSyncResult{} + syncKeepBoth(res, context.Background(), "", &remoteFile{RelPath: "f.txt", FileID: "FID12345678"}, + root, "f.txt", map[string]bool{}, map[string]bool{}) + + if res.Summary.Failed != 1 { + t.Fatalf("failed = %d, want 1", res.Summary.Failed) + } + if len(res.Items) != 1 || res.Items[0].Action != syncActionFailed { + t.Fatalf("items = %+v", res.Items) + } + // 数据没丢:本地版本仍以改名后的名字存在,且错误信息点明了这一点。 + suffixed := syncKeepBothCandidate("f.txt", "FID12345678", 0) + if b, err := os.ReadFile(filepath.Join(root, suffixed)); err != nil || string(b) != "local-version" { + t.Fatalf("local version must survive as %s: %v / %q", suffixed, err, string(b)) + } + if !strings.Contains(res.Items[0].Error, "回滚改名失败") { + t.Errorf("error must disclose the failed rollback, got %q", res.Items[0].Error) + } +} From f256d7a43c10c29161ded902aceb2e80078cba6a Mon Sep 17 00:00:00 2001 From: chichuan Date: Wed, 12 Aug 2026 13:57:48 +0800 Subject: [PATCH 05/86] refactor(drive): add case-detection seam, split Windows guards, extract walk callbacks --- internal/helpers/drive_pull.go | 15 +- internal/helpers/drive_push.go | 66 +++--- internal/helpers/drive_status.go | 72 +++---- internal/helpers/drive_status_unix.go | 8 + internal/helpers/drive_status_windows.go | 20 ++ internal/helpers/drive_sync_platform_test.go | 216 +++++++++++++++++++ 6 files changed, 328 insertions(+), 69 deletions(-) create mode 100644 internal/helpers/drive_status_unix.go create mode 100644 internal/helpers/drive_status_windows.go create mode 100644 internal/helpers/drive_sync_platform_test.go diff --git a/internal/helpers/drive_pull.go b/internal/helpers/drive_pull.go index f86adac1..3e1d875e 100644 --- a/internal/helpers/drive_pull.go +++ b/internal/helpers/drive_pull.go @@ -82,12 +82,21 @@ func pathCollisionKey(target string, caseInsensitive bool) string { return p } -// isCaseInsensitiveFS 探测 dir 所在文件系统是否大小写不敏感:在 dir 下创建一个随机 +// isCaseInsensitiveFS 是大小写探测的注入点(与 httpGetFile / httpPutFile 同样的 seam): +// 生产路径始终是 detectCaseInsensitiveFS,测试可替换它,以便在大小写敏感的 CI 文件系统上 +// 也能走到「等价路径冲突」分支。 +var isCaseInsensitiveFS = detectCaseInsensitiveFS + +// caseProbePattern 是探针文件名模板。抽成变量只为可测:纯数字模板的大写与原名相同, +// 可覆盖「名称无大小写差异、无法据此判定」的回退分支。 +var caseProbePattern = "dws-caseprobe-*" + +// detectCaseInsensitiveFS 探测 dir 所在文件系统是否大小写不敏感:在 dir 下创建一个随机 // 小写名探针文件,再用其大写名 stat;命中同一文件即为不敏感。dir 必须已存在。 // 探测失败时回退到平台默认(Windows / macOS 视为不敏感,其它敏感)。 -func isCaseInsensitiveFS(dir string) bool { +func detectCaseInsensitiveFS(dir string) bool { platformDefault := runtime.GOOS == "windows" || runtime.GOOS == "darwin" - f, err := os.CreateTemp(dir, "dws-caseprobe-*") + f, err := os.CreateTemp(dir, caseProbePattern) if err != nil { return platformDefault } diff --git a/internal/helpers/drive_push.go b/internal/helpers/drive_push.go index 52eb2827..fcce42c5 100644 --- a/internal/helpers/drive_push.go +++ b/internal/helpers/drive_push.go @@ -281,36 +281,7 @@ func walkLocalForPush(root string) ([]string, []localPushFile, error) { var dirs []string var files []localPushFile err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - rel, rerr := filepath.Rel(root, path) - if rerr != nil { - return rerr - } - if rel == "." { - return nil // 根目录本身不处理 - } - relSlash := filepath.ToSlash(rel) - if d.IsDir() { - dirs = append(dirs, relSlash) - return nil - } - info, ierr := d.Info() - if ierr != nil { - return ierr - } - // 只推送常规文件;符号链接、设备文件等忽略。 - if !info.Mode().IsRegular() { - return nil - } - files = append(files, localPushFile{ - RelPath: relSlash, - AbsPath: path, - ModTimeMillis: info.ModTime().UnixMilli(), - Size: info.Size(), - }) - return nil + return walkLocalForPushEntry(root, path, d, err, &dirs, &files) }) if err != nil { return nil, nil, err @@ -320,6 +291,41 @@ func walkLocalForPush(root string) ([]string, []localPushFile, error) { return dirs, files, nil } +// walkLocalForPushEntry 是 walkLocalForPush 的单条目处理逻辑。抽成命名函数只为可测: +// filepath.Rel / Info() 的失败在真实 WalkDir 下几乎不可复现,单测可直接注入。 +func walkLocalForPushEntry(root, path string, d fs.DirEntry, err error, dirs *[]string, files *[]localPushFile) error { + if err != nil { + return err + } + rel, rerr := filepath.Rel(root, path) + if rerr != nil { + return rerr + } + if rel == "." { + return nil // 根目录本身不处理 + } + relSlash := filepath.ToSlash(rel) + if d.IsDir() { + *dirs = append(*dirs, relSlash) + return nil + } + info, ierr := d.Info() + if ierr != nil { + return ierr + } + // 只推送常规文件;符号链接、设备文件等忽略。 + if !info.Mode().IsRegular() { + return nil + } + *files = append(*files, localPushFile{ + RelPath: relSlash, + AbsPath: path, + ModTimeMillis: info.ModTime().UnixMilli(), + Size: info.Size(), + }) + return nil +} + // pushCreateFolder 在 parentID 下创建名为 name 的文件夹,返回新目录的 fileId。 func pushCreateFolder(ctx context.Context, spaceID, parentID, name string) (string, error) { args := map[string]any{"name": name} diff --git a/internal/helpers/drive_status.go b/internal/helpers/drive_status.go index 7e43f3db..11239bc7 100644 --- a/internal/helpers/drive_status.go +++ b/internal/helpers/drive_status.go @@ -294,15 +294,9 @@ func isSafeRemoteSegment(name string) bool { if strings.ContainsAny(name, `/\`) { return false } - // Windows 卷标("C:"、"C:foo")等含盘符的名称一律拒绝。 - if filepath.VolumeName(name) != "" { - return false - } - // Clean 后若发生变化,说明名称非规范(含隐藏的 . / .. / 分隔符)。 - if filepath.Clean(name) != name { - return false - } - return true + // 余下的卷标/规范化检查只在 Windows 上可达:Unix 的 filepath.VolumeName 恒为空, + // 且经上面的分隔符过滤后 filepath.Clean 不会再改写名称。按平台分文件承载。 + return isSafeRemoteSegmentPlatform(name) } // resolveLocalTarget 把远端 rel_path 拼到本地根目录 absDir 下,并确认结果仍位于 @@ -385,33 +379,7 @@ func md5File(path string) (string, error) { func walkLocalTree(root string) (map[string]*localFile, error) { files := make(map[string]*localFile) err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - return nil - } - info, err := d.Info() - if err != nil { - return err - } - // 只比对常规文件;符号链接、设备文件等被忽略。 - if !info.Mode().IsRegular() { - return nil - } - rel, err := filepath.Rel(root, path) - if err != nil { - return err - } - relSlash := filepath.ToSlash(rel) - - files[relSlash] = &localFile{ - RelPath: relSlash, - AbsPath: path, - Size: info.Size(), - ModTimeMillis: info.ModTime().UnixMilli(), - } - return nil + return walkLocalTreeEntry(root, path, d, err, files) }) if err != nil { return nil, err @@ -419,6 +387,38 @@ func walkLocalTree(root string) (map[string]*localFile, error) { return files, nil } +// walkLocalTreeEntry 是 walkLocalTree 的单条目处理逻辑。抽成命名函数只为可测: +// Info() / filepath.Rel 的失败在真实 WalkDir 下几乎不可复现,单测可直接注入。 +func walkLocalTreeEntry(root, path string, d fs.DirEntry, err error, files map[string]*localFile) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + info, err := d.Info() + if err != nil { + return err + } + // 只比对常规文件;符号链接、设备文件等被忽略。 + if !info.Mode().IsRegular() { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + relSlash := filepath.ToSlash(rel) + + files[relSlash] = &localFile{ + RelPath: relSlash, + AbsPath: path, + Size: info.Size(), + ModTimeMillis: info.ModTime().UnixMilli(), + } + return nil +} + // 双端都存在文件的比对结论。 const ( matchUnchanged = "unchanged" diff --git a/internal/helpers/drive_status_unix.go b/internal/helpers/drive_status_unix.go new file mode 100644 index 00000000..fa5e9258 --- /dev/null +++ b/internal/helpers/drive_status_unix.go @@ -0,0 +1,8 @@ +//go:build !windows + +package helpers + +// isSafeRemoteSegmentPlatform 在非 Windows 平台没有额外约束:filepath.VolumeName +// 恒为空字符串,而调用方已过滤掉 ""、"."、".." 与任意平台分隔符,剩下的名称 +// filepath.Clean 不会再改写。 +func isSafeRemoteSegmentPlatform(string) bool { return true } diff --git a/internal/helpers/drive_status_windows.go b/internal/helpers/drive_status_windows.go new file mode 100644 index 00000000..b9ef2de4 --- /dev/null +++ b/internal/helpers/drive_status_windows.go @@ -0,0 +1,20 @@ +//go:build windows + +package helpers + +import "path/filepath" + +// isSafeRemoteSegmentPlatform 承载 Windows 专有的单层名称校验。 +// +// 拒绝含盘符的名称("C:"、"C:foo")——Windows 上 filepath.VolumeName 会识别它们, +// 拼进 rel_path 后可让落盘目标逃逸出本地根目录;并在 Clean 改写名称时拒绝,兜住 +// 分隔符过滤之外的非规范形式。 +func isSafeRemoteSegmentPlatform(name string) bool { + if filepath.VolumeName(name) != "" { + return false + } + if filepath.Clean(name) != name { + return false + } + return true +} diff --git a/internal/helpers/drive_sync_platform_test.go b/internal/helpers/drive_sync_platform_test.go new file mode 100644 index 00000000..0705b61f --- /dev/null +++ b/internal/helpers/drive_sync_platform_test.go @@ -0,0 +1,216 @@ +package helpers + +import ( + "context" + "errors" + "io/fs" + "os" + "path/filepath" + "runtime" + "testing" + "time" +) + +// forceCaseInsensitiveFS 在当前用例内把大小写探测钉成不敏感,使等价路径冲突分支 +// 在大小写敏感的 CI 文件系统(ext4)上同样可达。 +func forceCaseInsensitiveFS(t *testing.T) { + t.Helper() + prev := isCaseInsensitiveFS + isCaseInsensitiveFS = func(string) bool { return true } + t.Cleanup(func() { isCaseInsensitiveFS = prev }) +} + +// ────────────────────────────────────────────────────────── +// 等价路径冲突:pull / sync 都必须全部记 failed 且一个都不落盘 +// ────────────────────────────────────────────────────────── + +// 远端 A.txt 与 a.txt 在大小写不敏感目标上映射到同一本地文件 → 两项都 failed, +// 绝不顺序覆盖导致静默丢文件。 +func TestDrivePull_equivalentPathCollisionFailsBoth(t *testing.T) { + forceCaseInsensitiveFS(t) + root := t.TempDir() + + caller := pullListingCaller( + `{"name":"A.txt","type":"file","fileId":"UP","modifyTime":1},` + + `{"name":"a.txt","type":"file","fileId":"LOW","modifyTime":2}`) + SetHTTPGetFile(func(context.Context, string, map[string]string, string) error { + t.Error("collided entries must not be downloaded") + return nil + }) + t.Cleanup(func() { SetHTTPGetFile(nil) }) + + err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT") + var pf *drivePartialFailure + if !errors.As(err, &pf) { + t.Fatalf("expected drivePartialFailure, got %T %v", err, err) + } + for _, name := range []string{"A.txt", "a.txt"} { + if _, statErr := os.Stat(filepath.Join(root, name)); !os.IsNotExist(statErr) { + t.Errorf("%s must not be written on collision", name) + } + } +} + +// sync 的 new_remote 阶段同样拒绝等价冲突项。 +func TestDriveSync_equivalentPathCollisionFailsBoth(t *testing.T) { + forceCaseInsensitiveFS(t) + root := t.TempDir() + withSyncTransport(t, "remote") + + caller := syncCaller(map[string]string{ + "ROOT": `{"result":{"items":[` + + `{"name":"A.txt","type":"file","fileId":"UP","modifyTime":1},` + + `{"name":"a.txt","type":"file","fileId":"LOW","modifyTime":2}` + + `],"nextToken":""}}`, + }) + err := runDriveCmd(t, caller, "sync", "--local-folder", root, "--remote-folder", "ROOT", "--quick") + var sf *driveSyncFailure + if !errors.As(err, &sf) { + t.Fatalf("expected driveSyncFailure, got %T %v", err, err) + } + if sf.failed != 2 { + t.Errorf("failed = %d, want 2 (both collided entries)", sf.failed) + } +} + +// ────────────────────────────────────────────────────────── +// detectCaseInsensitiveFS:探针名无大小写差异时回退平台默认 +// ────────────────────────────────────────────────────────── + +func TestDetectCaseInsensitiveFS_caselessProbeFallsBackToPlatformDefault(t *testing.T) { + prev := caseProbePattern + caseProbePattern = "1234567890*" // 纯数字:ToUpper 与原名相同,无法据此判定 + t.Cleanup(func() { caseProbePattern = prev }) + + dir := t.TempDir() + got := detectCaseInsensitiveFS(dir) + // 回退值即平台默认;这里只断言它与平台默认一致,不写死具体布尔。 + want := runtime.GOOS == "windows" || runtime.GOOS == "darwin" + if got != want { + t.Errorf("detectCaseInsensitiveFS = %v, want platform default %v", got, want) + } +} + +// ────────────────────────────────────────────────────────── +// isSafeRemoteSegmentPlatform:非 Windows 平台无额外约束 +// ────────────────────────────────────────────────────────── + +func TestIsSafeRemoteSegmentPlatform_allowsPlainNames(t *testing.T) { + for _, name := range []string{"a.txt", "报告.pdf", "with space.md", "..."} { + if !isSafeRemoteSegmentPlatform(name) { + t.Errorf("isSafeRemoteSegmentPlatform(%q) = false, want true", name) + } + } +} + +// ────────────────────────────────────────────────────────── +// WalkDir 单条目处理:Info() 与 filepath.Rel 的失败分支 +// ────────────────────────────────────────────────────────── + +// errDirEntry 是一个 Info() 必然报错的 fs.DirEntry。 +type errDirEntry struct{ dir bool } + +func (e errDirEntry) Name() string { return "entry" } +func (e errDirEntry) IsDir() bool { return e.dir } +func (e errDirEntry) Type() fs.FileMode { return 0 } +func (e errDirEntry) Info() (fs.FileInfo, error) { return nil, errTestInfo } + +var errTestInfo = errors.New("simulated DirEntry.Info failure") + +// irregularDirEntry 的 Info() 成功,但报告为非常规文件(如设备文件)。 +type irregularDirEntry struct{} + +func (irregularDirEntry) Name() string { return "dev" } +func (irregularDirEntry) IsDir() bool { return false } +func (irregularDirEntry) Type() fs.FileMode { return fs.ModeDevice } +func (irregularDirEntry) Info() (fs.FileInfo, error) { return irregularFileInfo{}, nil } + +type irregularFileInfo struct{} + +func (irregularFileInfo) Name() string { return "dev" } +func (irregularFileInfo) Size() int64 { return 0 } +func (irregularFileInfo) Mode() fs.FileMode { return fs.ModeDevice } +func (irregularFileInfo) ModTime() time.Time { return time.Unix(0, 0) } +func (irregularFileInfo) IsDir() bool { return false } +func (irregularFileInfo) Sys() any { return nil } + +func TestWalkLocalTreeEntry_errorBranches(t *testing.T) { + files := map[string]*localFile{} + + // WalkDir 自身报错原样上抛。 + if err := walkLocalTreeEntry("/root", "/root/a", errDirEntry{}, errTestInfo, files); !errors.Is(err, errTestInfo) { + t.Errorf("incoming error must propagate, got %v", err) + } + // 目录条目直接跳过。 + if err := walkLocalTreeEntry("/root", "/root/sub", errDirEntry{dir: true}, nil, files); err != nil { + t.Errorf("directory entry must be skipped, got %v", err) + } + // Info() 失败上抛。 + if err := walkLocalTreeEntry("/root", "/root/a", errDirEntry{}, nil, files); !errors.Is(err, errTestInfo) { + t.Errorf("Info() error must propagate, got %v", err) + } + // 非常规文件忽略。 + if err := walkLocalTreeEntry("/root", "/root/dev", irregularDirEntry{}, nil, files); err != nil { + t.Errorf("irregular entry must be skipped, got %v", err) + } + // filepath.Rel 失败(root 绝对、path 相对)上抛。 + if err := walkLocalTreeEntry("/root", "relative/path", regularDirEntry{}, nil, files); err == nil { + t.Error("filepath.Rel error must propagate") + } + if len(files) != 0 { + t.Errorf("no entry should have been indexed, got %v", files) + } +} + +// regularDirEntry 的 Info() 返回一个常规文件,用于走到 filepath.Rel。 +type regularDirEntry struct{} + +func (regularDirEntry) Name() string { return "a" } +func (regularDirEntry) IsDir() bool { return false } +func (regularDirEntry) Type() fs.FileMode { return 0 } +func (regularDirEntry) Info() (fs.FileInfo, error) { return regularFileInfo{}, nil } + +type regularFileInfo struct{} + +func (regularFileInfo) Name() string { return "a" } +func (regularFileInfo) Size() int64 { return 1 } +func (regularFileInfo) Mode() fs.FileMode { return 0o644 } +func (regularFileInfo) ModTime() time.Time { return time.Unix(1, 0) } +func (regularFileInfo) IsDir() bool { return false } +func (regularFileInfo) Sys() any { return nil } + +func TestWalkLocalForPushEntry_errorBranches(t *testing.T) { + var dirs []string + var files []localPushFile + + // WalkDir 自身报错原样上抛。 + if err := walkLocalForPushEntry("/root", "/root/a", regularDirEntry{}, errTestInfo, &dirs, &files); !errors.Is(err, errTestInfo) { + t.Errorf("incoming error must propagate, got %v", err) + } + // filepath.Rel 失败上抛。 + if err := walkLocalForPushEntry("/root", "relative/path", regularDirEntry{}, nil, &dirs, &files); err == nil { + t.Error("filepath.Rel error must propagate") + } + // 根目录本身跳过。 + if err := walkLocalForPushEntry("/root", "/root", regularDirEntry{}, nil, &dirs, &files); err != nil { + t.Errorf("root itself must be skipped, got %v", err) + } + // Info() 失败上抛。 + if err := walkLocalForPushEntry("/root", "/root/a", errDirEntry{}, nil, &dirs, &files); !errors.Is(err, errTestInfo) { + t.Errorf("Info() error must propagate, got %v", err) + } + // 非常规文件忽略。 + if err := walkLocalForPushEntry("/root", "/root/dev", irregularDirEntry{}, nil, &dirs, &files); err != nil { + t.Errorf("irregular entry must be skipped, got %v", err) + } + // 目录条目进 dirs。 + if err := walkLocalForPushEntry("/root", "/root/sub", errDirEntry{dir: true}, nil, &dirs, &files); err != nil { + t.Errorf("directory entry must be recorded, got %v", err) + } + if len(dirs) != 1 || dirs[0] != "sub" { + t.Errorf("dirs = %v, want [sub]", dirs) + } + if len(files) != 0 { + t.Errorf("files = %v, want empty", files) + } +} From 6910bda9c72ee03e9a8545d4728e339904514b92 Mon Sep 17 00:00:00 2001 From: chichuan Date: Wed, 12 Aug 2026 14:09:35 +0800 Subject: [PATCH 06/86] refactor(drive): drop unreachable fixed-point guard in symlink escape check --- internal/helpers/drive_status.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/helpers/drive_status.go b/internal/helpers/drive_status.go index 11239bc7..eaeb360a 100644 --- a/internal/helpers/drive_status.go +++ b/internal/helpers/drive_status.go @@ -329,6 +329,10 @@ func verifyNoSymlinkEscape(absDir, target string) error { return nil } + // 循环必然终止,无需不动点兜底:每轮 ancestor 严格变短,要么越界后由下面的 ".." + // 分支退出,要么下降到 absDir 自身——absDir 必然存在(EvalSymlinks 刚成功),于是 + // 在 Lstat 成功分支退出。即便 absDir 在此期间被删除,下一轮 ancestor 升到 absDir + // 之上,同样由 ".." 分支退出。 ancestor := filepath.Dir(target) for { // 越过 absDir 边界(祖先跑到根目录之上)就停止:根目录之上不属于逃逸判定范围。 @@ -347,11 +351,7 @@ func verifyNoSymlinkEscape(absDir, target string) error { } return nil } - parent := filepath.Dir(ancestor) - if parent == ancestor { - return nil - } - ancestor = parent + ancestor = filepath.Dir(ancestor) } } From 0a90c0350d90820fd466709101777820449133be Mon Sep 17 00:00:00 2001 From: chichuan Date: Wed, 12 Aug 2026 14:26:45 +0800 Subject: [PATCH 07/86] docs(changelog): move release note to a .changes fragment --- .changes/drive-folder-sync.md | 15 +++++++++++++++ CHANGELOG.md | 14 -------------- 2 files changed, 15 insertions(+), 14 deletions(-) create mode 100644 .changes/drive-folder-sync.md diff --git a/.changes/drive-folder-sync.md b/.changes/drive-folder-sync.md new file mode 100644 index 00000000..a42fbf36 --- /dev/null +++ b/.changes/drive-folder-sync.md @@ -0,0 +1,15 @@ +--- +category: Added +--- + +- **Drive folder synchronization** — adds `dws drive status`, `dws drive pull`, + `dws drive push`, and `dws drive sync` for file-level comparison and transfer + between a local folder and a Drive folder. Differences come from exact MD5 by + default or from modification time with `--quick`; `status` is read-only, `pull` + and `push` are one-directional with `--if-exists skip|smart|overwrite`, and + `sync` is bidirectional with `--on-conflict remote-wins|local-wins|keep-both|ask`. + Only regular files are transferred — online documents and shortcuts are skipped, + neither side deletes extra files, downloads are staged through a temporary file + and committed with an atomic rename, and remote names that would escape + `--local-folder` are reported as failures instead of being written. Every command + prints a structured summary on stdout and exits non-zero when any item fails. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5077b270..02d4254e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,20 +6,6 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] -### Added - -- **Drive folder synchronization** — adds `dws drive status`, `dws drive pull`, - `dws drive push`, and `dws drive sync` for file-level comparison and transfer - between a local folder and a Drive folder. Differences come from exact MD5 by - default or from modification time with `--quick`; `status` is read-only, `pull` - and `push` are one-directional with `--if-exists skip|smart|overwrite`, and - `sync` is bidirectional with `--on-conflict remote-wins|local-wins|keep-both|ask`. - Only regular files are transferred — online documents and shortcuts are skipped, - neither side deletes extra files, downloads are staged through a temporary file - and committed with an atomic rename, and remote names that would escape - `--local-folder` are reported as failures instead of being written. Every command - prints a structured summary on stdout and exits non-zero when any item fails. - ## [1.0.58-beta.4] - 2026-08-12 ### Added From 54aefaaf6049c8c9278fa9a1814d3430159fd59f Mon Sep 17 00:00:00 2001 From: chichuan Date: Wed, 12 Aug 2026 15:22:08 +0800 Subject: [PATCH 08/86] test(drive): use testseam for seam swaps and expose tests to platform coverage runners --- internal/helpers/drive_pull_coverage_test.go | 22 ++++----- internal/helpers/drive_pull_test.go | 36 +++++++------- internal/helpers/drive_push_coverage_test.go | 30 ++++++------ internal/helpers/drive_push_test.go | 12 ++--- .../helpers/drive_status_coverage_test.go | 30 ++++++------ internal/helpers/drive_status_test.go | 34 +++++++------- internal/helpers/drive_sync_conflict_test.go | 18 +++---- internal/helpers/drive_sync_coverage_test.go | 47 +++++++++---------- internal/helpers/drive_sync_edges_test.go | 33 +++++++------ internal/helpers/drive_sync_platform_test.go | 23 +++++---- internal/helpers/drive_sync_test.go | 29 ++++++------ 11 files changed, 154 insertions(+), 160 deletions(-) diff --git a/internal/helpers/drive_pull_coverage_test.go b/internal/helpers/drive_pull_coverage_test.go index ef04e2d3..f59c93b5 100644 --- a/internal/helpers/drive_pull_coverage_test.go +++ b/internal/helpers/drive_pull_coverage_test.go @@ -32,7 +32,7 @@ func pullListingCaller(items string) *driveScriptCaller { } // 端到端 pull:远端两个文件都下载到本地,并自动创建缺失的本地根目录。 -func TestDrivePull_endToEndDownloadsAll(t *testing.T) { +func TestCrossPlatformCoverageDrivePull_endToEndDownloadsAll(t *testing.T) { root := filepath.Join(t.TempDir(), "created-by-pull") caller := pullListingCaller( `{"name":"a.txt","type":"file","fileId":"A","modifyTime":1000},` + @@ -54,7 +54,7 @@ func TestDrivePull_endToEndDownloadsAll(t *testing.T) { } // --if-exists skip:本地已存在则不下载。 -func TestDrivePull_ifExistsSkipKeepsLocal(t *testing.T) { +func TestCrossPlatformCoverageDrivePull_ifExistsSkipKeepsLocal(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, "a.txt"), "local-original") caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":9000}`) @@ -74,7 +74,7 @@ func TestDrivePull_ifExistsSkipKeepsLocal(t *testing.T) { } // --if-exists smart:本地 mtime 已 ≥ 远端 → 跳过;远端更新 → 下载。 -func TestDrivePull_ifExistsSmart(t *testing.T) { +func TestCrossPlatformCoverageDrivePull_ifExistsSmart(t *testing.T) { t.Run("local newer skips", func(t *testing.T) { root := t.TempDir() p := filepath.Join(root, "a.txt") @@ -118,7 +118,7 @@ func TestDrivePull_ifExistsSmart(t *testing.T) { } // --if-exists 非法值直接拒绝。 -func TestDrivePull_invalidIfExistsRejected(t *testing.T) { +func TestCrossPlatformCoverageDrivePull_invalidIfExistsRejected(t *testing.T) { caller := pullListingCaller("") err := runDriveCmd(t, caller, "pull", "--local-folder", t.TempDir(), "--remote-folder", "ROOT", "--if-exists", "bogus") if err == nil || !strings.Contains(err.Error(), "--if-exists") { @@ -127,7 +127,7 @@ func TestDrivePull_invalidIfExistsRejected(t *testing.T) { } // space-id 透传到 download_file。 -func TestDrivePull_passesSpaceIDToDownload(t *testing.T) { +func TestCrossPlatformCoverageDrivePull_passesSpaceIDToDownload(t *testing.T) { root := t.TempDir() caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":1}`) SetHTTPGetFile(func(_ context.Context, _ string, _ map[string]string, dest string) error { @@ -144,7 +144,7 @@ func TestDrivePull_passesSpaceIDToDownload(t *testing.T) { } // 下载失败 → 该项记 failed,命令以 partial_failure 非零退出。 -func TestDrivePull_downloadFailureIsPartialFailure(t *testing.T) { +func TestCrossPlatformCoverageDrivePull_downloadFailureIsPartialFailure(t *testing.T) { root := t.TempDir() caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":1}`) SetHTTPGetFile(func(context.Context, string, map[string]string, string) error { return errTestDownload }) @@ -161,7 +161,7 @@ func TestDrivePull_downloadFailureIsPartialFailure(t *testing.T) { } // download_file 未返回下载链接 → failed(不是 panic,也不落盘)。 -func TestDrivePull_missingDownloadURLFails(t *testing.T) { +func TestCrossPlatformCoverageDrivePull_missingDownloadURLFails(t *testing.T) { root := t.TempDir() caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, nth int) (string, error) { if tool == "list_files" { @@ -181,7 +181,7 @@ func TestDrivePull_missingDownloadURLFails(t *testing.T) { } // download_file 的 MCP 调用本身失败 → failed。 -func TestDrivePull_downloadToolErrorFails(t *testing.T) { +func TestCrossPlatformCoverageDrivePull_downloadToolErrorFails(t *testing.T) { root := t.TempDir() caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, nth int) (string, error) { if tool == "list_files" { @@ -198,7 +198,7 @@ func TestDrivePull_downloadToolErrorFails(t *testing.T) { } // 远端名称逃逸出本地根 → 记 failed,不落盘(resolveLocalTarget 二次确认)。 -func TestDrivePull_escapingRelPathIsFailed(t *testing.T) { +func TestCrossPlatformCoverageDrivePull_escapingRelPathIsFailed(t *testing.T) { root := t.TempDir() // 远端目录名合法,但目录内的软链把落盘点指到根目录外。 outside := t.TempDir() @@ -231,7 +231,7 @@ func TestDrivePull_escapingRelPathIsFailed(t *testing.T) { } // 目标父目录不可创建(同名常规文件占位)→ failed,不 panic。 -func TestPullOneFile_mkdirFailureIsFailed(t *testing.T) { +func TestCrossPlatformCoveragePullOneFile_mkdirFailureIsFailed(t *testing.T) { root := t.TempDir() // "sub" 是普通文件,MkdirAll("sub") 必然失败。 mustWrite(t, filepath.Join(root, "sub"), "occupied") @@ -253,7 +253,7 @@ func TestPullOneFile_mkdirFailureIsFailed(t *testing.T) { } // 探测目录不存在时 isCaseInsensitiveFS 回退平台默认,不 panic。 -func TestIsCaseInsensitiveFS_missingDirFallsBackToPlatformDefault(t *testing.T) { +func TestCrossPlatformCoverageIsCaseInsensitiveFS_missingDirFallsBackToPlatformDefault(t *testing.T) { missing := filepath.Join(t.TempDir(), "absent") // 只断言不 panic 且返回布尔(具体值取决于平台默认)。 _ = isCaseInsensitiveFS(missing) diff --git a/internal/helpers/drive_pull_test.go b/internal/helpers/drive_pull_test.go index c913b9cb..032eb1d7 100644 --- a/internal/helpers/drive_pull_test.go +++ b/internal/helpers/drive_pull_test.go @@ -31,7 +31,7 @@ func (m *drivePullMockCaller) JQ() string { return "" } // isSafeRemoteSegment — 远端名称逐段安全校验(拒绝逃逸成分) // ────────────────────────────────────────────────────────── -func TestIsSafeRemoteSegment(t *testing.T) { +func TestCrossPlatformCoverageIsSafeRemoteSegment(t *testing.T) { cases := []struct { name string ok bool @@ -59,7 +59,7 @@ func TestIsSafeRemoteSegment(t *testing.T) { // resolveLocalTarget — 拼接后必须仍位于本地根目录内 // ────────────────────────────────────────────────────────── -func TestResolveLocalTarget_withinRoot(t *testing.T) { +func TestCrossPlatformCoverageResolveLocalTarget_withinRoot(t *testing.T) { root := filepath.Clean(t.TempDir()) got, err := resolveLocalTarget(root, "sub/a.txt") if err != nil { @@ -72,7 +72,7 @@ func TestResolveLocalTarget_withinRoot(t *testing.T) { } // 即便 isSafeRemoteSegment 被绕过,逐段拼出的逃逸路径也必须被 resolveLocalTarget 兜住。 -func TestResolveLocalTarget_escapeRejected(t *testing.T) { +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 { @@ -83,7 +83,7 @@ func TestResolveLocalTarget_escapeRejected(t *testing.T) { // 根目录内的目录符号链接指向外部时,词法检查会放过(root/sub 仍在 root 下), // 必须靠符号链接解析挡住:sub -> /outside 时 sub/a.txt 应被拒绝。 -func TestResolveLocalTarget_dirSymlinkEscapeRejected(t *testing.T) { +func TestCrossPlatformCoverageResolveLocalTarget_dirSymlinkEscapeRejected(t *testing.T) { root := filepath.Clean(t.TempDir()) outside := filepath.Clean(t.TempDir()) @@ -106,7 +106,7 @@ func TestResolveLocalTarget_dirSymlinkEscapeRejected(t *testing.T) { } // 不存在的本地根目录必须放行(不误判逃逸),由后续 MkdirAll 创建。 -func TestResolveLocalTarget_nonexistentRootAllowed(t *testing.T) { +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") @@ -120,7 +120,7 @@ func TestResolveLocalTarget_nonexistentRootAllowed(t *testing.T) { // 回归:--local-folder 指向尚不存在的绝对路径时,pull 应自动创建目录并下载, // 而不是把每个远端文件都误判为符号链接逃逸而 failed。 -func TestDrivePull_nonexistentLocalRoot(t *testing.T) { +func TestCrossPlatformCoverageDrivePull_nonexistentLocalRoot(t *testing.T) { parent := t.TempDir() absDir := filepath.Join(parent, "new", "repo") // 尚不存在 @@ -167,7 +167,7 @@ func TestDrivePull_nonexistentLocalRoot(t *testing.T) { // ────────────────────────────────────────────────────────── // 大小写不敏感 FS:A.txt 与 a.txt 落到同一目标 → 两者都判冲突;无关文件不受影响。 -func TestDetectTargetCollisions_caseInsensitive(t *testing.T) { +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) @@ -183,7 +183,7 @@ func TestDetectTargetCollisions_caseInsensitive(t *testing.T) { } // 大小写敏感 FS:A.txt 与 a.txt 是两个合法文件 → 不应误判冲突。 -func TestDetectTargetCollisions_caseSensitive(t *testing.T) { +func TestCrossPlatformCoverageDetectTargetCollisions_caseSensitive(t *testing.T) { root := filepath.Clean(t.TempDir()) rels := []string{"A.txt", "a.txt", "b.txt"} collided := detectTargetCollisions(root, rels, false) @@ -193,7 +193,7 @@ func TestDetectTargetCollisions_caseSensitive(t *testing.T) { } // 平台名称规范化冲突:同名的 NFC 与 NFD 记法在不敏感 FS 上落到同一目标 → 冲突。 -func TestDetectTargetCollisions_unicodeNormalization(t *testing.T) { +func TestCrossPlatformCoverageDetectTargetCollisions_unicodeNormalization(t *testing.T) { root := filepath.Clean(t.TempDir()) nfc := "café.txt" // café(预组合 é) nfd := "café.txt" // café(e + 组合尖音符) @@ -213,7 +213,7 @@ func TestDetectTargetCollisions_unicodeNormalization(t *testing.T) { } // isCaseInsensitiveFS 应能对临时目录给出与实际探针一致的判定(本机自洽)。 -func TestIsCaseInsensitiveFS_selfConsistent(t *testing.T) { +func TestCrossPlatformCoverageIsCaseInsensitiveFS_selfConsistent(t *testing.T) { dir := t.TempDir() got := isCaseInsensitiveFS(dir) // 实测:写一个小写名,再用大写名 stat。 @@ -233,7 +233,7 @@ func TestIsCaseInsensitiveFS_selfConsistent(t *testing.T) { // ────────────────────────────────────────────────────────── // 下载中途失败时:命令报告 failed,原有本地文件内容必须原封不动,且不留临时残file。 -func TestPullOneFile_downloadFailureKeepsOriginal(t *testing.T) { +func TestCrossPlatformCoveragePullOneFile_downloadFailureKeepsOriginal(t *testing.T) { root := t.TempDir() target := filepath.Join(root, "a.txt") const original = "ORIGINAL-CONTENT-DO-NOT-LOSE" @@ -280,7 +280,7 @@ func TestPullOneFile_downloadFailureKeepsOriginal(t *testing.T) { } // 下载成功时:临时文件被原子重命名为目标,内容与远端一致。 -func TestPullOneFile_downloadSuccessReplacesTarget(t *testing.T) { +func TestCrossPlatformCoveragePullOneFile_downloadSuccessReplacesTarget(t *testing.T) { root := t.TempDir() target := filepath.Join(root, "a.txt") mustWrite(t, target, "OLD") @@ -318,7 +318,7 @@ func TestPullOneFile_downloadSuccessReplacesTarget(t *testing.T) { // ────────────────────────────────────────────────────────── // drive 的正常返回:result.downloadUrl 是预签名 URL,无需额外 header。 -func TestParseDriveDownloadInfo_downloadUrl(t *testing.T) { +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 { @@ -333,7 +333,7 @@ func TestParseDriveDownloadInfo_downloadUrl(t *testing.T) { } // 兼容 flat resourceUrl 字段。 -func TestParseDriveDownloadInfo_resourceUrlFallback(t *testing.T) { +func TestCrossPlatformCoverageParseDriveDownloadInfo_resourceUrlFallback(t *testing.T) { text := `{"resourceUrl":"https://flat.example.com/dl"}` url, _, err := parseDriveDownloadInfo(text) if err != nil { @@ -345,7 +345,7 @@ func TestParseDriveDownloadInfo_resourceUrlFallback(t *testing.T) { } // 兼容 resourceUrls[].url 数组格式(在 result 包裹下)。 -func TestParseDriveDownloadInfo_resourceUrlsArrayFallback(t *testing.T) { +func TestCrossPlatformCoverageParseDriveDownloadInfo_resourceUrlsArrayFallback(t *testing.T) { text := `{"result":{"resourceUrls":[{"url":"https://arr.example.com/dl","headers":{}}]}}` url, _, err := parseDriveDownloadInfo(text) if err != nil { @@ -356,14 +356,14 @@ func TestParseDriveDownloadInfo_resourceUrlsArrayFallback(t *testing.T) { } } -func TestParseDriveDownloadInfo_missingURL(t *testing.T) { +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 TestParseDriveDownloadInfo_invalidJSON(t *testing.T) { +func TestCrossPlatformCoverageParseDriveDownloadInfo_invalidJSON(t *testing.T) { if _, _, err := parseDriveDownloadInfo("not json"); err == nil { t.Fatal("expected error for invalid JSON") } @@ -373,7 +373,7 @@ func TestParseDriveDownloadInfo_invalidJSON(t *testing.T) { // drivePartialFailure — pull 部分失败错误:exit=1,JSON 原样透传 // ────────────────────────────────────────────────────────── -func TestDrivePartialFailure(t *testing.T) { +func TestCrossPlatformCoverageDrivePartialFailure(t *testing.T) { raw := `{"error":{"type":"partial_failure","detail":{}}}` e := &drivePartialFailure{raw: raw} if e.ExitCode() != 1 { diff --git a/internal/helpers/drive_push_coverage_test.go b/internal/helpers/drive_push_coverage_test.go index 1b2774f6..c279ae3f 100644 --- a/internal/helpers/drive_push_coverage_test.go +++ b/internal/helpers/drive_push_coverage_test.go @@ -44,7 +44,7 @@ func withNoopPut(t *testing.T) { // ────────────────────────────────────────────────────────── // 本地子目录在远端缺失 → 按需 create_folder 并留 folder_created 痕迹,再上传其中文件。 -func TestDrivePush_createsMissingRemoteFolders(t *testing.T) { +func TestCrossPlatformCoverageDrivePush_createsMissingRemoteFolders(t *testing.T) { dir := t.TempDir() if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil { t.Fatal(err) @@ -71,7 +71,7 @@ func TestDrivePush_createsMissingRemoteFolders(t *testing.T) { } // 远端已存在同名目录 → 复用其 fileId,不重建、不出现在 items[]。 -func TestDrivePush_reusesExistingRemoteFolder(t *testing.T) { +func TestCrossPlatformCoverageDrivePush_reusesExistingRemoteFolder(t *testing.T) { dir := t.TempDir() if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil { t.Fatal(err) @@ -94,7 +94,7 @@ func TestDrivePush_reusesExistingRemoteFolder(t *testing.T) { } // create_folder 失败 / 未返回 fileId → 该目录记 failed,其中文件因父目录缺失也 failed。 -func TestDrivePush_folderCreationFailureCascades(t *testing.T) { +func TestCrossPlatformCoverageDrivePush_folderCreationFailureCascades(t *testing.T) { for _, tc := range []struct { name string reply func(string, map[string]any, int) (string, error) @@ -138,7 +138,7 @@ func TestDrivePush_folderCreationFailureCascades(t *testing.T) { } // --if-exists skip(默认):远端已存在 → 跳过,不上传。 -func TestDrivePush_ifExistsSkipIsDefault(t *testing.T) { +func TestCrossPlatformCoverageDrivePush_ifExistsSkipIsDefault(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "a.txt"), "local") withNoopPut(t) @@ -155,7 +155,7 @@ func TestDrivePush_ifExistsSkipIsDefault(t *testing.T) { } // --if-exists smart:远端时间已 ≥ 本地 → 跳过;远端更旧 → 覆盖上传。 -func TestDrivePush_ifExistsSmart(t *testing.T) { +func TestCrossPlatformCoverageDrivePush_ifExistsSmart(t *testing.T) { t.Run("remote newer skips", func(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "a.txt") @@ -217,7 +217,7 @@ func TestDrivePush_ifExistsSmart(t *testing.T) { } // 上传失败 → 记 failed 并以非零退出码退出,结构化结果仍打印。 -func TestDrivePush_uploadFailureExitsNonZero(t *testing.T) { +func TestCrossPlatformCoverageDrivePush_uploadFailureExitsNonZero(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "a.txt"), "local") SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return errTestUpload }) @@ -232,7 +232,7 @@ func TestDrivePush_uploadFailureExitsNonZero(t *testing.T) { } // --if-exists 非法值直接拒绝。 -func TestDrivePush_invalidIfExistsRejected(t *testing.T) { +func TestCrossPlatformCoverageDrivePush_invalidIfExistsRejected(t *testing.T) { err := runDriveCmd(t, pushOKCaller(nil), "push", "--local-folder", t.TempDir(), "--remote-folder", "ROOT", "--if-exists", "bogus") if err == nil || !strings.Contains(err.Error(), "--if-exists") { t.Fatalf("expected --if-exists rejection, got %v", err) @@ -240,7 +240,7 @@ func TestDrivePush_invalidIfExistsRejected(t *testing.T) { } // space-id 必须透传到 create_folder / get_upload_info / ln 三处。 -func TestDrivePush_passesSpaceIDEverywhere(t *testing.T) { +func TestCrossPlatformCoverageDrivePush_passesSpaceIDEverywhere(t *testing.T) { dir := t.TempDir() if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil { t.Fatal(err) @@ -264,7 +264,7 @@ func TestDrivePush_passesSpaceIDEverywhere(t *testing.T) { } // 远端列举失败 → push 直接报错,不做任何上传。 -func TestDrivePush_remoteListFailurePropagates(t *testing.T) { +func TestCrossPlatformCoverageDrivePush_remoteListFailurePropagates(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "a.txt"), "x") caller := &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) { return "", errTestList }} @@ -274,7 +274,7 @@ func TestDrivePush_remoteListFailurePropagates(t *testing.T) { } // 本地根目录不存在 → 扫描失败。 -func TestDrivePush_missingLocalRootFails(t *testing.T) { +func TestCrossPlatformCoverageDrivePush_missingLocalRootFails(t *testing.T) { missing := filepath.Join(t.TempDir(), "absent") err := runDriveCmd(t, pushOKCaller(nil), "push", "--local-folder", missing, "--remote-folder", "ROOT") if err == nil || !strings.Contains(err.Error(), "扫描本地目录失败") { @@ -286,7 +286,7 @@ func TestDrivePush_missingLocalRootFails(t *testing.T) { // walkRemoteForPush — 分页、递归、深度上限、非法/空名跳过 // ────────────────────────────────────────────────────────── -func TestWalkRemoteForPush_paginationRecursionAndSkips(t *testing.T) { +func TestCrossPlatformCoverageWalkRemoteForPush_paginationRecursionAndSkips(t *testing.T) { // 递归会插在 ROOT 的两页之间,所以按 (parentId, nextToken) 判定而非调用序号。 caller := &driveScriptCaller{reply: func(_ string, args map[string]any, _ int) (string, error) { parent, _ := args["parentId"].(string) @@ -332,7 +332,7 @@ func TestWalkRemoteForPush_paginationRecursionAndSkips(t *testing.T) { } } -func TestWalkRemoteForPush_depthLimitAborts(t *testing.T) { +func TestCrossPlatformCoverageWalkRemoteForPush_depthLimitAborts(t *testing.T) { caller := &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) { return `{"result":{"items":[{"name":"loop","type":"folder","fileId":"LOOP"}],"nextToken":""}}`, nil }} @@ -351,7 +351,7 @@ func TestWalkRemoteForPush_depthLimitAborts(t *testing.T) { // walkLocalForPush — 非常规文件与错误分支 // ────────────────────────────────────────────────────────── -func TestWalkLocalForPush_skipsIrregularAndMissingRoot(t *testing.T) { +func TestCrossPlatformCoverageWalkLocalForPush_skipsIrregularAndMissingRoot(t *testing.T) { if _, _, err := walkLocalForPush(filepath.Join(t.TempDir(), "absent")); err == nil { t.Fatal("expected error for missing root") } @@ -376,7 +376,7 @@ func TestWalkLocalForPush_skipsIrregularAndMissingRoot(t *testing.T) { // pushUploadFile — 凭证解析与提交失败分支 // ────────────────────────────────────────────────────────── -func TestPushUploadFile_failureBranches(t *testing.T) { +func TestCrossPlatformCoveragePushUploadFile_failureBranches(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "a.txt") mustWrite(t, p, "payload") @@ -437,7 +437,7 @@ func TestPushUploadFile_failureBranches(t *testing.T) { } // pushCreateFolder:MCP 失败上抛,成功时从返回体提取 fileId。 -func TestPushCreateFolder_errorAndSuccess(t *testing.T) { +func TestCrossPlatformCoveragePushCreateFolder_errorAndSuccess(t *testing.T) { prevDeps, prevArgs := deps, os.Args os.Args = []string{"dws", "drive"} t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs }) diff --git a/internal/helpers/drive_push_test.go b/internal/helpers/drive_push_test.go index c0d92ad1..809c0bdc 100644 --- a/internal/helpers/drive_push_test.go +++ b/internal/helpers/drive_push_test.go @@ -17,7 +17,7 @@ import ( // splitRel — rel_path 拆父路径 / 末段名 // ────────────────────────────────────────────────────────── -func TestSplitRel(t *testing.T) { +func TestCrossPlatformCoverageSplitRel(t *testing.T) { cases := []struct { in string parent, base string @@ -39,7 +39,7 @@ func TestSplitRel(t *testing.T) { // parseNodeID — create_folder / commit 返回里抽 fileId // ────────────────────────────────────────────────────────── -func TestParseNodeID(t *testing.T) { +func TestCrossPlatformCoverageParseNodeID(t *testing.T) { cases := []struct { name string text string @@ -65,7 +65,7 @@ func TestParseNodeID(t *testing.T) { // walkLocalForPush — 本地遍历(含空目录,父目录先于子目录) // ────────────────────────────────────────────────────────── -func TestWalkLocalForPush(t *testing.T) { +func TestCrossPlatformCoverageWalkLocalForPush(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, "a.txt"), "hello") // 5 bytes mustWrite(t, filepath.Join(root, "sub1", "b.txt"), "worl") // 4 bytes, 建出 sub1 @@ -115,7 +115,7 @@ func TestWalkLocalForPush(t *testing.T) { // drivePushFailure — push 部分失败错误:exit=1 // ────────────────────────────────────────────────────────── -func TestDrivePushFailure(t *testing.T) { +func TestCrossPlatformCoverageDrivePushFailure(t *testing.T) { e := &drivePushFailure{failed: 3} if e.ExitCode() != 1 { t.Errorf("ExitCode() = %d, want 1", e.ExitCode()) @@ -208,7 +208,7 @@ func runDrivePushTest(t *testing.T, caller *driveSyncMockCaller, localDir string } // 覆盖分支:get_upload_info 与 commit_upload 两阶段都必须传 overwriteFileId、都不传 parentId。 -func TestDrivePush_overwriteUsesOverwriteFileId(t *testing.T) { +func TestCrossPlatformCoverageDrivePush_overwriteUsesOverwriteFileId(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "a.txt"), "new-content") @@ -244,7 +244,7 @@ func TestDrivePush_overwriteUsesOverwriteFileId(t *testing.T) { } // 新建分支:远端不存在同名文件时走普通上传——两阶段都传 parentId、都不传 overwriteFileId。 -func TestDrivePush_newUploadUsesParentId(t *testing.T) { +func TestCrossPlatformCoverageDrivePush_newUploadUsesParentId(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "b.txt"), "brand-new") diff --git a/internal/helpers/drive_status_coverage_test.go b/internal/helpers/drive_status_coverage_test.go index e999a6a6..8c6471cb 100644 --- a/internal/helpers/drive_status_coverage_test.go +++ b/internal/helpers/drive_status_coverage_test.go @@ -86,7 +86,7 @@ func runDriveCmd(t *testing.T, caller edition.ToolCaller, args ...string) error // ────────────────────────────────────────────────────────── // 端到端 status:递归进入子文件夹、拼接 rel_path、跳过非 file 类型。 -func TestDriveStatus_endToEndNestedTree(t *testing.T) { +func TestCrossPlatformCoverageDriveStatus_endToEndNestedTree(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "root.txt"), "r") if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil { @@ -121,7 +121,7 @@ func TestDriveStatus_endToEndNestedTree(t *testing.T) { } // 分页:首页返回 nextToken,第二页取完;token 未推进时必须终止而非死循环。 -func TestDriveStatus_paginationAndStalledToken(t *testing.T) { +func TestCrossPlatformCoverageDriveStatus_paginationAndStalledToken(t *testing.T) { dir := t.TempDir() caller := &driveScriptCaller{reply: func(_ string, args map[string]any, nth int) (string, error) { @@ -150,7 +150,7 @@ func TestDriveStatus_paginationAndStalledToken(t *testing.T) { } // space-id 显式传入时必须透传到 list_files。 -func TestDriveStatus_passesSpaceID(t *testing.T) { +func TestCrossPlatformCoverageDriveStatus_passesSpaceID(t *testing.T) { dir := t.TempDir() caller := &driveScriptCaller{reply: func(_ string, _ map[string]any, _ int) (string, error) { return `{"result":{"items":[],"nextToken":""}}`, nil @@ -164,7 +164,7 @@ func TestDriveStatus_passesSpaceID(t *testing.T) { } // list_files 失败 → status 直接报错。 -func TestDriveStatus_listFailurePropagates(t *testing.T) { +func TestCrossPlatformCoverageDriveStatus_listFailurePropagates(t *testing.T) { dir := t.TempDir() caller := &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) { return "", errTestList }} if err := runDriveCmd(t, caller, "status", "--local-folder", dir, "--remote-folder", "ROOT"); err == nil { @@ -173,7 +173,7 @@ func TestDriveStatus_listFailurePropagates(t *testing.T) { } // 返回体无法解析 → status 报解析失败。 -func TestDriveStatus_unparsableListPropagates(t *testing.T) { +func TestCrossPlatformCoverageDriveStatus_unparsableListPropagates(t *testing.T) { dir := t.TempDir() caller := &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) { return `{"result":"not-a-list"}`, nil @@ -185,7 +185,7 @@ func TestDriveStatus_unparsableListPropagates(t *testing.T) { } // exact 模式下本地文件不可读 → MD5 计算失败要作为错误上抛,不能静默当成一致。 -func TestDriveStatus_unreadableLocalFileFailsExactCompare(t *testing.T) { +func TestCrossPlatformCoverageDriveStatus_unreadableLocalFileFailsExactCompare(t *testing.T) { if runtime.GOOS == "windows" || os.Geteuid() == 0 { t.Skip("chmod-based unreadable file needs POSIX perms and a non-root user") } @@ -207,7 +207,7 @@ func TestDriveStatus_unreadableLocalFileFailsExactCompare(t *testing.T) { } // --local-folder 不是绝对路径 → 校验拦下,不发起任何 MCP 调用。 -func TestDriveStatus_relativeLocalFolderRejected(t *testing.T) { +func TestCrossPlatformCoverageDriveStatus_relativeLocalFolderRejected(t *testing.T) { caller := &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) { t.Error("must not call MCP when --local-folder is rejected") return "", nil @@ -222,7 +222,7 @@ func TestDriveStatus_relativeLocalFolderRejected(t *testing.T) { // ────────────────────────────────────────────────────────── // 远端目录自引用时深度上限必须中止遍历,避免无限递归。 -func TestWalkRemoteDir_depthLimitAborts(t *testing.T) { +func TestCrossPlatformCoverageWalkRemoteDir_depthLimitAborts(t *testing.T) { caller := &driveScriptCaller{reply: func(_ string, _ map[string]any, _ int) (string, error) { // 每层都返回一个同名子文件夹 → 无限深。 return `{"result":{"items":[{"name":"loop","type":"folder","fileId":"LOOP"}],"nextToken":""}}`, nil @@ -246,7 +246,7 @@ func TestWalkRemoteDir_depthLimitAborts(t *testing.T) { // isSafeRemoteSegment / resolveLocalTarget 的剩余分支 // ────────────────────────────────────────────────────────── -func TestIsSafeRemoteSegment_volumeAndNonCanonical(t *testing.T) { +func TestCrossPlatformCoverageIsSafeRemoteSegment_volumeAndNonCanonical(t *testing.T) { if runtime.GOOS == "windows" { if isSafeRemoteSegment(`C:name.txt`) { t.Error("volume-qualified name must be rejected") @@ -264,27 +264,27 @@ func TestIsSafeRemoteSegment_volumeAndNonCanonical(t *testing.T) { // md5File / walkLocalTree 错误分支 // ────────────────────────────────────────────────────────── -func TestMD5File_missingFile(t *testing.T) { +func TestCrossPlatformCoverageMD5File_missingFile(t *testing.T) { if _, err := md5File(filepath.Join(t.TempDir(), "nope.bin")); err == nil { t.Fatal("expected error for missing file") } } -func TestMD5File_directoryIsNotReadable(t *testing.T) { +func TestCrossPlatformCoverageMD5File_directoryIsNotReadable(t *testing.T) { // 目录不是常规文件:io.Copy 读取时必然报错。 if _, err := md5File(t.TempDir()); err == nil { t.Fatal("expected error when hashing a directory") } } -func TestWalkLocalTree_missingRootErrors(t *testing.T) { +func TestCrossPlatformCoverageWalkLocalTree_missingRootErrors(t *testing.T) { if _, err := walkLocalTree(filepath.Join(t.TempDir(), "absent")); err == nil { t.Fatal("expected error for missing local root") } } // 非常规文件(FIFO/符号链接目标缺失等)不计入本地索引。 -func TestWalkLocalTree_skipsIrregularEntries(t *testing.T) { +func TestCrossPlatformCoverageWalkLocalTree_skipsIrregularEntries(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "real.txt"), "x") if err := os.Symlink(filepath.Join(dir, "absent"), filepath.Join(dir, "dangling")); err != nil { @@ -306,7 +306,7 @@ func TestWalkLocalTree_skipsIrregularEntries(t *testing.T) { // toMillis 的 json.Number / float64 分支 // ────────────────────────────────────────────────────────── -func TestDriveItem_modifiedMillisFromNumericForms(t *testing.T) { +func TestCrossPlatformCoverageDriveItem_modifiedMillisFromNumericForms(t *testing.T) { if got, ok := (driveItem{"modifyTime": float64(1500)}).modifiedMillis(); !ok || got != 1500 { t.Errorf("float64 modifyTime = (%d,%v), want (1500,true)", got, ok) } @@ -325,7 +325,7 @@ func TestDriveItem_modifiedMillisFromNumericForms(t *testing.T) { } // fileSize 的 float64 / json.Number 两种形态,以及缺失时的兜底。 -func TestDriveItem_sizeForms(t *testing.T) { +func TestCrossPlatformCoverageDriveItem_sizeForms(t *testing.T) { if got := (driveItem{"fileSize": float64(42)}).size(); got != 42 { t.Errorf("float fileSize = %d, want 42", got) } diff --git a/internal/helpers/drive_status_test.go b/internal/helpers/drive_status_test.go index 8bf5e8d7..097af9d2 100644 --- a/internal/helpers/drive_status_test.go +++ b/internal/helpers/drive_status_test.go @@ -10,19 +10,19 @@ import ( // validateLocalDirAbs — 本地路径校验(必须为绝对路径) // ────────────────────────────────────────────────────────── -func TestValidateLocalDirAbs_empty(t *testing.T) { +func TestCrossPlatformCoverageValidateLocalDirAbs_empty(t *testing.T) { if _, err := validateLocalDirAbs(""); err == nil { t.Fatal("expected error for empty --local-folder") } } -func TestValidateLocalDirAbs_relative(t *testing.T) { +func TestCrossPlatformCoverageValidateLocalDirAbs_relative(t *testing.T) { if _, err := validateLocalDirAbs(filepath.Join(".", "repo")); err == nil { t.Fatal("expected error for relative path") } } -func TestValidateLocalDirAbs_ok(t *testing.T) { +func TestCrossPlatformCoverageValidateLocalDirAbs_ok(t *testing.T) { abs := filepath.Join(t.TempDir(), "repo") got, err := validateLocalDirAbs(abs) if err != nil { @@ -37,7 +37,7 @@ func TestValidateLocalDirAbs_ok(t *testing.T) { // walkLocalTree — 本地遍历 // ────────────────────────────────────────────────────────── -func TestWalkLocalTree_collectsRegularFiles(t *testing.T) { +func TestCrossPlatformCoverageWalkLocalTree_collectsRegularFiles(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, "a.txt"), "hello") mustWrite(t, filepath.Join(root, "sub", "b.txt"), "world") @@ -69,7 +69,7 @@ func TestWalkLocalTree_collectsRegularFiles(t *testing.T) { // compareTrees — 四类差异分类 // ────────────────────────────────────────────────────────── -func TestCompareTrees_exact(t *testing.T) { +func TestCrossPlatformCoverageCompareTrees_exact(t *testing.T) { local := map[string]*localFile{ "same.txt": {RelPath: "same.txt", Hash: "h1"}, "diff.txt": {RelPath: "diff.txt", Hash: "local"}, @@ -94,7 +94,7 @@ func TestCompareTrees_exact(t *testing.T) { } } -func TestCompareTrees_exact_unknownRemoteHashIsUnknown(t *testing.T) { +func TestCrossPlatformCoverageCompareTrees_exact_unknownRemoteHashIsUnknown(t *testing.T) { local := map[string]*localFile{"a.txt": {RelPath: "a.txt", Hash: "h1"}} remote := map[string]*remoteFile{"a.txt": {RelPath: "a.txt", Hash: ""}} res, err := compareTrees(local, remote, "exact", false) @@ -113,7 +113,7 @@ func TestCompareTrees_exact_unknownRemoteHashIsUnknown(t *testing.T) { // TestCompareTrees_exact_hashesOnlyIntersection 验证本地 hash 只在双端都存在时才 // 计算:local-only 文件即使 AbsPath 指向不存在的文件也不会触发 hash(不报错)。 -func TestCompareTrees_exact_hashesOnlyIntersection(t *testing.T) { +func TestCrossPlatformCoverageCompareTrees_exact_hashesOnlyIntersection(t *testing.T) { root := t.TempDir() bothPath := filepath.Join(root, "both.txt") mustWrite(t, bothPath, "same-content") @@ -147,7 +147,7 @@ func TestCompareTrees_exact_hashesOnlyIntersection(t *testing.T) { // TestCompareTrees_exact_noRemoteMD5AllUnknown 验证 exact 模式在远端无 md5 时 // 一律归入 unknown——不再用 fileSize + modified_time 近似判 unchanged(否则大小与 // mtime 恰好相同但内容不同的文件会被误报为未变更),无论大小/时间是否相同。 -func TestCompareTrees_exact_noRemoteMD5AllUnknown(t *testing.T) { +func TestCrossPlatformCoverageCompareTrees_exact_noRemoteMD5AllUnknown(t *testing.T) { local := map[string]*localFile{ "sameSizeTime.txt": {RelPath: "sameSizeTime.txt", Size: 100, ModTimeMillis: 1000}, "diffsize.txt": {RelPath: "diffsize.txt", Size: 100, ModTimeMillis: 1000}, @@ -174,7 +174,7 @@ func TestCompareTrees_exact_noRemoteMD5AllUnknown(t *testing.T) { // TestCompareTrees_exact_base64RemoteMD5 验证 exact 用 base64 md5 比对:本地按 // base64 算 md5,与钉盘返回的 base64 md5 直接相等 → unchanged。 -func TestCompareTrees_exact_base64RemoteMD5(t *testing.T) { +func TestCrossPlatformCoverageCompareTrees_exact_base64RemoteMD5(t *testing.T) { root := t.TempDir() p := filepath.Join(root, "a.txt") mustWrite(t, p, "hello root\n") // 11 字节,md5 base64 = 6y9K7B0b2K0AInNZZV/70A== @@ -195,7 +195,7 @@ func TestCompareTrees_exact_base64RemoteMD5(t *testing.T) { } } -func TestCompareTrees_quick(t *testing.T) { +func TestCrossPlatformCoverageCompareTrees_quick(t *testing.T) { local := map[string]*localFile{ "eq.txt": {RelPath: "eq.txt", ModTimeMillis: 1000}, "ne.txt": {RelPath: "ne.txt", ModTimeMillis: 1000}, @@ -216,7 +216,7 @@ func TestCompareTrees_quick(t *testing.T) { assertPaths(t, "modified", res.Modified, "bad.txt", "ne.txt") } -func TestCompareTrees_emptyRemote_allNewLocal(t *testing.T) { +func TestCrossPlatformCoverageCompareTrees_emptyRemote_allNewLocal(t *testing.T) { local := map[string]*localFile{ "a.txt": {RelPath: "a.txt", Hash: "h1"}, "b.txt": {RelPath: "b.txt", Hash: "h2"}, @@ -277,7 +277,7 @@ func entryPaths(entries []driveStatusEntry) []string { // parseDriveList — list_files 返回解析 // ────────────────────────────────────────────────────────── -func TestParseDriveList_resultItems(t *testing.T) { +func TestCrossPlatformCoverageParseDriveList_resultItems(t *testing.T) { text := `{"result":{"items":[{"name":"a.txt","type":"file"},{"name":"sub","type":"folder"}],"nextToken":"tok"}}` items, token, err := parseDriveList(text) if err != nil { @@ -291,7 +291,7 @@ func TestParseDriveList_resultItems(t *testing.T) { } } -func TestParseDriveList_topLevelArray(t *testing.T) { +func TestCrossPlatformCoverageParseDriveList_topLevelArray(t *testing.T) { text := `{"result":[{"name":"a.txt","type":"file"}]}` items, token, err := parseDriveList(text) if err != nil { @@ -305,7 +305,7 @@ func TestParseDriveList_topLevelArray(t *testing.T) { } } -func TestParseDriveList_invalid(t *testing.T) { +func TestCrossPlatformCoverageParseDriveList_invalid(t *testing.T) { if _, _, err := parseDriveList("not json"); err == nil { t.Fatal("expected error for invalid JSON") } @@ -315,7 +315,7 @@ func TestParseDriveList_invalid(t *testing.T) { // driveItem — 字段访问器(带 fallback) // ────────────────────────────────────────────────────────── -func TestDriveItem_accessors(t *testing.T) { +func TestCrossPlatformCoverageDriveItem_accessors(t *testing.T) { it := driveItem{ "name": "报告.pdf", "type": "file", @@ -351,7 +351,7 @@ func TestDriveItem_accessors(t *testing.T) { } } -func TestDriveItem_folderAndFile(t *testing.T) { +func TestCrossPlatformCoverageDriveItem_folderAndFile(t *testing.T) { folder := driveItem{"name": "sub", "type": "folder"} if !folder.isFolder() || folder.isFile() { t.Error("type=folder should be folder, not file") @@ -375,7 +375,7 @@ func TestDriveItem_folderAndFile(t *testing.T) { // toMillis — 时间字段归一化 // ────────────────────────────────────────────────────────── -func TestToMillis(t *testing.T) { +func TestCrossPlatformCoverageToMillis(t *testing.T) { cases := []struct { name string in any diff --git a/internal/helpers/drive_sync_conflict_test.go b/internal/helpers/drive_sync_conflict_test.go index 148d7d94..c57fd385 100644 --- a/internal/helpers/drive_sync_conflict_test.go +++ b/internal/helpers/drive_sync_conflict_test.go @@ -44,7 +44,7 @@ func mkNested(t *testing.T) string { } // 顶层目录 a 建失败 → 子目录 a/b 因父目录缺失也 failed,其中文件同样 failed。 -func TestDrivePush_nestedDirFailsWhenParentMissing(t *testing.T) { +func TestCrossPlatformCoverageDrivePush_nestedDirFailsWhenParentMissing(t *testing.T) { dir := mkNested(t) withNoopPut(t) @@ -63,7 +63,7 @@ func TestDrivePush_nestedDirFailsWhenParentMissing(t *testing.T) { } } -func TestDriveSync_nestedDirFailsWhenParentMissing(t *testing.T) { +func TestCrossPlatformCoverageDriveSync_nestedDirFailsWhenParentMissing(t *testing.T) { dir := mkNested(t) withNoopPut(t) @@ -79,7 +79,7 @@ func TestDriveSync_nestedDirFailsWhenParentMissing(t *testing.T) { } // new_remote 落盘目标经软链逃逸出本地根 → 记 failed,不写到根目录之外。 -func TestDriveSync_newRemoteEscapeIsFailed(t *testing.T) { +func TestCrossPlatformCoverageDriveSync_newRemoteEscapeIsFailed(t *testing.T) { root := t.TempDir() outside := t.TempDir() if err := os.Symlink(outside, filepath.Join(root, "sub")); err != nil { @@ -106,7 +106,7 @@ func TestDriveSync_newRemoteEscapeIsFailed(t *testing.T) { // ────────────────────────────────────────────────────────── // syncKeepBoth 在 rel 逃逸时直接 failed(resolveLocalTarget 报错)。 -func TestSyncKeepBoth_escapingRelIsFailed(t *testing.T) { +func TestCrossPlatformCoverageSyncKeepBoth_escapingRelIsFailed(t *testing.T) { root := t.TempDir() outside := t.TempDir() if err := os.Symlink(outside, filepath.Join(root, "sub")); err != nil { @@ -121,7 +121,7 @@ func TestSyncKeepBoth_escapingRelIsFailed(t *testing.T) { } // collided 命中时 keep-both 直接 failed,不做任何改名。 -func TestSyncKeepBoth_collidedIsFailed(t *testing.T) { +func TestCrossPlatformCoverageSyncKeepBoth_collidedIsFailed(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, "f.txt"), "local") res := &driveSyncResult{} @@ -136,7 +136,7 @@ func TestSyncKeepBoth_collidedIsFailed(t *testing.T) { } // 占用改名目标失败(目录不可写)→ failed。 -func TestSyncKeepBoth_reserveFailureIsFailed(t *testing.T) { +func TestCrossPlatformCoverageSyncKeepBoth_reserveFailureIsFailed(t *testing.T) { if runtime.GOOS == "windows" || os.Geteuid() == 0 { t.Skip("read-only dir enforcement needs POSIX perms and a non-root user") } @@ -156,7 +156,7 @@ func TestSyncKeepBoth_reserveFailureIsFailed(t *testing.T) { } // 改名本身失败(本地原文件其实是目录)→ failed,并清理刚建的空占位。 -func TestSyncKeepBoth_renameFailureCleansPlaceholder(t *testing.T) { +func TestCrossPlatformCoverageSyncKeepBoth_renameFailureCleansPlaceholder(t *testing.T) { root := t.TempDir() // rel 指向一个非空目录:os.Rename(dir, emptyFile) 会失败。 if err := os.MkdirAll(filepath.Join(root, "f.txt", "inner"), 0o755); err != nil { @@ -177,7 +177,7 @@ func TestSyncKeepBoth_renameFailureCleansPlaceholder(t *testing.T) { } // 拉取失败 → 回滚改名,本地文件恢复原名与原内容。 -func TestSyncKeepBoth_pullFailureRollsBackRename(t *testing.T) { +func TestCrossPlatformCoverageSyncKeepBoth_pullFailureRollsBackRename(t *testing.T) { root := t.TempDir() p := filepath.Join(root, "f.txt") mustWrite(t, p, "local-version") @@ -207,7 +207,7 @@ func TestSyncKeepBoth_pullFailureRollsBackRename(t *testing.T) { } // 拉取失败且回滚也失败 → 如实上报“本地版本保留为 <改名>”,不谎称成功。 -func TestSyncKeepBoth_rollbackFailureIsReported(t *testing.T) { +func TestCrossPlatformCoverageSyncKeepBoth_rollbackFailureIsReported(t *testing.T) { root := t.TempDir() p := filepath.Join(root, "f.txt") mustWrite(t, p, "local-version") diff --git a/internal/helpers/drive_sync_coverage_test.go b/internal/helpers/drive_sync_coverage_test.go index 069e89bc..15d04b30 100644 --- a/internal/helpers/drive_sync_coverage_test.go +++ b/internal/helpers/drive_sync_coverage_test.go @@ -3,11 +3,14 @@ package helpers import ( "context" "errors" + "io" "os" "path/filepath" "strconv" "strings" "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" ) // syncCaller 组合 push 与 pull 两侧:list_files 按 parentId 返回远端现状, @@ -50,7 +53,7 @@ func withSyncTransport(t *testing.T, body string) { // ────────────────────────────────────────────────────────── // 显式传空 --on-conflict 时回落默认 remote-wins(而非报非法取值)。 -func TestDriveSync_emptyOnConflictDefaultsToRemoteWins(t *testing.T) { +func TestCrossPlatformCoverageDriveSync_emptyOnConflictDefaultsToRemoteWins(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "f.txt") mustWrite(t, p, "local-old") @@ -70,7 +73,7 @@ func TestDriveSync_emptyOnConflictDefaultsToRemoteWins(t *testing.T) { } } -func TestDriveSync_invalidOnConflictRejected(t *testing.T) { +func TestCrossPlatformCoverageDriveSync_invalidOnConflictRejected(t *testing.T) { err := runDriveCmd(t, syncCaller(nil), "sync", "--local-folder", t.TempDir(), "--remote-folder", "ROOT", "--on-conflict", "bogus") if err == nil || !strings.Contains(err.Error(), "--on-conflict") { @@ -83,7 +86,7 @@ func TestDriveSync_invalidOnConflictRejected(t *testing.T) { // ────────────────────────────────────────────────────────── // quick 模式下两侧 mtime 相同 → unchanged,不做任何读写。 -func TestDriveSync_unchangedIsUntouched(t *testing.T) { +func TestCrossPlatformCoverageDriveSync_unchangedIsUntouched(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "same.txt") mustWrite(t, p, "identical") @@ -106,7 +109,7 @@ func TestDriveSync_unchangedIsUntouched(t *testing.T) { } // exact 模式远端无可靠 md5 → unknown,一律计入 skipped 且不动任何一侧。 -func TestDriveSync_unknownIsSkipped(t *testing.T) { +func TestCrossPlatformCoverageDriveSync_unknownIsSkipped(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "u.txt") mustWrite(t, p, "local") @@ -128,7 +131,7 @@ func TestDriveSync_unknownIsSkipped(t *testing.T) { } // exact 模式下本地不可读 → judgeFileMatch 的 MD5 失败要中止整个 sync。 -func TestDriveSync_md5FailureAborts(t *testing.T) { +func TestCrossPlatformCoverageDriveSync_md5FailureAborts(t *testing.T) { if os.Geteuid() == 0 { t.Skip("chmod-based unreadable file needs a non-root user") } @@ -153,7 +156,7 @@ func TestDriveSync_md5FailureAborts(t *testing.T) { // ────────────────────────────────────────────────────────── // 本地子目录在远端缺失 → 建目录(记 folder_created + pushed)再上传其中文件。 -func TestDriveSync_createsRemoteFolderForNewLocal(t *testing.T) { +func TestCrossPlatformCoverageDriveSync_createsRemoteFolderForNewLocal(t *testing.T) { dir := t.TempDir() if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil { t.Fatal(err) @@ -176,7 +179,7 @@ func TestDriveSync_createsRemoteFolderForNewLocal(t *testing.T) { } // 远端已有同名目录 → 复用,不重建。 -func TestDriveSync_reusesExistingRemoteFolder(t *testing.T) { +func TestCrossPlatformCoverageDriveSync_reusesExistingRemoteFolder(t *testing.T) { dir := t.TempDir() if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil { t.Fatal(err) @@ -196,7 +199,7 @@ func TestDriveSync_reusesExistingRemoteFolder(t *testing.T) { } // create_folder 失败 → 目录与其中文件都记 failed,命令非零退出。 -func TestDriveSync_folderCreationFailureCascades(t *testing.T) { +func TestCrossPlatformCoverageDriveSync_folderCreationFailureCascades(t *testing.T) { dir := t.TempDir() if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil { t.Fatal(err) @@ -218,7 +221,7 @@ func TestDriveSync_folderCreationFailureCascades(t *testing.T) { } // new_local 上传失败 → 记 failed 并非零退出。 -func TestDriveSync_pushUploadFailureIsFailed(t *testing.T) { +func TestCrossPlatformCoverageDriveSync_pushUploadFailureIsFailed(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "only-local.txt"), "x") SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return errTestUpload }) @@ -236,7 +239,7 @@ func TestDriveSync_pushUploadFailureIsFailed(t *testing.T) { // --on-conflict ask 的每个交互答案 // ────────────────────────────────────────────────────────── -func TestDriveSyncAskConflict_answers(t *testing.T) { +func TestCrossPlatformCoverageDriveSyncAskConflict_answers(t *testing.T) { cases := []struct { input string want string @@ -255,9 +258,7 @@ func TestDriveSyncAskConflict_answers(t *testing.T) { } for _, tc := range cases { t.Run(strings.TrimSpace(tc.input), func(t *testing.T) { - prev := syncAskStdin - syncAskStdin = strings.NewReader(tc.input) - t.Cleanup(func() { syncAskStdin = prev }) + testseam.Swap(t, &syncAskStdin, io.Reader(strings.NewReader(tc.input))) got, err := driveSyncAskConflict("f.txt") if tc.isErr { @@ -277,10 +278,8 @@ func TestDriveSyncAskConflict_answers(t *testing.T) { } // ask 选到无效值时整个 sync 中止(错误上抛,不是静默跳过)。 -func TestDriveSync_askInvalidChoiceAborts(t *testing.T) { - prev := syncAskStdin - syncAskStdin = strings.NewReader("nonsense\n") - t.Cleanup(func() { syncAskStdin = prev }) +func TestCrossPlatformCoverageDriveSync_askInvalidChoiceAborts(t *testing.T) { + testseam.Swap(t, &syncAskStdin, io.Reader(strings.NewReader("nonsense\n"))) dir := t.TempDir() p := filepath.Join(dir, "f.txt") @@ -299,10 +298,8 @@ func TestDriveSync_askInvalidChoiceAborts(t *testing.T) { } // ask 选 keep-both:本地改名保留副本,远端拉到原名。 -func TestDriveSync_askKeepBothViaStdin(t *testing.T) { - prev := syncAskStdin - syncAskStdin = strings.NewReader("k\n") - t.Cleanup(func() { syncAskStdin = prev }) +func TestCrossPlatformCoverageDriveSync_askKeepBothViaStdin(t *testing.T) { + testseam.Swap(t, &syncAskStdin, io.Reader(strings.NewReader("k\n"))) dir := t.TempDir() p := filepath.Join(dir, "f.txt") @@ -338,7 +335,7 @@ func TestDriveSync_askKeepBothViaStdin(t *testing.T) { } // keep-both 改名失败(目标被目录占位)→ 记 failed,本地原文件保持不动。 -func TestDriveSync_keepBothRenameFailureKeepsLocal(t *testing.T) { +func TestCrossPlatformCoverageDriveSync_keepBothRenameFailureKeepsLocal(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "f.txt") mustWrite(t, p, "local-version") @@ -367,7 +364,7 @@ func TestDriveSync_keepBothRenameFailureKeepsLocal(t *testing.T) { // ────────────────────────────────────────────────────────── // dry-run 下即使有 new_local / new_remote / modified 也不产生任何写调用。 -func TestDriveSync_dryRunSkipsEveryWrite(t *testing.T) { +func TestCrossPlatformCoverageDriveSync_dryRunSkipsEveryWrite(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "local.txt"), "l") p := filepath.Join(dir, "both.txt") @@ -396,7 +393,7 @@ func TestDriveSync_dryRunSkipsEveryWrite(t *testing.T) { } // 远端列举失败 → sync 直接报错。 -func TestDriveSync_remoteListFailurePropagates(t *testing.T) { +func TestCrossPlatformCoverageDriveSync_remoteListFailurePropagates(t *testing.T) { caller := &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) { return "", errTestList }} if err := runDriveCmd(t, caller, "sync", "--local-folder", t.TempDir(), "--remote-folder", "ROOT"); err == nil { t.Fatal("expected remote listing failure to propagate") @@ -404,7 +401,7 @@ func TestDriveSync_remoteListFailurePropagates(t *testing.T) { } // 本地根目录不存在 → 扫描失败上抛。 -func TestDriveSync_missingLocalRootFails(t *testing.T) { +func TestCrossPlatformCoverageDriveSync_missingLocalRootFails(t *testing.T) { missing := filepath.Join(t.TempDir(), "absent") if err := runDriveCmd(t, syncCaller(nil), "sync", "--local-folder", missing, "--remote-folder", "ROOT"); err == nil { t.Fatal("expected missing local root to fail") diff --git a/internal/helpers/drive_sync_edges_test.go b/internal/helpers/drive_sync_edges_test.go index 99f5ab3f..84843247 100644 --- a/internal/helpers/drive_sync_edges_test.go +++ b/internal/helpers/drive_sync_edges_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" "github.com/spf13/cobra" ) @@ -46,7 +47,7 @@ func mustSetFlags(t *testing.T, cmd *cobra.Command, kv map[string]string) { // --if-exists 显式空值回落默认 // ────────────────────────────────────────────────────────── -func TestDrivePull_emptyIfExistsDefaultsToOverwrite(t *testing.T) { +func TestCrossPlatformCoverageDrivePull_emptyIfExistsDefaultsToOverwrite(t *testing.T) { root := t.TempDir() p := filepath.Join(root, "a.txt") mustWrite(t, p, "local-old") @@ -64,7 +65,7 @@ func TestDrivePull_emptyIfExistsDefaultsToOverwrite(t *testing.T) { } } -func TestDrivePush_emptyIfExistsDefaultsToSkip(t *testing.T) { +func TestCrossPlatformCoverageDrivePush_emptyIfExistsDefaultsToSkip(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "a.txt"), "local") withNoopPut(t) @@ -85,7 +86,7 @@ func TestDrivePush_emptyIfExistsDefaultsToSkip(t *testing.T) { // ────────────────────────────────────────────────────────── // 目标目录不可写 → 创建临时文件失败。 -func TestPullOneFile_tempFileCreationFailure(t *testing.T) { +func TestCrossPlatformCoveragePullOneFile_tempFileCreationFailure(t *testing.T) { if runtime.GOOS == "windows" || os.Geteuid() == 0 { t.Skip("read-only dir enforcement needs POSIX perms and a non-root user") } @@ -108,7 +109,7 @@ func TestPullOneFile_tempFileCreationFailure(t *testing.T) { } // 目标路径是目录 → 最后的原子 rename 失败。 -func TestPullOneFile_renameOntoDirectoryFails(t *testing.T) { +func TestCrossPlatformCoveragePullOneFile_renameOntoDirectoryFails(t *testing.T) { root := t.TempDir() target := filepath.Join(root, "a.txt") if err := os.MkdirAll(target, 0o755); err != nil { // 目标被目录占位 @@ -137,7 +138,7 @@ func TestPullOneFile_renameOntoDirectoryFails(t *testing.T) { // PrintJSON 失败分支 // ────────────────────────────────────────────────────────── -func TestDrivePush_printFailurePropagates(t *testing.T) { +func TestCrossPlatformCoverageDrivePush_printFailurePropagates(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "a.txt"), "x") withNoopPut(t) @@ -154,7 +155,7 @@ func TestDrivePush_printFailurePropagates(t *testing.T) { } } -func TestDriveSync_printFailurePropagates(t *testing.T) { +func TestCrossPlatformCoverageDriveSync_printFailurePropagates(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "a.txt"), "x") SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil }) @@ -176,7 +177,7 @@ func TestDriveSync_printFailurePropagates(t *testing.T) { // walkLocalForPush / walkLocalTree 的 WalkDir 错误回调 // ────────────────────────────────────────────────────────── -func TestWalkLocal_unreadableSubdirectoryErrors(t *testing.T) { +func TestCrossPlatformCoverageWalkLocal_unreadableSubdirectoryErrors(t *testing.T) { if runtime.GOOS == "windows" || os.Geteuid() == 0 { t.Skip("unreadable dir needs POSIX perms and a non-root user") } @@ -204,7 +205,7 @@ func TestWalkLocal_unreadableSubdirectoryErrors(t *testing.T) { // ────────────────────────────────────────────────────────── // 目标位于 absDir 之外 → 走到根目录之上即放行(不是逃逸判定范围)。 -func TestVerifyNoSymlinkEscape_targetOutsideRootIsIgnored(t *testing.T) { +func TestCrossPlatformCoverageVerifyNoSymlinkEscape_targetOutsideRootIsIgnored(t *testing.T) { root := t.TempDir() outside := filepath.Join(t.TempDir(), "elsewhere", "x.txt") if err := verifyNoSymlinkEscape(root, outside); err != nil { @@ -213,7 +214,7 @@ func TestVerifyNoSymlinkEscape_targetOutsideRootIsIgnored(t *testing.T) { } // 已存在路径含符号链接环 → EvalSymlinks 失败要如实上报。 -func TestVerifyNoSymlinkEscape_symlinkLoopErrors(t *testing.T) { +func TestCrossPlatformCoverageVerifyNoSymlinkEscape_symlinkLoopErrors(t *testing.T) { root := t.TempDir() loop := filepath.Join(root, "loop") if err := os.Symlink(loop, loop); err != nil { @@ -230,7 +231,7 @@ func TestVerifyNoSymlinkEscape_symlinkLoopErrors(t *testing.T) { // ────────────────────────────────────────────────────────── // 候选目标逃逸出本地根(父目录是指向外部的软链)→ 直接失败。 -func TestReserveSyncKeepBothTarget_escapeFails(t *testing.T) { +func TestCrossPlatformCoverageReserveSyncKeepBothTarget_escapeFails(t *testing.T) { root := t.TempDir() outside := t.TempDir() if err := os.Symlink(outside, filepath.Join(root, "sub")); err != nil { @@ -242,7 +243,7 @@ func TestReserveSyncKeepBothTarget_escapeFails(t *testing.T) { } // 候选目标所在目录不可写 → OpenFile 以非 EEXIST 错误失败,直接上报。 -func TestReserveSyncKeepBothTarget_unwritableDirFails(t *testing.T) { +func TestCrossPlatformCoverageReserveSyncKeepBothTarget_unwritableDirFails(t *testing.T) { if runtime.GOOS == "windows" || os.Geteuid() == 0 { t.Skip("read-only dir enforcement needs POSIX perms and a non-root user") } @@ -258,7 +259,7 @@ func TestReserveSyncKeepBothTarget_unwritableDirFails(t *testing.T) { } // occupied 中已知占用的首选候选被跳过,改用下一个后缀。 -func TestReserveSyncKeepBothTarget_skipsKnownOccupied(t *testing.T) { +func TestCrossPlatformCoverageReserveSyncKeepBothTarget_skipsKnownOccupied(t *testing.T) { root := t.TempDir() first := syncKeepBothCandidate("f.txt", "FID12345678", 0) occupied := map[string]bool{first: true} @@ -276,10 +277,8 @@ func TestReserveSyncKeepBothTarget_skipsKnownOccupied(t *testing.T) { // ask 读取失败(非 EOF) // ────────────────────────────────────────────────────────── -func TestDriveSyncAskConflict_readErrorPropagates(t *testing.T) { - prev := syncAskStdin - syncAskStdin = errReader{} - t.Cleanup(func() { syncAskStdin = prev }) +func TestCrossPlatformCoverageDriveSyncAskConflict_readErrorPropagates(t *testing.T) { + testseam.Swap(t, &syncAskStdin, io.Reader(errReader{})) if _, err := driveSyncAskConflict("f.txt"); err == nil { t.Fatal("expected read failure to propagate") @@ -291,7 +290,7 @@ func TestDriveSyncAskConflict_readErrorPropagates(t *testing.T) { // ────────────────────────────────────────────────────────── // --local-folder 指向一个常规文件 → MkdirAll 失败。 -func TestDriveSync_localRootIsFileFails(t *testing.T) { +func TestCrossPlatformCoverageDriveSync_localRootIsFileFails(t *testing.T) { dir := t.TempDir() asFile := filepath.Join(dir, "not-a-dir") mustWrite(t, asFile, "x") diff --git a/internal/helpers/drive_sync_platform_test.go b/internal/helpers/drive_sync_platform_test.go index 0705b61f..71f9313b 100644 --- a/internal/helpers/drive_sync_platform_test.go +++ b/internal/helpers/drive_sync_platform_test.go @@ -9,15 +9,15 @@ import ( "runtime" "testing" "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" ) // forceCaseInsensitiveFS 在当前用例内把大小写探测钉成不敏感,使等价路径冲突分支 // 在大小写敏感的 CI 文件系统(ext4)上同样可达。 func forceCaseInsensitiveFS(t *testing.T) { t.Helper() - prev := isCaseInsensitiveFS - isCaseInsensitiveFS = func(string) bool { return true } - t.Cleanup(func() { isCaseInsensitiveFS = prev }) + testseam.Swap(t, &isCaseInsensitiveFS, func(string) bool { return true }) } // ────────────────────────────────────────────────────────── @@ -26,7 +26,7 @@ func forceCaseInsensitiveFS(t *testing.T) { // 远端 A.txt 与 a.txt 在大小写不敏感目标上映射到同一本地文件 → 两项都 failed, // 绝不顺序覆盖导致静默丢文件。 -func TestDrivePull_equivalentPathCollisionFailsBoth(t *testing.T) { +func TestCrossPlatformCoverageDrivePull_equivalentPathCollisionFailsBoth(t *testing.T) { forceCaseInsensitiveFS(t) root := t.TempDir() @@ -52,7 +52,7 @@ func TestDrivePull_equivalentPathCollisionFailsBoth(t *testing.T) { } // sync 的 new_remote 阶段同样拒绝等价冲突项。 -func TestDriveSync_equivalentPathCollisionFailsBoth(t *testing.T) { +func TestCrossPlatformCoverageDriveSync_equivalentPathCollisionFailsBoth(t *testing.T) { forceCaseInsensitiveFS(t) root := t.TempDir() withSyncTransport(t, "remote") @@ -77,10 +77,9 @@ func TestDriveSync_equivalentPathCollisionFailsBoth(t *testing.T) { // detectCaseInsensitiveFS:探针名无大小写差异时回退平台默认 // ────────────────────────────────────────────────────────── -func TestDetectCaseInsensitiveFS_caselessProbeFallsBackToPlatformDefault(t *testing.T) { - prev := caseProbePattern - caseProbePattern = "1234567890*" // 纯数字:ToUpper 与原名相同,无法据此判定 - t.Cleanup(func() { caseProbePattern = prev }) +func TestCrossPlatformCoverageDetectCaseInsensitiveFS_caselessProbeFallsBackToPlatformDefault(t *testing.T) { + // 纯数字模板:ToUpper 与原名相同,无法据此判定。 + testseam.Swap(t, &caseProbePattern, "1234567890*") dir := t.TempDir() got := detectCaseInsensitiveFS(dir) @@ -95,7 +94,7 @@ func TestDetectCaseInsensitiveFS_caselessProbeFallsBackToPlatformDefault(t *test // isSafeRemoteSegmentPlatform:非 Windows 平台无额外约束 // ────────────────────────────────────────────────────────── -func TestIsSafeRemoteSegmentPlatform_allowsPlainNames(t *testing.T) { +func TestCrossPlatformCoverageIsSafeRemoteSegmentPlatform_allowsPlainNames(t *testing.T) { for _, name := range []string{"a.txt", "报告.pdf", "with space.md", "..."} { if !isSafeRemoteSegmentPlatform(name) { t.Errorf("isSafeRemoteSegmentPlatform(%q) = false, want true", name) @@ -134,7 +133,7 @@ func (irregularFileInfo) ModTime() time.Time { return time.Unix(0, 0) } func (irregularFileInfo) IsDir() bool { return false } func (irregularFileInfo) Sys() any { return nil } -func TestWalkLocalTreeEntry_errorBranches(t *testing.T) { +func TestCrossPlatformCoverageWalkLocalTreeEntry_errorBranches(t *testing.T) { files := map[string]*localFile{} // WalkDir 自身报错原样上抛。 @@ -179,7 +178,7 @@ func (regularFileInfo) ModTime() time.Time { return time.Unix(1, 0) } func (regularFileInfo) IsDir() bool { return false } func (regularFileInfo) Sys() any { return nil } -func TestWalkLocalForPushEntry_errorBranches(t *testing.T) { +func TestCrossPlatformCoverageWalkLocalForPushEntry_errorBranches(t *testing.T) { var dirs []string var files []localPushFile diff --git a/internal/helpers/drive_sync_test.go b/internal/helpers/drive_sync_test.go index fed0a21b..6dab3f7b 100644 --- a/internal/helpers/drive_sync_test.go +++ b/internal/helpers/drive_sync_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" "github.com/spf13/cobra" ) @@ -20,7 +21,7 @@ var errTestDownload = errors.New("simulated download failure") // driveSyncFailure — sync 部分失败错误:exit=1 // ────────────────────────────────────────────────────────── -func TestDriveSyncFailure(t *testing.T) { +func TestCrossPlatformCoverageDriveSyncFailure(t *testing.T) { e := &driveSyncFailure{failed: 2} if e.ExitCode() != 1 { t.Errorf("ExitCode() = %d, want 1", e.ExitCode()) @@ -37,7 +38,7 @@ func TestDriveSyncFailure(t *testing.T) { // driveSyncSuffixedRel — keep-both 本地重命名目标生成 // ────────────────────────────────────────────────────────── -func TestDriveSyncSuffixedRel(t *testing.T) { +func TestCrossPlatformCoverageDriveSyncSuffixedRel(t *testing.T) { // 基本:扩展名前插入基于 fileId 末 8 位的后缀。 got := driveSyncSuffixedRel("a/b.txt", "0123456789ABCDEF", map[string]bool{}) if want := "a/b.conflict-89ABCDEF.txt"; got != want { @@ -104,7 +105,7 @@ func runDriveSyncTestWithGet(t *testing.T, caller *driveSyncMockCaller, getFn fu } // 双向:本地独有 local.txt 应上传;远端独有 remote.txt 应下载到本地。 -func TestDriveSync_bidirectional(t *testing.T) { +func TestCrossPlatformCoverageDriveSync_bidirectional(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "local.txt"), "local-only") @@ -139,7 +140,7 @@ func TestDriveSync_bidirectional(t *testing.T) { } // modified + remote-wins:quick 模式下两侧 mtime 不同判为 modified,默认拉取远端覆盖本地。 -func TestDriveSync_modifiedRemoteWins(t *testing.T) { +func TestCrossPlatformCoverageDriveSync_modifiedRemoteWins(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "f.txt") mustWrite(t, p, "local-old") @@ -167,7 +168,7 @@ func TestDriveSync_modifiedRemoteWins(t *testing.T) { } // modified + local-wins:改用 local-wins 时应覆盖上传远端(传 overwriteFileId、不传 parentId)。 -func TestDriveSync_modifiedLocalWins(t *testing.T) { +func TestCrossPlatformCoverageDriveSync_modifiedLocalWins(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "f.txt") mustWrite(t, p, "local-new") @@ -197,7 +198,7 @@ func TestDriveSync_modifiedLocalWins(t *testing.T) { } // --dry-run:只计算差异,不触发任何 MCP 写操作与本地落盘。 -func TestDriveSync_dryRun(t *testing.T) { +func TestCrossPlatformCoverageDriveSync_dryRun(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "local.txt"), "x") @@ -219,7 +220,7 @@ func TestDriveSync_dryRun(t *testing.T) { // remote-wins 下载失败:本地原文件必须原样保留(pullOneFile 先写临时文件、成功才原子 // rename 覆盖),且命令以非零退出码退出。 -func TestDriveSync_pullFailureKeepsLocal(t *testing.T) { +func TestCrossPlatformCoverageDriveSync_pullFailureKeepsLocal(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "f.txt") mustWrite(t, p, "local-original") @@ -248,7 +249,7 @@ func TestDriveSync_pullFailureKeepsLocal(t *testing.T) { // keep-both 成功路径:本地既有文件改名保留(os.Rename 覆盖 O_EXCL 空占位),远端拉取到 // 原路径。此测试在 Windows 主机上运行即验证 os.Rename 可替换既有目标(MOVEFILE_REPLACE_ // EXISTING),覆盖 pull 覆盖与 keep-both 两条 rename 路径的平台行为。 -func TestDriveSync_keepBothSuccess(t *testing.T) { +func TestCrossPlatformCoverageDriveSync_keepBothSuccess(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "f.txt") mustWrite(t, p, "local-new") @@ -282,7 +283,7 @@ func TestDriveSync_keepBothSuccess(t *testing.T) { // keep-both 下载失败:改名保留后拉取远端失败,应回滚改名——原文件恢复原名原内容, // 不留下改名后的残留文件。 -func TestDriveSync_keepBothRollbackOnPullFailure(t *testing.T) { +func TestCrossPlatformCoverageDriveSync_keepBothRollbackOnPullFailure(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "f.txt") mustWrite(t, p, "local-original") @@ -314,7 +315,7 @@ func TestDriveSync_keepBothRollbackOnPullFailure(t *testing.T) { // reserveSyncKeepBothTarget 不得占用「精确同名」的既有文件(occupied 未记录时), // 应原子占位到下一个后缀,且既有文件内容不被破坏。此断言在任何文件系统上都成立。 -func TestReserveSyncKeepBothTarget_noOverwriteExact(t *testing.T) { +func TestCrossPlatformCoverageReserveSyncKeepBothTarget_noOverwriteExact(t *testing.T) { dir := t.TempDir() // 预置一个与首选候选精确同名的既有文件(模拟未被 occupied 记录的外部残留)。 first := "f.conflict-ABCD1234.txt" @@ -342,7 +343,7 @@ func TestReserveSyncKeepBothTarget_noOverwriteExact(t *testing.T) { // reserveSyncKeepBothTarget 在大小写不敏感文件系统上,不得覆盖与候选「大小写等价」的 // 既有文件——O_EXCL 由 OS 兜底判等价性,occupied 的精确字符串键覆盖不到这种情况。 -func TestReserveSyncKeepBothTarget_noOverwriteCaseEquivalent(t *testing.T) { +func TestCrossPlatformCoverageReserveSyncKeepBothTarget_noOverwriteCaseEquivalent(t *testing.T) { dir := t.TempDir() if !isCaseInsensitiveFS(dir) { t.Skip("文件系统大小写敏感,跳过大小写等价覆盖测试") @@ -367,10 +368,8 @@ func TestReserveSyncKeepBothTarget_noOverwriteCaseEquivalent(t *testing.T) { // 非交互 ask(stdin 立即 EOF):冲突项应被跳过而非中止整个同步, // new_local 仍上传、new_remote 仍下载,且被跳过的 modified 本地文件不被改动。 -func TestDriveSync_askNonInteractiveSkipsConflict(t *testing.T) { - prev := syncAskStdin - syncAskStdin = strings.NewReader("") // 非交互:立即 EOF - t.Cleanup(func() { syncAskStdin = prev }) +func TestCrossPlatformCoverageDriveSync_askNonInteractiveSkipsConflict(t *testing.T) { + testseam.Swap(t, &syncAskStdin, io.Reader(strings.NewReader(""))) // 非交互:立即 EOF dir := t.TempDir() mustWrite(t, filepath.Join(dir, "local.txt"), "local-only") // new_local → 应上传 From 56bb50913b2f5094525d96ca343cc898ff722a60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Wed, 12 Aug 2026 16:23:13 +0800 Subject: [PATCH 09/86] feat(chat): default message query time ranges --- internal/helpers/chat.go | 112 +++++++++--------- internal/helpers/chat_message_help_test.go | 93 +++++++++++++++ .../helpers/chat_message_pagination_test.go | 84 +++++++++++++ .../helpers/im_read_result_contract_test.go | 38 +++++- test/fixtures/cli-interface-baseline.txt | 8 +- 5 files changed, 275 insertions(+), 60 deletions(-) diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 3bf56fe6..3cc7a516 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -467,22 +467,17 @@ func validateNativeSearchConversationScope(conversationIDs []string) error { } func chatMessageListAllArgs(cmd *cobra.Command) (map[string]any, error) { - if err := validateRequiredFlags(cmd, "start", "end"); err != nil { - return nil, err - } + startRaw, endRaw := defaultChatMessageTimeRange(cmd, 24*time.Hour) cursor, _ := cmd.Flags().GetString("cursor") return map[string]any{ - "startTime": mustGetFlag(cmd, "start"), - "endTime": mustGetFlag(cmd, "end"), + "startTime": startRaw, + "endTime": endRaw, "limit": chatIntFlagOrFallback(cmd, "limit", "size"), "cursor": cursor, }, nil } func chatMessageListBySenderArgs(cmd *cobra.Command) (map[string]any, error) { - if err := validateRequiredFlags(cmd, "start"); err != nil { - return nil, err - } senderUserID := flagOrFallback(cmd, "sender-user-id", "sender") senderOpenDingTalkID, _ := cmd.Flags().GetString("sender-open-dingtalk-id") if senderUserID != "" && senderOpenDingTalkID != "" { @@ -491,14 +486,11 @@ func chatMessageListBySenderArgs(cmd *cobra.Command) (map[string]any, error) { if senderUserID == "" && senderOpenDingTalkID == "" { return nil, fmt.Errorf("--sender-user-id or --sender-open-dingtalk-id is required") } - startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start")) + startRaw, endRaw := defaultChatMessageTimeRange(cmd, 7*24*time.Hour) + startMs, err := parseISOTimeToMillis("start", startRaw) if err != nil { return nil, err } - endRaw, _ := cmd.Flags().GetString("end") - if strings.TrimSpace(endRaw) == "" { - endRaw = time.Now().Format(time.RFC3339) - } endMs, err := parseISOTimeToMillis("end", endRaw) if err != nil { return nil, err @@ -522,14 +514,12 @@ func chatMessageListBySenderArgs(cmd *cobra.Command) (map[string]any, error) { } func chatMessageListMentionsArgs(cmd *cobra.Command) (map[string]any, error) { - if err := validateRequiredFlags(cmd, "start", "end"); err != nil { - return nil, err - } - startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start")) + startRaw, endRaw := defaultChatMessageTimeRange(cmd, 7*24*time.Hour) + startMs, err := parseISOTimeToMillis("start", startRaw) if err != nil { return nil, err } - endMs, err := parseISOTimeToMillis("end", mustGetFlag(cmd, "end")) + endMs, err := parseISOTimeToMillis("end", endRaw) if err != nil { return nil, err } @@ -564,14 +554,12 @@ func chatMessageSearchArgs(cmd *cobra.Command) (map[string]any, error) { if err := validateRequiredFlagWithAliases(cmd, "query", "keyword"); err != nil { return nil, err } - if err := validateRequiredFlags(cmd, "start", "end"); err != nil { - return nil, err - } - startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start")) + startRaw, endRaw := defaultChatMessageTimeRange(cmd, 7*24*time.Hour) + startMs, err := parseISOTimeToMillis("start", startRaw) if err != nil { return nil, err } - endMs, err := parseISOTimeToMillis("end", mustGetFlag(cmd, "end")) + endMs, err := parseISOTimeToMillis("end", endRaw) if err != nil { return nil, err } @@ -592,6 +580,19 @@ func chatMessageSearchArgs(cmd *cobra.Command) (map[string]any, error) { return toolArgs, nil } +func defaultChatMessageTimeRange(cmd *cobra.Command, lookback time.Duration) (string, string) { + startRaw := mustGetFlag(cmd, "start") + endRaw := mustGetFlag(cmd, "end") + now := time.Now() + if endRaw == "" { + endRaw = now.Format(time.RFC3339) + } + if startRaw == "" { + startRaw = now.Add(-lookback).Format(time.RFC3339) + } + return startRaw, endRaw +} + func chatMessageSearchAdvancedArgs(cmd *cobra.Command) (map[string]any, error) { toolArgs := map[string]any{} if v := flagOrFallback(cmd, "query", "keyword"); v != "" { @@ -2605,8 +2606,9 @@ func newChatCommand() *cobra.Command { chatMessageListCmd := &cobra.Command{ Use: "list", Short: "拉取会话消息内容", - Long: `拉取指定群聊或单聊的会话消息内容。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId 和 content。--group 指定群聊,--user 指定单聊用户(userId),--open-dingtalk-id 指定单聊用户(openDingTalkId),三者互斥。推荐使用 --direction newer/older 控制时间方向:newer 表示从给定时间往现在拉,older 表示从给定时间往以前拉。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。引用回复消息会返回 quotedMessage 引用上下文;被引用的原消息是合并转发或图片时,对应的类型与内容也会随引用上下文返回。如果返回的会话消息中包含 openConvThreadId 字段,说明是话题消息,可以调用 dws chat message list-topic-replies 拉取话题回复消息列表,openConvThreadId 作为 topic-id 参数。`, - Example: ` dws chat message list --group --time "2025-03-01 00:00:00" + Long: `拉取指定群聊或单聊的会话消息内容。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId 和 content。--group 指定群聊,--user 指定单聊用户(userId),--open-dingtalk-id 指定单聊用户(openDingTalkId),三者互斥。--time 可选,不传时默认当前时间并向旧消息拉取。推荐使用 --direction newer/older 控制时间方向:newer 表示从给定时间往现在拉,older 表示从给定时间往以前拉。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。引用回复消息会返回 quotedMessage 引用上下文;被引用的原消息是合并转发或图片时,对应的类型与内容也会随引用上下文返回。如果返回的会话消息中包含 openConvThreadId 字段,说明是话题消息,可以调用 dws chat message list-topic-replies 拉取话题回复消息列表,openConvThreadId 作为 topic-id 参数。`, + Example: ` dws chat message list --group + dws chat message list --group --time "2025-03-01 00:00:00" dws chat message list --user --time "2025-03-01 00:00:00" --limit 50 dws chat message list --open-dingtalk-id --time "2025-03-01 00:00:00" --limit 50 dws chat message list --group --time "2025-03-01 00:00:00" --direction older @@ -2614,9 +2616,6 @@ func newChatCommand() *cobra.Command { # 查询群 ID: dws chat search --query "群名" # 查询 userId: dws contact user search --query "姓名"`, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "time"); err != nil { - return err - } groupID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat") userID, _ := cmd.Flags().GetString("user") openDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id") @@ -2640,11 +2639,16 @@ func newChatCommand() *cobra.Command { openDingTalkID = userID userID = "" } - forward, err := resolveMessageForward(cmd, true) + timeVal := mustGetFlag(cmd, "time") + defaultForward := true + if timeVal == "" { + timeVal = time.Now().Format("2006-01-02 15:04:05") + defaultForward = false + } + forward, err := resolveMessageForward(cmd, defaultForward) if err != nil { return err } - timeVal := mustGetFlag(cmd, "time") if groupID != "" { toolArgs := map[string]any{ "openconversation_id": groupID, @@ -2695,6 +2699,7 @@ func newChatCommand() *cobra.Command { UseWhen: []string{"用户明确指定某个会话,并要读取消息或追溯引用回复中的原消息上下文时"}, AvoidWhen: []string{"跨全部会话按时间查询时使用 chat message list-all"}, Examples: []string{ + "dws chat message list --group --limit 50", "dws chat message list --group --time \"2026-07-01 00:00:00\" --limit 50", "dws chat message list --group --time \"2026-07-01 00:00:00\" --limit 50 --jq '.messages[] | {messageId, text}'", }, @@ -3483,7 +3488,7 @@ func newChatCommand() *cobra.Command { chatMessageListAllCmd := &cobra.Command{ Use: "list-all", Short: "拉取指定时间范围内当前用户的所有会话消息", - Long: `分页拉取当前登录用户在指定时间范围内的所有会话消息。--start 和 --end 限定时间范围,--limit 指定每页数量,--cursor 传分页游标(首页传 0)。服务端按 cursor 分页返回,hasMore=true 时用返回的 nextCursor 值继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。如果当前账号没有消息搜索权益,CLI 会保留服务端返回的友好提示与开通入口;不要把权限错误解释为时间范围内没有消息。`, + Long: `分页拉取当前登录用户在指定时间范围内的所有会话消息。--start 和 --end 可选,不传时默认最近 1 天到当前时间,避免跨全部会话范围过大;--limit 指定每页数量,--cursor 传分页游标(首页传 0)。服务端按 cursor 分页返回,hasMore=true 时用返回的 nextCursor 值继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。如果当前账号没有消息搜索权益,CLI 会保留服务端返回的友好提示与开通入口;不要把权限错误解释为时间范围内没有消息。`, Example: ` dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 50 dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 50 --cursor "abc123token" dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 100 --page-all --page-limit 20 --max-items 500 --page-delay 0`, @@ -3530,7 +3535,7 @@ func newChatCommand() *cobra.Command { chatMessageListBySenderCmd := &cobra.Command{ Use: "list-by-sender", Short: "拉取指定发送者的消息(包含单聊和群聊)", - Long: `搜索特定人发送给我的消息,返回结果包含单聊和群聊标识。--sender-user-id 指定发送者 userId,--sender-open-dingtalk-id 指定发送者 openDingTalkId,二者互斥。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`, + Long: `搜索特定人发送给我的消息,返回结果包含单聊和群聊标识。--sender-user-id 指定发送者 userId,--sender-open-dingtalk-id 指定发送者 openDingTalkId,二者互斥。--start 和 --end 可选,不传时默认最近 7 天到当前时间。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`, Example: ` dws chat message list-by-sender --sender-user-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 dws chat message list-by-sender --sender-open-dingtalk-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 dws chat message list-by-sender --sender-user-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --limit 20 --cursor 0 @@ -3582,7 +3587,7 @@ func newChatCommand() *cobra.Command { chatMessageListMentionsCmd := &cobra.Command{ Use: "list-mentions", Short: "拉取 @我 的消息", - Long: `搜索时间范围内 @我 的消息,可选指定群聊。返回结果包含单聊和群聊标识。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`, + Long: `搜索时间范围内 @我 的消息,可选指定群聊。--start 和 --end 可选,不传时默认最近 7 天到当前时间。返回结果包含单聊和群聊标识。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持单页调用。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`, Example: ` dws chat message list-mentions --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 dws chat message list-mentions --start "2026-04-01T00:00:00+08:00" --end "2026-04-14T00:00:00+08:00" --limit 20 --cursor 0 dws chat message list-mentions --group --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0 @@ -3770,7 +3775,7 @@ func newChatCommand() *cobra.Command { chatMessageSearchCmd := &cobra.Command{ Use: "search", Short: "按关键词搜索消息", - Long: `在当前用户的会话中按关键词搜索消息。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId、content 和原始 result。--query 指定搜索关键词(必填)。可选 --group 限定搜索某个会话,不传则搜索所有会话。显式指定会话时,CLI 会先验证 CID,再扫描全局搜索流并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID;默认最多扫描 40 页并返回至 --limit 条范围内消息。时间参数 --start/--end(ISO-8601)限定搜索时间范围。分页参数 --limit(默认 100)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。未指定会话时默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持默认行为。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`, + Long: `在当前用户的会话中按关键词搜索消息。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId、content 和原始 result。--query 指定搜索关键词(必填)。可选 --group 限定搜索某个会话,不传则搜索所有会话。显式指定会话时,CLI 会先验证 CID,再扫描全局搜索流并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID;默认最多扫描 40 页并返回至 --limit 条范围内消息。时间参数 --start/--end(ISO-8601)可选,不传时默认最近 7 天到当前时间。分页参数 --limit(默认 100)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。未指定会话时默认只读取单页;只有显式传 --page-all 才会自动翻页并保留、合并 result.conversationMessagesList,同一会话跨页合并 messages。只传 --page-limit、--max-items 或 --page-delay 仍保持默认行为。自动翻页时 --page-limit 控制最多请求页数,--max-items 按消息数精确截断,--page-delay 控制页间等待毫秒数。`, Example: ` dws chat message search --query "changefree" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 50 --cursor 0 dws chat message search --query "codereview" --group --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --cursor 0 dws chat message search --query "链接" --start "2026-04-15T00:00:00+08:00" --end "2026-04-16T00:00:00+08:00" --limit 100 --cursor @@ -4328,9 +4333,9 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 chatMessageListCmd.Flags().String("group", "", "群聊 openconversation_id(群聊时必填)") chatMessageListCmd.Flags().String("user", "", "单聊用户 userId(单聊时与 --open-dingtalk-id 二选一)") chatMessageListCmd.Flags().String("open-dingtalk-id", "", "单聊用户 openDingTalkId(单聊时与 --user 二选一,适用于无法获取 userId 的场景)") - chatMessageListCmd.Flags().String("time", "", "开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)") - chatMessageListCmd.Flags().String("direction", "", "时间方向: newer=从给定时间往现在拉,older=从给定时间往以前拉(推荐)") - chatMessageListCmd.Flags().String("forward", "true", "true 等价 --direction newer,false 等价 --direction older") + chatMessageListCmd.Flags().String("time", "", "开始时间,格式: yyyy-MM-dd HH:mm:ss(可选,默认当前时间)") + chatMessageListCmd.Flags().String("direction", "", "时间方向: newer=从给定时间往现在拉,older=从给定时间往以前拉(未传 --time 时默认 older)") + chatMessageListCmd.Flags().String("forward", "true", "true 等价 --direction newer,false 等价 --direction older(未传 --time 时默认 false)") _ = chatMessageListCmd.Flags().MarkHidden("forward") chatMessageListCmd.Flags().Int("limit", 0, "返回数量,不传则不限制") chatMessageListCmd.Flags().Int("size", 0, "--limit 的旧版别名") @@ -4444,10 +4449,8 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 chatMessageListTopicRepliesCmd.Flags().String("forward", "false", "true 等价 --direction newer,false 等价 --direction older(默认 false)") _ = chatMessageListTopicRepliesCmd.Flags().MarkHidden("forward") - chatMessageListAllCmd.Flags().String("start", "", "起始时间,格式: yyyy-MM-dd HH:mm:ss (必填)") - _ = chatMessageListAllCmd.MarkFlagRequired("start") - chatMessageListAllCmd.Flags().String("end", "", "结束时间,格式: yyyy-MM-dd HH:mm:ss (必填)") - _ = chatMessageListAllCmd.MarkFlagRequired("end") + chatMessageListAllCmd.Flags().String("start", "", "起始时间,格式: yyyy-MM-dd HH:mm:ss(可选,默认当前时间前 1 天)") + chatMessageListAllCmd.Flags().String("end", "", "结束时间,格式: yyyy-MM-dd HH:mm:ss(可选,默认当前时间)") chatMessageListAllCmd.Flags().Int("limit", 50, "每页返回数量(默认 50)") chatMessageListAllCmd.Flags().Int("size", 0, "--limit 的旧版别名") _ = chatMessageListAllCmd.Flags().MarkHidden("size") @@ -4461,9 +4464,8 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 chatMessageListBySenderCmd.Flags().String("sender-open-dingtalk-id", "", "发送者 openDingTalkId(与 --sender-user-id 二选一,适用于无法获取 userId 的场景)") chatMessageListBySenderCmd.Flags().String("user", "", "") _ = chatMessageListBySenderCmd.Flags().MarkHidden("user") - chatMessageListBySenderCmd.Flags().String("start", "", "开始时间,ISO-8601 格式 (必填)") - _ = chatMessageListBySenderCmd.MarkFlagRequired("start") - chatMessageListBySenderCmd.Flags().String("end", "", "结束时间,ISO-8601 格式 (必填)") + chatMessageListBySenderCmd.Flags().String("start", "", "开始时间,ISO-8601 格式(可选,默认当前时间前 7 天)") + chatMessageListBySenderCmd.Flags().String("end", "", "结束时间,ISO-8601 格式(可选,默认当前时间)") chatMessageListBySenderCmd.Flags().Int("limit", 50, "每页返回数量(默认 50)") chatMessageListBySenderCmd.Flags().Int("size", 0, "--limit 的旧版别名") _ = chatMessageListBySenderCmd.Flags().MarkHidden("size") @@ -4472,10 +4474,8 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 // list-mentions flags chatMessageListMentionsCmd.Flags().String("group", "", "群聊 openconversation_id(可选,不传则查全部)") - chatMessageListMentionsCmd.Flags().String("start", "", "开始时间,ISO-8601 格式 (必填)") - _ = chatMessageListMentionsCmd.MarkFlagRequired("start") - chatMessageListMentionsCmd.Flags().String("end", "", "结束时间,ISO-8601 格式 (必填)") - _ = chatMessageListMentionsCmd.MarkFlagRequired("end") + chatMessageListMentionsCmd.Flags().String("start", "", "开始时间,ISO-8601 格式(可选,默认当前时间前 7 天)") + chatMessageListMentionsCmd.Flags().String("end", "", "结束时间,ISO-8601 格式(可选,默认当前时间)") chatMessageListMentionsCmd.Flags().Int("limit", 50, "每页返回数量(默认 50)") chatMessageListMentionsCmd.Flags().Int("size", 0, "--limit 的旧版别名") _ = chatMessageListMentionsCmd.Flags().MarkHidden("size") @@ -4500,10 +4500,8 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 chatMessageSearchCmd.Flags().String("keyword", "", "--query 的别名") _ = chatMessageSearchCmd.Flags().MarkHidden("keyword") chatMessageSearchCmd.Flags().String("group", "", "群聊 openconversation_id(可选,不传则搜索所有会话)") - chatMessageSearchCmd.Flags().String("start", "", "开始时间,ISO-8601 格式 (必填)") - _ = chatMessageSearchCmd.MarkFlagRequired("start") - chatMessageSearchCmd.Flags().String("end", "", "结束时间,ISO-8601 格式 (必填)") - _ = chatMessageSearchCmd.MarkFlagRequired("end") + chatMessageSearchCmd.Flags().String("start", "", "开始时间,ISO-8601 格式(可选,默认当前时间前 7 天)") + chatMessageSearchCmd.Flags().String("end", "", "结束时间,ISO-8601 格式(可选,默认当前时间)") chatMessageSearchCmd.Flags().Int("limit", 100, "每页返回数量(默认 100)") chatMessageSearchCmd.Flags().Int("size", 0, "--limit 的旧版别名") _ = chatMessageSearchCmd.Flags().MarkHidden("size") @@ -5913,6 +5911,9 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 Short: "下载消息中的资源(图片/视频/语音等)到本地", Long: `下载聊天消息中的图片、视频、语音等资源到本地文件。 +本命令只支持聊天消息 mediaId 下载,不支持钉盘 fileId。 +如需用 fileId 下载,请使用钉盘/drive 下载命令。 + 流程: 1. 根据 resourceType + resource-id + message-id + open-conversation-id 向服务端获取下载 URL 2. HTTP GET 下载文件到本地 @@ -5923,6 +5924,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 dws chat message download-media --type mediaId --resource-id --message-id --open-conversation-id --output ./downloads/ dws chat message download-media --type mediaId --resource-id --message-id --open-conversation-id --output ./photo.jpg # resource-id: 从 dws chat message list 返回的消息内容中获取 mediaId + # 不支持钉盘 fileId;fileId 下载请使用钉盘/drive 下载命令 # message-id: 从 dws chat message list 返回的 openMessageId # open-conversation-id: 从 dws chat search 获取 openConversationId`, PreRunE: func(cmd *cobra.Command, args []string) error { @@ -6052,16 +6054,16 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 Selection: contract.SelectionSpec{ AgentSummary: "下载消息中的媒体资源到本地", UseWhen: []string{"已知消息、会话和资源 ID,需要保存媒体文件时"}, - AvoidWhen: []string{"只查看文本消息内容时使用对应消息查询命令"}, + AvoidWhen: []string{"只查看文本消息内容时使用对应消息查询命令", "资源是钉盘 fileId 时使用钉盘/drive 下载命令"}, Examples: []string{"dws chat message download-media --type mediaId --resource-id --message-id --open-conversation-id --output ."}, }, }, }) // download-media flags - chatMessageDownloadMediaCmd.Flags().String("type", "", "资源类型: mediaId (必填)") + chatMessageDownloadMediaCmd.Flags().String("type", "", "资源类型: mediaId(必填;仅支持聊天消息 mediaId,不支持钉盘 fileId)") _ = chatMessageDownloadMediaCmd.MarkFlagRequired("type") - chatMessageDownloadMediaCmd.Flags().String("resource-id", "", "资源 ID,mediaId 类型时为消息中的 mediaId 值 (必填)") + chatMessageDownloadMediaCmd.Flags().String("resource-id", "", "资源 ID,mediaId 类型时为消息中的 mediaId 值(必填;不是钉盘 fileId)") _ = chatMessageDownloadMediaCmd.MarkFlagRequired("resource-id") chatMessageDownloadMediaCmd.Flags().String("open-conversation-id", "", "会话 openConversationId (必填)") _ = chatMessageDownloadMediaCmd.MarkFlagRequired("open-conversation-id") diff --git a/internal/helpers/chat_message_help_test.go b/internal/helpers/chat_message_help_test.go index fcf1548c..6941e2e8 100644 --- a/internal/helpers/chat_message_help_test.go +++ b/internal/helpers/chat_message_help_test.go @@ -90,3 +90,96 @@ func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing }) } } + +func TestCrossPlatformCoverageChatMessageHelpDocumentsOptionalTimeDefaults(t *testing.T) { + tests := []struct { + name string + args []string + contains []string + absent []string + }{ + { + name: "list", + args: []string{"message", "list", "--help"}, + contains: []string{ + "--time 可选,不传时默认当前时间并向旧消息拉取", + "默认当前时间", + "未传 --time 时默认 older", + }, + absent: []string{"开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)"}, + }, + { + name: "search", + args: []string{"message", "search", "--help"}, + contains: []string{ + "可选,不传时默认最近 7 天到当前时间", + "默认当前时间前 7 天", + "默认当前时间", + }, + absent: []string{"开始时间,ISO-8601 格式 (必填)", "结束时间,ISO-8601 格式 (必填)"}, + }, + { + name: "list-by-sender", + args: []string{"message", "list-by-sender", "--help"}, + contains: []string{ + "--start 和 --end 可选,不传时默认最近 7 天到当前时间", + "默认当前时间前 7 天", + "默认当前时间", + }, + absent: []string{"开始时间,ISO-8601 格式 (必填)", "结束时间,ISO-8601 格式 (必填)"}, + }, + { + name: "list-mentions", + args: []string{"message", "list-mentions", "--help"}, + contains: []string{ + "--start 和 --end 可选,不传时默认最近 7 天到当前时间", + "默认当前时间前 7 天", + "默认当前时间", + }, + absent: []string{"开始时间,ISO-8601 格式 (必填)", "结束时间,ISO-8601 格式 (必填)"}, + }, + { + name: "list-all", + args: []string{"message", "list-all", "--help"}, + contains: []string{ + "--start 和 --end 可选,不传时默认最近 1 天到当前时间", + "默认当前时间前 1 天", + "默认当前时间", + }, + absent: []string{"起始时间,格式: yyyy-MM-dd HH:mm:ss (必填)", "结束时间,格式: yyyy-MM-dd HH:mm:ss (必填)"}, + }, + { + name: "download-media", + args: []string{"message", "download-media", "--help"}, + contains: []string{ + "只支持聊天消息 mediaId 下载,不支持钉盘 fileId", + "fileId 下载请使用钉盘/drive 下载命令", + "仅支持聊天消息 mediaId,不支持钉盘 fileId", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cmd := newChatCommand() + var output bytes.Buffer + cmd.SetOut(&output) + cmd.SetErr(&output) + cmd.SetArgs(test.args) + if err := cmd.Execute(); err != nil { + t.Fatalf("dws chat %s: %v\n%s", strings.Join(test.args, " "), err, output.String()) + } + help := output.String() + for _, want := range test.contains { + if !strings.Contains(help, want) { + t.Errorf("chat message %s help missing %q:\n%s", test.name, want, help) + } + } + for _, unwanted := range test.absent { + if strings.Contains(help, unwanted) { + t.Errorf("chat message %s help still contains %q:\n%s", test.name, unwanted, help) + } + } + }) + } +} diff --git a/internal/helpers/chat_message_pagination_test.go b/internal/helpers/chat_message_pagination_test.go index 21bac2c7..2b6c28ab 100644 --- a/internal/helpers/chat_message_pagination_test.go +++ b/internal/helpers/chat_message_pagination_test.go @@ -148,6 +148,90 @@ func TestChatMessagePaginationDefaultSinglePageUnchanged(t *testing.T) { } } +func TestChatMessagePaginationUsesDefaultTimeWindows(t *testing.T) { + tests := []struct { + name string + args []string + wantTool string + wantLookback time.Duration + }{ + { + name: "list-all defaults to one day", + args: []string{"message", "list-all"}, + wantTool: "search_messages_by_time_range", + wantLookback: 24 * time.Hour, + }, + { + name: "list-by-sender defaults to seven days", + args: []string{"message", "list-by-sender", "--sender-user-id", "u1"}, + wantTool: "search_messages_by_sender", + wantLookback: 7 * 24 * time.Hour, + }, + { + name: "list-mentions defaults to seven days", + args: []string{"message", "list-mentions"}, + wantTool: "search_at_me_message", + wantLookback: 7 * 24 * time.Hour, + }, + { + name: "search defaults to seven days", + args: []string{"message", "search", "--query", "发布"}, + wantTool: "search_messages_by_keyword", + wantLookback: 7 * 24 * time.Hour, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caller := &chatMessagePaginationCaller{} + before := time.Now() + _, err := executeChatMessagePaginationCommand(t, caller, tt.args...) + after := time.Now() + if err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 { + t.Fatalf("calls = %#v, want one call", caller.calls) + } + got := caller.calls[0] + if got.tool != tt.wantTool { + t.Fatalf("tool = %q, want %q", got.tool, tt.wantTool) + } + startMs := numericArgAsInt64(t, got.args["startTime"]) + endMs := numericArgAsInt64(t, got.args["endTime"]) + if endMs < before.UnixMilli() || endMs > after.Add(time.Second).UnixMilli() { + t.Fatalf("endTime = %d, want between %d and %d", endMs, before.UnixMilli(), after.Add(time.Second).UnixMilli()) + } + wantStartMin := before.Add(-tt.wantLookback).UnixMilli() + wantStartMax := after.Add(-tt.wantLookback).Add(time.Second).UnixMilli() + if startMs < wantStartMin || startMs > wantStartMax { + t.Fatalf("startTime = %d, want between %d and %d", startMs, wantStartMin, wantStartMax) + } + }) + } +} + +func numericArgAsInt64(t *testing.T, value any) int64 { + t.Helper() + switch v := value.(type) { + case int64: + return v + case int: + return int64(v) + case float64: + return int64(v) + case string: + parsed, err := parseISOTimeToMillis("time", v) + if err != nil { + t.Fatalf("parse time arg %q: %v", v, err) + } + return parsed + default: + t.Fatalf("unsupported numeric arg type %T (%#v)", value, value) + return 0 + } +} + func TestChatMessagePaginationPageAllAggregatesSevenCommands(t *testing.T) { tests := []struct { name string diff --git a/internal/helpers/im_read_result_contract_test.go b/internal/helpers/im_read_result_contract_test.go index 3cff91cc..d2df5bc2 100644 --- a/internal/helpers/im_read_result_contract_test.go +++ b/internal/helpers/im_read_result_contract_test.go @@ -22,6 +22,7 @@ import ( "os" "reflect" "testing" + "time" "github.com/spf13/cobra" @@ -39,11 +40,16 @@ type imReadResultCaller struct { results map[string]*edition.ToolResult errors map[string]error calls []imReadResultCall + args map[string]any dryRun bool } -func (c *imReadResultCaller) CallTool(_ context.Context, productID, toolName string, _ map[string]any) (*edition.ToolResult, error) { +func (c *imReadResultCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) { c.calls = append(c.calls, imReadResultCall{productID: productID, toolName: toolName}) + c.args = map[string]any{} + for k, v := range args { + c.args[k] = v + } if err := c.errors[toolName]; err != nil { return nil, err } @@ -203,6 +209,36 @@ func TestCrossPlatformCoverageChatMessageListProjectsStableFieldsAndPreservesLeg } } +func TestCrossPlatformCoverageChatMessageListDefaultsTimeAndOlderDirection(t *testing.T) { + payload := `{"result":{"messages":[],"hasMore":false}}` + caller := &imReadResultCaller{responses: map[string]string{"list_conversation_message_v2": payload}} + + before := time.Now() + _, err := executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand, + "message", "list", "--group", "cid-1", "--limit", "50") + after := time.Now() + if err != nil { + t.Fatalf("chat message list returned error: %v", err) + } + if len(caller.calls) != 1 || caller.calls[0] != (imReadResultCall{productID: "chat", toolName: "list_conversation_message_v2"}) { + t.Fatalf("calls = %#v, want chat/list_conversation_message_v2", caller.calls) + } + if got := caller.args["forward"]; got != false { + t.Fatalf("forward = %#v, want false when --time is omitted", got) + } + timeRaw, ok := caller.args["time"].(string) + if !ok || strings.TrimSpace(timeRaw) == "" { + t.Fatalf("time arg = %#v, want generated current time", caller.args["time"]) + } + gotTime, err := time.ParseInLocation("2006-01-02 15:04:05", timeRaw, time.Local) + if err != nil { + t.Fatalf("parse generated time %q: %v", timeRaw, err) + } + if gotTime.Before(before.Add(-time.Second)) || gotTime.After(after.Add(time.Second)) { + t.Fatalf("time = %s, want between %s and %s", gotTime, before, after) + } +} + func TestCrossPlatformCoverageChatMessageSearchProjectsStableFieldsAndPreservesLegacy(t *testing.T) { payload := `{ "result": { diff --git a/test/fixtures/cli-interface-baseline.txt b/test/fixtures/cli-interface-baseline.txt index fab13963..db802415 100644 --- a/test/fixtures/cli-interface-baseline.txt +++ b/test/fixtures/cli-interface-baseline.txt @@ -2692,7 +2692,7 @@ [chat.message.list-all] runnable: true hidden: false - flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --cursor:string|required=false|hidden=false|no-opt=""|scope=local, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --end:string|required=true|hidden=false|no-opt=""|scope=local, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --limit:int|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --size:int|required=false|hidden=true|no-opt=""|scope=local, --start:string|required=true|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited + flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --cursor:string|required=false|hidden=false|no-opt=""|scope=local, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --end:string|required=false|hidden=false|no-opt=""|scope=local, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --limit:int|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --size:int|required=false|hidden=true|no-opt=""|scope=local, --start:string|required=false|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited [chat.message.list-by-ids] runnable: true @@ -2702,7 +2702,7 @@ [chat.message.list-by-sender] runnable: true hidden: false - flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --cursor:string|required=false|hidden=false|no-opt=""|scope=local, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --end:string|required=false|hidden=false|no-opt=""|scope=local, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --limit:int|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --sender:string|required=false|hidden=true|no-opt=""|scope=local, --sender-open-dingtalk-id:string|required=false|hidden=false|no-opt=""|scope=local, --sender-user-id:string|required=false|hidden=false|no-opt=""|scope=local, --size:int|required=false|hidden=true|no-opt=""|scope=local, --start:string|required=true|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, --user:string|required=false|hidden=true|no-opt=""|scope=local, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited + flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --cursor:string|required=false|hidden=false|no-opt=""|scope=local, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --end:string|required=false|hidden=false|no-opt=""|scope=local, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --limit:int|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --sender:string|required=false|hidden=true|no-opt=""|scope=local, --sender-open-dingtalk-id:string|required=false|hidden=false|no-opt=""|scope=local, --sender-user-id:string|required=false|hidden=false|no-opt=""|scope=local, --size:int|required=false|hidden=true|no-opt=""|scope=local, --start:string|required=false|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, --user:string|required=false|hidden=true|no-opt=""|scope=local, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited [chat.message.list-emotion-replies] runnable: true @@ -2722,7 +2722,7 @@ [chat.message.list-mentions] runnable: true hidden: false - flags: --chat:string|required=false|hidden=true|no-opt=""|scope=local, --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --conversation-id:string|required=false|hidden=true|no-opt=""|scope=local, --cursor:string|required=false|hidden=false|no-opt=""|scope=local, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --end:string|required=true|hidden=false|no-opt=""|scope=local, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, --group:string|required=false|hidden=false|no-opt=""|scope=local, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --id:string|required=false|hidden=true|no-opt=""|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --limit:int|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --size:int|required=false|hidden=true|no-opt=""|scope=local, --start:string|required=true|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited + flags: --chat:string|required=false|hidden=true|no-opt=""|scope=local, --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --conversation-id:string|required=false|hidden=true|no-opt=""|scope=local, --cursor:string|required=false|hidden=false|no-opt=""|scope=local, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --end:string|required=false|hidden=false|no-opt=""|scope=local, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, --group:string|required=false|hidden=false|no-opt=""|scope=local, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --id:string|required=false|hidden=true|no-opt=""|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --limit:int|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --size:int|required=false|hidden=true|no-opt=""|scope=local, --start:string|required=false|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited [chat.message.list-pin-msg] runnable: true @@ -2782,7 +2782,7 @@ [chat.message.search] runnable: true hidden: false - flags: --chat:string|required=false|hidden=true|no-opt=""|scope=local, --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --conversation-id:string|required=false|hidden=true|no-opt=""|scope=local, --cursor:string|required=false|hidden=false|no-opt=""|scope=local, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --end:string|required=true|hidden=false|no-opt=""|scope=local, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, --group:string|required=false|hidden=false|no-opt=""|scope=local, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --id:string|required=false|hidden=true|no-opt=""|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --keyword:string|required=false|hidden=true|no-opt=""|scope=local, --limit:int|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --query:string|required=false|hidden=false|no-opt=""|scope=local, --size:int|required=false|hidden=true|no-opt=""|scope=local, --start:string|required=true|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited + flags: --chat:string|required=false|hidden=true|no-opt=""|scope=local, --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --conversation-id:string|required=false|hidden=true|no-opt=""|scope=local, --cursor:string|required=false|hidden=false|no-opt=""|scope=local, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --end:string|required=false|hidden=false|no-opt=""|scope=local, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, --group:string|required=false|hidden=false|no-opt=""|scope=local, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --id:string|required=false|hidden=true|no-opt=""|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --keyword:string|required=false|hidden=true|no-opt=""|scope=local, --limit:int|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --query:string|required=false|hidden=false|no-opt=""|scope=local, --size:int|required=false|hidden=true|no-opt=""|scope=local, --start:string|required=false|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited [chat.message.search-advanced] runnable: true From a53971b146860f20852c6ccba90540129a116b37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E8=B0=A8?= <2940480744@qq.com> Date: Wed, 12 Aug 2026 16:23:17 +0800 Subject: [PATCH 10/86] feat(cli): standardize Doc and Drive parameter aliases --- internal/app/param_alias_e2e_test.go | 39 + .../param_alias_payload_equivalence_test.go | 206 ++- internal/cli/param_aliases_generated.go | 1507 ++++++++++++++--- internal/cli/param_concepts.json | 259 ++- 4 files changed, 1757 insertions(+), 254 deletions(-) diff --git a/internal/app/param_alias_e2e_test.go b/internal/app/param_alias_e2e_test.go index 8267390c..77b9ac5b 100644 --- a/internal/app/param_alias_e2e_test.go +++ b/internal/app/param_alias_e2e_test.go @@ -68,6 +68,45 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string return `{"result":[{"templateId":"fixture-template-id"}]}` case "create_document": return `{"nodeId":"fixture-node"}` + case "list_files": + return `{"success":true,"result":{"files":[],"hasMore":false}}` + case "list_recycle_items": + return `{"success":true,"result":{"recycleItems":[{"recycleItemId":"recycle-1","originalName":"Fixture Node"}],"hasMore":false}}` + case "get_star_list": + return `{"success":true,"result":{"starList":[],"hasMore":false}}` + case "list_file_versions": + return `{"success":true,"result":{"versions":[{"version":3,"name":"Fixture Version"}],"hasMore":false}}` + case "get_file_info": + name := "Fixture Node" + for index := len(c.calls) - 2; index >= 0; index-- { + call := c.calls[index] + switch call.tool { + case "create_folder": + if value, ok := call.args["name"].(string); ok { + name = value + } + index = -1 + case "rename_document": + if value, ok := call.args["newName"].(string); ok { + name = value + } + index = -1 + } + } + encoded, _ := json.Marshal(map[string]any{"success": true, "result": map[string]any{"fileId": "node-1", "name": name}}) + return string(encoded) + case "get_cover", "get_node_stats": + return `{"success":true,"result":{"nodeId":"node-1"}}` + case "get_file_publish_status": + return `{"success":true,"result":{"fileId":"node-1","published":false}}` + case "create_folder", "create_shortcut": + return `{"success":true,"fileId":"node-1"}` + case "delete_document", "mark_star", "unmark_star", "restore_recycle_item", "rename_document", "revert_file_version": + return `{"success":true,"fileId":"node-1"}` + case "set_file_publish": + return `{"success":true}` + case "download_file", "download_file_version": + return `{"success":true,"result":{"downloadUrl":"http://invalid.test/fixture.bin","fileName":"fixture.bin"}}` case "get_document_content": for index := len(c.calls) - 2; index >= 0; index-- { call := c.calls[index] diff --git a/internal/app/param_alias_payload_equivalence_test.go b/internal/app/param_alias_payload_equivalence_test.go index 494a8b6c..064bc154 100644 --- a/internal/app/param_alias_payload_equivalence_test.go +++ b/internal/app/param_alias_payload_equivalence_test.go @@ -124,6 +124,7 @@ var paramAliasCompleteCommands = map[string][]string{ "doc +version-revert": {"doc", "+version-revert", "--node", "node-1", "--version", "3", "--yes"}, "doc +version-save": {"doc", "+version-save", "--node", "node-1", "--yes"}, "doc +update": {"doc", "+update", "--node", "node-1", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "1", "--yes"}, + "doc +export": {"doc", "+export", "--node", "node-1", "--export-format", "docx", "--output", "exports/fixture.docx"}, "doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"}, "doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"}, "doc comment create": {"doc", "comment", "create", "--node", "node-1", "--content", "fixture comment", "--yes"}, @@ -131,9 +132,43 @@ var paramAliasCompleteCommands = map[string][]string{ "doc comment delete": {"doc", "comment", "delete", "--node", "node-1", "--comment-key", "comment-1", "--yes"}, "doc comment reply": {"doc", "comment", "reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture reply", "--mentioned-open-conversation-id", "cid-1,cid-2", "--yes"}, "doc comment update": {"doc", "comment", "update", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture update", "--yes"}, + "doc create": {"doc", "create", "--name", "Fixture Document", "--workspace", "workspace-1"}, "doc version revert": {"doc", "version", "revert", "--node", "node-1", "--version", "3", "--yes"}, + "drive +cover": {"drive", "+cover", "--node", "node-1"}, + "drive +create-folder": {"drive", "+create-folder", "--name", "Fixture Folder", "--space-id", "space-1", "--folder", "folder-1"}, + "drive +create-shortcut": {"drive", "+create-shortcut", "--node", "node-1", "--folder", "folder-1", "--workspace", "workspace-1"}, + "drive +delete": {"drive", "+delete", "--node", "node-1", "--yes"}, + "drive +download": {"drive", "+download", "--node", "node-1", "--space-id", "space-1", "--output", "downloads/fixture.bin"}, + "drive +info": {"drive", "+info", "--node", "node-1", "--space-id", "space-1"}, + "drive +inspect": {"drive", "+inspect", "--node", "node-1", "--space-id", "space-1", "--include-stats"}, + "drive +list": {"drive", "+list", "--space-id", "space-1", "--folder", "folder-1", "--limit", "7", "--cursor", "cursor-1", "--order-by", "name", "--order", "asc"}, + "drive +publish-get": {"drive", "+publish-get", "--node", "node-1"}, + "drive +publish-unset": {"drive", "+publish-unset", "--node", "node-1", "--yes"}, + "drive +recycle-list": {"drive", "+recycle-list", "--space-id", "space-1", "--limit", "7", "--cursor", "cursor-1"}, + "drive +recycle-restore": {"drive", "+recycle-restore", "--id", "recycle-1", "--yes"}, + "drive +rename": {"drive", "+rename", "--node", "node-1", "--name", "Fixture Renamed", "--yes"}, + "drive +search": {"drive", "+search", "--query", "fixture"}, + "drive +star-add": {"drive", "+star-add", "--node", "node-1"}, + "drive +star-list": {"drive", "+star-list", "--limit", "7", "--cursor", "cursor-1"}, + "drive +star-remove": {"drive", "+star-remove", "--node", "node-1"}, + "drive +stats": {"drive", "+stats", "--node", "node-1"}, + "drive +upload": {"drive", "+upload", "--file", "param_alias_payload_equivalence_test.go", "--file-name", "fixture.txt", "--mime-type", "text/plain", "--space-id", "space-1", "--node", "node-1", "--yes"}, + "drive +version-download": {"drive", "+version-download", "--node", "node-1", "--version", "3", "--output", "downloads/fixture-v3.bin"}, + "drive +version-get": {"drive", "+version-get", "--node", "node-1", "--version", "3"}, + "drive +version-history": {"drive", "+version-history", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"}, + "drive +version-revert": {"drive", "+version-revert", "--node", "node-1", "--version", "3", "--yes"}, + "drive commit": {"drive", "commit", "--file-name", "fixture.txt", "--file-size", "7", "--upload-id", "upload-1", "--space-id", "space-1"}, + "drive copy": {"drive", "copy", "--node", "node-1", "--folder", "folder-1"}, + "drive download": {"drive", "download", "--node", "node-1", "--output", "downloads/fixture.bin", "--version", "3", "--space-id", "space-1"}, "drive info": {"drive", "info", "--node", "node-1", "--space-id", "space-1"}, "drive list": {"drive", "list", "--folder", "folder-1", "--limit", "7"}, + "drive mkdir": {"drive", "mkdir", "--name", "Fixture Folder", "--space-id", "space-1"}, + "drive permission add": {"drive", "permission", "add", "--node", "node-1", "--users", "user-1,user-2", "--role", "READER"}, + "drive recycle list": {"drive", "recycle", "list", "--space-id", "space-1", "--limit", "7"}, + "drive recycle restore": {"drive", "recycle", "restore", "--id", "recycle-1"}, + "drive search": {"drive", "search", "--query", "fixture", "--created-from", "1", "--created-to", "2", "--modified-from", "3", "--modified-to", "4", "--creator-uids", "user-1,user-2"}, + "drive upload": {"drive", "upload", "--file", "../../go.mod", "--space-id", "space-1"}, + "drive upload-info": {"drive", "upload-info", "--file-name", "fixture.txt", "--file-size", "7", "--space-id", "space-1"}, "mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7"}, "mail folder update": {"mail", "folder", "update", "--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder", "--yes"}, "mail message search": {"mail", "message", "search", "--email", "fixture@example.com", "--query", "subject:fixture"}, @@ -156,6 +191,19 @@ var paramAliasCompleteCommandVariants = map[string]map[string][]string{ "doc block insert": { "parent-block": {"doc", "block", "insert", "--node", "node-1", "--parent-block", "parent-block-1", "--index", "0", "--text", "fixture paragraph", "--yes"}, }, + "doc +inspect": { + "include-permissions": {"doc", "+inspect", "--node", "node-1", "--include-permissions"}, + }, + "doc +search": { + "created-from": {"doc", "+search", "--query", "fixture", "--created-from", "1"}, + "creator-uids": {"doc", "+search", "--query", "fixture", "--creator-uids", "user-1,user-2"}, + }, + "drive list": { + "workspace": {"drive", "list", "--workspace", "workspace-1", "--limit", "7"}, + "order-by": {"drive", "list", "--folder", "folder-1", "--order-by", "name", "--limit", "7"}, + "space-id": {"drive", "list", "--space-id", "space-1", "--limit", "7"}, + "order": {"drive", "list", "--folder", "folder-1", "--order", "asc", "--limit", "7"}, + }, "chat message list": { "user": {"chat", "message", "list", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"}, }, @@ -241,12 +289,87 @@ var paramAliasNewIMCases = []struct { {command: "chat +messages-set-pin", emitted: "conversation-id", canonical: "open-conversation-id"}, } +// paramAliasNewDriveCases is the exact executable-alias set introduced by the +// reviewed Drive expansion. Guard fixtures are covered separately by the +// exhaustive runtime-contract tests; every entry here must preserve the final +// transport payload of its canonical spelling. +var paramAliasNewDriveCases = []struct { + command string + emitted string + canonical string +}{ + {command: "drive +cover", emitted: "dentry-uuid", canonical: "node"}, + {command: "drive +create-folder", emitted: "folder-name", canonical: "name"}, + {command: "drive +create-folder", emitted: "storage-space-id", canonical: "space-id"}, + {command: "drive +create-shortcut", emitted: "source-file-id", canonical: "node"}, + {command: "drive +create-shortcut", emitted: "target-folder-id", canonical: "folder"}, + {command: "drive +create-shortcut", emitted: "target-workspace-id", canonical: "workspace"}, + {command: "drive +delete", emitted: "file-id", canonical: "node"}, + {command: "drive +download", emitted: "dentry-uuid", canonical: "node"}, + {command: "drive +download", emitted: "destination-path", canonical: "output"}, + {command: "drive +inspect", emitted: "include-statistics", canonical: "include-stats"}, + {command: "drive +list", emitted: "folder-id", canonical: "folder"}, + {command: "drive +list", emitted: "page-size", canonical: "limit"}, + {command: "drive +list", emitted: "next-token", canonical: "cursor"}, + {command: "drive +list", emitted: "sort-direction", canonical: "order"}, + {command: "drive +list", emitted: "sort-by", canonical: "order-by"}, + {command: "drive +publish-get", emitted: "dentry-uuid", canonical: "node"}, + {command: "drive +publish-unset", emitted: "file-id", canonical: "node"}, + {command: "drive +recycle-list", emitted: "storage-space-id", canonical: "space-id"}, + {command: "drive +recycle-list", emitted: "page-token", canonical: "cursor"}, + {command: "drive +recycle-restore", emitted: "recycle-item-id", canonical: "id"}, + {command: "drive +rename", emitted: "file-id", canonical: "node"}, + {command: "drive +rename", emitted: "new-name", canonical: "name"}, + {command: "drive +star-add", emitted: "dentry-uuid", canonical: "node"}, + {command: "drive +star-list", emitted: "max-results", canonical: "limit"}, + {command: "drive +star-remove", emitted: "file-id", canonical: "node"}, + {command: "drive +stats", emitted: "dentry-uuid", canonical: "node"}, + {command: "drive +upload", emitted: "source-file", canonical: "file"}, + {command: "drive +upload", emitted: "name", canonical: "file-name"}, + {command: "drive +upload", emitted: "overwrite-node-id", canonical: "node"}, + {command: "drive +version-download", emitted: "version-number", canonical: "version"}, + {command: "drive +version-download", emitted: "save-path", canonical: "output"}, + {command: "drive +version-get", emitted: "version-no", canonical: "version"}, + {command: "drive +version-history", emitted: "next-cursor", canonical: "cursor"}, + {command: "drive +version-history", emitted: "page-size", canonical: "limit"}, + {command: "drive +version-revert", emitted: "version-number", canonical: "version"}, + {command: "drive +cover", emitted: "node-id", canonical: "node"}, + {command: "drive +create-shortcut", emitted: "node-id", canonical: "node"}, + {command: "drive +delete", emitted: "node-id", canonical: "node"}, + {command: "drive +download", emitted: "node-id", canonical: "node"}, + {command: "drive +inspect", emitted: "node-id", canonical: "node"}, + {command: "drive +publish-get", emitted: "node-id", canonical: "node"}, + {command: "drive +publish-unset", emitted: "node-id", canonical: "node"}, + {command: "drive +rename", emitted: "node-id", canonical: "node"}, + {command: "drive +star-add", emitted: "node-id", canonical: "node"}, + {command: "drive +star-remove", emitted: "node-id", canonical: "node"}, + {command: "drive +stats", emitted: "node-id", canonical: "node"}, + {command: "drive +upload", emitted: "node-id", canonical: "node"}, + {command: "drive +version-download", emitted: "node-id", canonical: "node"}, + {command: "drive +version-get", emitted: "node-id", canonical: "node"}, + {command: "drive +version-history", emitted: "node-id", canonical: "node"}, + {command: "drive +version-revert", emitted: "node-id", canonical: "node"}, + {command: "drive +cover", emitted: "url", canonical: "node"}, + {command: "drive +create-shortcut", emitted: "document-id", canonical: "node"}, + {command: "drive +delete", emitted: "folder-id", canonical: "node"}, + {command: "drive +inspect", emitted: "document-id", canonical: "node"}, + {command: "drive +inspect", emitted: "folder-id", canonical: "node"}, + {command: "drive +publish-get", emitted: "url", canonical: "node"}, + {command: "drive +publish-unset", emitted: "document-url", canonical: "node"}, + {command: "drive +rename", emitted: "document-id", canonical: "node"}, + {command: "drive +rename", emitted: "folder-id", canonical: "node"}, + {command: "drive +star-add", emitted: "url", canonical: "node"}, + {command: "drive +star-remove", emitted: "doc-id", canonical: "node"}, + {command: "drive +stats", emitted: "document-url", canonical: "node"}, + {command: "drive +upload", emitted: "file-id", canonical: "node"}, +} + // paramAliasRepresentativePayloadCases keeps final transport coverage across // old concept aliases, command overrides, native compatibility flags, read and // write commands, and different products. Every reviewed alias is still // checked through the embedded PreParse delivery path and against a complete -// business-valid command template. The separate IM gate below continues to -// execute every alias introduced by the current IM optimization. +// business-valid command template. The dedicated product gates below continue +// to execute every alias introduced by the reviewed IM and Drive expansions. // // Keeping the older 100+ aliases at the contract layer avoids rebuilding and // executing the complete 800+ command Root twice per spelling under -race. @@ -421,14 +544,79 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl } } -// Resource download deliberately continues from the transport call into a -// local HTTPS download. The generic capture caller returns an empty object, so -// this command's stable post-transport validation error is the expected test -// boundary; canonical and alias calls must still produce the same request and -// the same error. +func TestCrossPlatformCoverageNewDriveParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) { + activeAliases := 0 + for _, test := range paramAliasNewDriveCases { + test := test + t.Run(test.command+"/"+test.emitted, func(t *testing.T) { + complete, ok := paramAliasCompleteCommand(test.command, test.canonical) + if !ok { + t.Fatal("reviewed Drive alias has no complete-command E2E template") + } + canonicalArgs := append([]string(nil), complete...) + aliasArgs, replacements := replaceLongFlag(canonicalArgs, test.canonical, test.emitted) + if replacements != 1 { + t.Fatalf("complete command must contain canonical --%s exactly once; replacements=%d args=%v", test.canonical, replacements, canonicalArgs) + } + + canonicalCaller := ¶mAliasCaptureCaller{} + _, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...) + if canonicalErr != nil && !paramAliasExpectedCaptureBoundaryError(test.command, canonicalErr) { + t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls) + } + if len(canonicalCaller.calls) == 0 { + t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs) + } + + entry, exists := cli.LookupParamAlias(test.command) + target, active := entry.ResolveAlias(test.emitted) + if !exists || !active { + return + } + if target != test.canonical { + t.Fatalf("active reviewed Drive alias --%s resolves to --%s, want --%s", test.emitted, target, test.canonical) + } + activeAliases++ + + aliasCaller := ¶mAliasCaptureCaller{} + ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...) + if aliasErr != nil && !paramAliasExpectedCaptureBoundaryError(test.command, aliasErr) { + t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls) + } + if ctx == nil { + t.Fatal("complete alias command skipped PreParse") + } + if (canonicalErr == nil) != (aliasErr == nil) || (canonicalErr != nil && canonicalErr.Error() != aliasErr.Error()) { + t.Fatalf("canonical and alias completion errors differ: canonical=%v alias=%v", canonicalErr, aliasErr) + } + if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) { + t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls) + } + }) + } + if activeAliases != len(paramAliasNewDriveCases) { + t.Fatalf("new Drive aliases active in embedded table = %d, want %d", activeAliases, len(paramAliasNewDriveCases)) + } +} + +// Some artifact commands deliberately continue past the final captured +// transport call into local download/upload handling. Deterministic invalid +// resource responses form their expected post-transport test boundary; +// canonical and alias calls must still produce the same request and error. func paramAliasExpectedCaptureBoundaryError(command string, err error) bool { - return command == "chat +messages-resource-download" && err != nil && - strings.Contains(err.Error(), "资源下载接口未返回合法的 HTTPS 下载地址") + if err == nil { + return false + } + switch command { + case "chat +messages-resource-download": + return strings.Contains(err.Error(), "资源下载接口未返回合法的 HTTPS 下载地址") + case "drive +download", "drive +version-download": + return strings.Contains(err.Error(), "下载地址必须是受信任域名上的 HTTPS URL") + case "drive +upload": + return strings.Contains(err.Error(), "incomplete drive upload credentials") + default: + return false + } } // +chat-messages supplies the current wall-clock time when callers omit diff --git a/internal/cli/param_aliases_generated.go b/internal/cli/param_aliases_generated.go index d5229552..58ffe989 100644 --- a/internal/cli/param_aliases_generated.go +++ b/internal/cli/param_aliases_generated.go @@ -1883,51 +1883,62 @@ var generatedParamAliases = []ParamAliasEntry{ { CLIPath: "doc +access-change", Aliases: map[string]string{ - "doc": "node", - "doc-id": "node", - "document-id": "node", - "file-id": "node", - "node-id": "node", - "space": "workspace", - "space-id": "workspace", - "url": "node", - "workspace-id": "workspace", + "access-role": "role", + "dentry-uuid": "node", + "doc": "node", + "doc-id": "node", + "document-id": "node", + "file-id": "node", + "knowledge-base-id": "workspace", + "member-role": "role", + "node-id": "node", + "permission-role": "role", + "url": "node", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "id", "job-id", "parent-id", "revision", "task-id", "template-id", "version"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "dingdrive-space-id", "drive-space-id", "filter-role", "folder", "folder-id", "id", "job-id", "name", "parent-id", "permission", "public-permission", "reserve-role", "revision", "space", "space-id", "storage-space-id", "task-id", "template-id", "version"}, }, { CLIPath: "doc +access-grant", Aliases: map[string]string{ - "doc": "node", - "doc-id": "node", - "document-id": "node", - "file-id": "node", - "node-id": "node", - "space": "workspace", - "space-id": "workspace", - "url": "node", - "workspace-id": "workspace", + "access-role": "role", + "dentry-uuid": "node", + "doc": "node", + "doc-id": "node", + "document-id": "node", + "file-id": "node", + "knowledge-base-id": "workspace", + "member-role": "role", + "node-id": "node", + "permission-role": "role", + "url": "node", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "id", "job-id", "parent-id", "revision", "task-id", "template-id", "version"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "dingdrive-space-id", "drive-space-id", "filter-role", "folder", "folder-id", "id", "job-id", "name", "parent-id", "permission", "public-permission", "reserve-role", "revision", "space", "space-id", "storage-space-id", "target-user-id", "target-user-ids", "task-id", "template-id", "user-id", "user-ids", "version"}, + Ambiguous: []string{"target-user", "user", "users"}, }, { CLIPath: "doc +access-revoke", Aliases: map[string]string{ - "doc": "node", - "doc-id": "node", - "document-id": "node", - "file-id": "node", - "node-id": "node", - "space": "workspace", - "space-id": "workspace", - "url": "node", - "workspace-id": "workspace", + "dentry-uuid": "node", + "doc": "node", + "doc-id": "node", + "document-id": "node", + "file-id": "node", + "knowledge-base-id": "workspace", + "node-id": "node", + "url": "node", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "id", "job-id", "parent-id", "revision", "task-id", "template-id", "version"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "id", "job-id", "name", "parent-id", "revision", "role", "space", "space-id", "storage-space-id", "task-id", "template-id", "version"}, }, { CLIPath: "doc +background-delete", Aliases: map[string]string{ + "dentry-uuid": "node", "doc": "node", "doc-id": "node", "document-id": "node", @@ -1935,11 +1946,12 @@ var generatedParamAliases = []ParamAliasEntry{ "node-id": "node", "url": "node", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "id", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc +background-update", Aliases: map[string]string{ + "dentry-uuid": "node", "doc": "node", "doc-id": "node", "document-id": "node", @@ -1947,12 +1959,13 @@ var generatedParamAliases = []ParamAliasEntry{ "node-id": "node", "url": "node", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "id", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc +checkpoint-update", Aliases: map[string]string{ "body": "content", + "dentry-uuid": "node", "doc": "node", "doc-id": "node", "document-id": "node", @@ -1961,12 +1974,13 @@ var generatedParamAliases = []ParamAliasEntry{ "text": "content", "url": "node", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "id", "job-id", "name", "parent-id", "revision", "task-id", "template-id", "title", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "title", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc +comment-create", Aliases: map[string]string{ "body": "content", + "dentry-uuid": "node", "doc": "node", "doc-id": "node", "document-id": "node", @@ -1975,12 +1989,13 @@ var generatedParamAliases = []ParamAliasEntry{ "text": "content", "url": "node", }, - Blocked: []string{"chat-id", "chat-ids", "comment-id", "comment-key", "conversation-id", "conversation-ids", "folder", "folder-id", "group-id", "group-ids", "id", "job-id", "mentioned-open-conversation-id", "mentioned-open-conversation-ids", "name", "open-conversation-id", "open-conversation-ids", "parent-id", "revision", "task-id", "template-id", "title", "version", "workspace", "workspace-id"}, + Blocked: []string{"chat-id", "chat-ids", "comment-id", "comment-key", "conversation-id", "conversation-ids", "dentry-id", "folder", "folder-id", "group-id", "group-ids", "id", "job-id", "mentioned-open-conversation-id", "mentioned-open-conversation-ids", "name", "open-conversation-id", "open-conversation-ids", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "title", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc +comment-delete", Aliases: map[string]string{ "comment-id": "comment-key", + "dentry-uuid": "node", "doc": "node", "doc-id": "node", "document-id": "node", @@ -1988,11 +2003,12 @@ var generatedParamAliases = []ParamAliasEntry{ "node-id": "node", "url": "node", }, - Blocked: []string{"block-id", "folder", "folder-id", "id", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "dentry-id", "folder", "folder-id", "id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc +comment-list", Aliases: map[string]string{ + "dentry-uuid": "node", "doc": "node", "doc-id": "node", "document-id": "node", @@ -2010,13 +2026,14 @@ var generatedParamAliases = []ParamAliasEntry{ "top": "limit", "url": "node", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "count", "folder", "folder-id", "id", "job-id", "offset", "page", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "count", "dentry-id", "folder", "folder-id", "id", "job-id", "name", "offset", "page", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc +comment-reply", Aliases: map[string]string{ "body": "content", "comment-id": "comment-key", + "dentry-uuid": "node", "doc": "node", "doc-id": "node", "document-id": "node", @@ -2025,13 +2042,14 @@ var generatedParamAliases = []ParamAliasEntry{ "text": "content", "url": "node", }, - Blocked: []string{"block-id", "chat-id", "chat-ids", "conversation-id", "conversation-ids", "folder", "folder-id", "group-id", "group-ids", "id", "job-id", "mentioned-open-conversation-id", "mentioned-open-conversation-ids", "name", "open-conversation-id", "open-conversation-ids", "parent-id", "revision", "task-id", "template-id", "title", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "chat-id", "chat-ids", "conversation-id", "conversation-ids", "dentry-id", "folder", "folder-id", "group-id", "group-ids", "id", "job-id", "mentioned-open-conversation-id", "mentioned-open-conversation-ids", "name", "open-conversation-id", "open-conversation-ids", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "title", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc +comment-update", Aliases: map[string]string{ "body": "content", "comment-id": "comment-key", + "dentry-uuid": "node", "doc": "node", "doc-id": "node", "document-id": "node", @@ -2040,65 +2058,67 @@ var generatedParamAliases = []ParamAliasEntry{ "text": "content", "url": "node", }, - Blocked: []string{"block-id", "folder", "folder-id", "id", "job-id", "name", "parent-id", "revision", "task-id", "template-id", "title", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "dentry-id", "folder", "folder-id", "id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "title", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc +copy", Aliases: map[string]string{ - "doc": "node", - "doc-id": "node", - "document-id": "node", - "file-id": "node", - "folder-id": "folder", - "node-id": "node", - "parent-folder": "folder", - "parent-folder-id": "folder", - "parent-node-id": "folder", - "space": "workspace", - "space-id": "workspace", - "url": "node", - "workspace-id": "workspace", + "dentry-uuid": "node", + "doc": "node", + "doc-id": "node", + "document-id": "node", + "file-id": "node", + "folder-id": "folder", + "knowledge-base-id": "workspace", + "node-id": "node", + "parent-folder": "folder", + "parent-folder-id": "folder", + "parent-node-id": "folder", + "url": "node", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "id", "job-id", "parent-id", "revision", "task-id", "template-id", "version"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "dingdrive-space-id", "drive-space-id", "id", "job-id", "name", "parent-id", "revision", "role", "space", "space-id", "storage-space-id", "task-id", "template-id", "version"}, }, { CLIPath: "doc +create", Aliases: map[string]string{ - "body": "content", - "content-format": "doc-format", - "folder-id": "folder", - "parent-folder": "folder", - "parent-folder-id": "folder", - "parent-node-id": "folder", - "space": "workspace", - "space-id": "workspace", - "text": "content", - "workspace-id": "workspace", + "body": "content", + "content-format": "doc-format", + "folder-id": "folder", + "knowledge-base-id": "workspace", + "parent-folder": "folder", + "parent-folder-id": "folder", + "parent-node-id": "folder", + "text": "content", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", }, - Blocked: []string{"content-file", "export-format", "mime-type", "node", "parent-id", "title"}, + Blocked: []string{"content-file", "dentry-id", "dingdrive-space-id", "drive-space-id", "export-format", "mime-type", "node", "node-id", "parent-id", "space", "space-id", "storage-space-id", "title"}, }, { CLIPath: "doc +create-from-template", Aliases: map[string]string{ - "folder-id": "folder", - "keyword": "query", - "keywords": "query", - "parent-folder": "folder", - "parent-folder-id": "folder", - "parent-node-id": "folder", - "q": "query", - "search-word": "query", - "space": "workspace", - "space-id": "workspace", - "workspace-id": "workspace", + "folder-id": "folder", + "keyword": "query", + "keywords": "query", + "knowledge-base-id": "workspace", + "parent-folder": "folder", + "parent-folder-id": "folder", + "parent-node-id": "folder", + "q": "query", + "search-word": "query", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", }, - Blocked: []string{"node", "parent-id", "subject", "text", "title"}, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "node", "node-id", "parent-id", "space", "space-id", "storage-space-id", "subject", "text", "title"}, }, { CLIPath: "doc +doc-append", Aliases: map[string]string{ "body": "text", "content": "text", + "dentry-uuid": "doc", "doc-id": "doc", "document-id": "doc", "file-id": "doc", @@ -2106,30 +2126,38 @@ var generatedParamAliases = []ParamAliasEntry{ "node-id": "doc", "url": "doc", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "id", "job-id", "name", "parent-id", "revision", "task-id", "template-id", "title", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "title", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc +export", Aliases: map[string]string{ - "doc": "node", - "doc-id": "node", - "document-id": "node", - "file-id": "node", - "node-id": "node", - "url": "node", + "dentry-uuid": "node", + "destination-path": "output", + "doc": "node", + "doc-id": "node", + "document-id": "node", + "file-id": "node", + "node-id": "node", + "output-path": "output", + "save-path": "output", + "url": "node", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "id", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "content-file", "dentry-id", "file", "file-path", "folder", "folder-id", "id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "wait", "workspace", "workspace-id"}, }, { CLIPath: "doc +export-get", Aliases: map[string]string{ - "export-job-id": "job-id", + "destination-path": "output", + "export-job-id": "job-id", + "output-path": "output", + "save-path": "output", }, - Blocked: []string{"doc-id", "document-id", "file-id", "node", "node-id", "task-id", "url"}, + Blocked: []string{"content-file", "doc-id", "document-id", "file", "file-id", "file-path", "folder", "folder-id", "node", "node-id", "task-id", "url"}, }, { CLIPath: "doc +export-submit", Aliases: map[string]string{ + "dentry-uuid": "node", "doc": "node", "doc-id": "node", "document-id": "node", @@ -2137,11 +2165,12 @@ var generatedParamAliases = []ParamAliasEntry{ "node-id": "node", "url": "node", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "id", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc +fetch", Aliases: map[string]string{ + "dentry-uuid": "node", "doc": "node", "doc-id": "node", "document-id": "node", @@ -2151,7 +2180,7 @@ var generatedParamAliases = []ParamAliasEntry{ "start-block": "start-block-id", "url": "node", }, - Blocked: []string{"comment-id", "comment-key", "folder", "folder-id", "id", "job-id", "parent-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"comment-id", "comment-key", "content-format", "dentry-id", "doc-format", "folder", "folder-id", "id", "job-id", "name", "parent-id", "range", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, Ambiguous: []string{"block-id"}, }, { @@ -2174,20 +2203,24 @@ var generatedParamAliases = []ParamAliasEntry{ { CLIPath: "doc +grant-and-share", Aliases: map[string]string{ - "doc": "node", - "doc-id": "node", - "document-id": "node", - "file-id": "node", - "node-id": "node", - "space": "workspace", - "space-id": "workspace", - "workspace-id": "workspace", + "access-role": "role", + "doc": "node", + "doc-id": "node", + "document-id": "node", + "file-id": "node", + "knowledge-base-id": "workspace", + "member-role": "role", + "node-id": "node", + "permission-role": "role", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", }, - Blocked: []string{"folder"}, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "filter-role", "folder", "folder-id", "permission", "public-permission", "reserve-role", "space", "space-id", "storage-space-id"}, }, { CLIPath: "doc +history-list", Aliases: map[string]string{ + "dentry-uuid": "node", "doc": "node", "doc-id": "node", "document-id": "node", @@ -2195,11 +2228,12 @@ var generatedParamAliases = []ParamAliasEntry{ "node-id": "node", "url": "node", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "id", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc +history-revert", Aliases: map[string]string{ + "dentry-uuid": "node", "doc": "node", "doc-id": "node", "document-id": "node", @@ -2209,11 +2243,12 @@ var generatedParamAliases = []ParamAliasEntry{ "version-no": "version", "version-number": "version", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "id", "job-id", "parent-id", "revision", "task-id", "template-id", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "workspace", "workspace-id"}, }, { CLIPath: "doc +history-save", Aliases: map[string]string{ + "dentry-uuid": "node", "doc": "node", "doc-id": "node", "document-id": "node", @@ -2221,66 +2256,74 @@ var generatedParamAliases = []ParamAliasEntry{ "node-id": "node", "url": "node", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "id", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc +import", Aliases: map[string]string{ - "folder-id": "folder", - "parent-folder": "folder", - "parent-folder-id": "folder", - "parent-node-id": "folder", - "space": "workspace", - "space-id": "workspace", - "workspace-id": "workspace", + "folder-id": "folder", + "knowledge-base-id": "workspace", + "parent-folder": "folder", + "parent-folder-id": "folder", + "parent-node-id": "folder", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", }, - Blocked: []string{"node", "parent-id"}, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "node", "node-id", "parent-id", "space", "space-id", "storage-space-id"}, }, { CLIPath: "doc +inspect", Aliases: map[string]string{ + "dentry-uuid": "node", "doc": "node", "doc-id": "node", "document-id": "node", "file-id": "node", + "include-access": "include-permissions", + "include-member": "include-permissions", + "include-members": "include-permissions", "include-versions": "include-history", "node-id": "node", "url": "node", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "id", "include", "include-info", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "id", "include", "include-blocks", "include-content", "include-info", "include-meta", "include-metadata", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc +list", Aliases: map[string]string{ - "folder-id": "folder", - "max-result": "limit", - "max-results": "limit", - "next-cursor": "cursor", - "next-page-token": "cursor", - "next-token": "cursor", - "page-size": "limit", - "page-token": "cursor", - "parent-folder": "folder", - "parent-folder-id": "folder", - "parent-node-id": "folder", - "per-page": "limit", - "size": "limit", - "space": "workspace", - "space-id": "workspace", - "take": "limit", - "top": "limit", - "workspace-id": "workspace", + "folder-id": "folder", + "knowledge-base-id": "workspace", + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "page-token": "cursor", + "parent-folder": "folder", + "parent-folder-id": "folder", + "parent-node-id": "folder", + "per-page": "limit", + "size": "limit", + "take": "limit", + "top": "limit", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", }, - Blocked: []string{"count", "node", "offset", "page", "parent-id"}, + Blocked: []string{"count", "dentry-id", "dingdrive-space-id", "drive-space-id", "node", "node-id", "offset", "page", "parent-id", "space", "space-id", "storage-space-id"}, }, { CLIPath: "doc +media-download", Aliases: map[string]string{ - "doc": "node", - "doc-id": "node", - "document-id": "node", - "node-id": "node", + "destination-path": "output", + "doc": "node", + "doc-id": "node", + "document-id": "node", + "node-id": "node", + "output-path": "output", + "save-path": "output", }, + Blocked: []string{"content-file", "file", "file-path", "folder", "folder-id"}, Ambiguous: []string{"file-id", "url"}, }, { @@ -2291,6 +2334,7 @@ var generatedParamAliases = []ParamAliasEntry{ "document-id": "node", "node-id": "node", }, + Blocked: []string{"after-block-id", "before-block-id"}, Ambiguous: []string{"file-id", "url"}, }, { @@ -2316,21 +2360,22 @@ var generatedParamAliases = []ParamAliasEntry{ { CLIPath: "doc +move", Aliases: map[string]string{ - "doc": "node", - "doc-id": "node", - "document-id": "node", - "file-id": "node", - "folder-id": "folder", - "node-id": "node", - "parent-folder": "folder", - "parent-folder-id": "folder", - "parent-node-id": "folder", - "space": "workspace", - "space-id": "workspace", - "url": "node", - "workspace-id": "workspace", + "dentry-uuid": "node", + "doc": "node", + "doc-id": "node", + "document-id": "node", + "file-id": "node", + "folder-id": "folder", + "knowledge-base-id": "workspace", + "node-id": "node", + "parent-folder": "folder", + "parent-folder-id": "folder", + "parent-node-id": "folder", + "url": "node", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "id", "job-id", "parent-id", "revision", "task-id", "template-id", "version"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "dingdrive-space-id", "drive-space-id", "id", "job-id", "name", "parent-id", "revision", "role", "space", "space-id", "storage-space-id", "task-id", "template-id", "version"}, }, { CLIPath: "doc +resource-delete", @@ -2345,11 +2390,15 @@ var generatedParamAliases = []ParamAliasEntry{ { CLIPath: "doc +resource-download", Aliases: map[string]string{ - "doc": "node", - "doc-id": "node", - "document-id": "node", - "node-id": "node", + "destination-path": "output", + "doc": "node", + "doc-id": "node", + "document-id": "node", + "node-id": "node", + "output-path": "output", + "save-path": "output", }, + Blocked: []string{"content-file", "file", "file-path", "folder", "folder-id"}, Ambiguous: []string{"file-id", "url"}, }, { @@ -2365,6 +2414,7 @@ var generatedParamAliases = []ParamAliasEntry{ { CLIPath: "doc +review", Aliases: map[string]string{ + "dentry-uuid": "node", "doc": "node", "doc-id": "node", "document-id": "node", @@ -2372,28 +2422,37 @@ var generatedParamAliases = []ParamAliasEntry{ "node-id": "node", "url": "node", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "id", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc +search", Aliases: map[string]string{ - "keyword": "query", - "keywords": "query", - "max-result": "limit", - "max-results": "limit", - "next-cursor": "cursor", - "next-page-token": "cursor", - "next-token": "cursor", - "page-size": "limit", - "page-token": "cursor", - "per-page": "limit", - "q": "query", - "search-word": "query", - "size": "limit", - "take": "limit", - "top": "limit", + "create-time-from": "created-from", + "create-time-to": "created-to", + "created-after": "created-from", + "created-before": "created-to", + "created-by-user-ids": "creator-uids", + "created-time-end": "created-to", + "created-time-start": "created-from", + "creator-ids": "creator-uids", + "creator-user-ids": "creator-uids", + "keyword": "query", + "keywords": "query", + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "page-token": "cursor", + "per-page": "limit", + "q": "query", + "search-word": "query", + "size": "limit", + "take": "limit", + "top": "limit", }, - Blocked: []string{"count", "name", "offset", "page", "subject", "text", "title"}, + Blocked: []string{"count", "end", "from", "modified-from", "modified-to", "modifier-uids", "name", "offset", "owner-id", "page", "start", "subject", "text", "title", "to", "user", "user-id", "user-ids", "users"}, }, { CLIPath: "doc +share", @@ -2453,11 +2512,13 @@ var generatedParamAliases = []ParamAliasEntry{ "revision": "expected-revision", "url": "node", }, - Blocked: []string{"content-file", "export-format", "mime-type", "version", "version-no", "version-number"}, + Blocked: []string{"content-file", "export-format", "mime-type", "version", "version-no", "version-number"}, + Ambiguous: []string{"element"}, }, { CLIPath: "doc +version-list", Aliases: map[string]string{ + "dentry-uuid": "node", "doc": "node", "doc-id": "node", "document-id": "node", @@ -2465,12 +2526,13 @@ var generatedParamAliases = []ParamAliasEntry{ "node-id": "node", "url": "node", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "id", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, Ambiguous: []string{"max-result", "max-results", "next-cursor", "next-page-token", "next-token", "per-page", "size", "take", "top"}, }, { CLIPath: "doc +version-revert", Aliases: map[string]string{ + "dentry-uuid": "node", "doc": "node", "doc-id": "node", "document-id": "node", @@ -2480,11 +2542,12 @@ var generatedParamAliases = []ParamAliasEntry{ "version-no": "version", "version-number": "version", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "id", "job-id", "parent-id", "revision", "task-id", "template-id", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "workspace", "workspace-id"}, }, { CLIPath: "doc +version-save", Aliases: map[string]string{ + "dentry-uuid": "node", "doc": "node", "doc-id": "node", "document-id": "node", @@ -2492,52 +2555,57 @@ var generatedParamAliases = []ParamAliasEntry{ "node-id": "node", "url": "node", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "id", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "id", "job-id", "message", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "title", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc block delete", Aliases: map[string]string{ + "dentry-uuid": "node", "doc": "node", "document-id": "node", }, - Blocked: []string{"comment-id", "comment-key", "folder", "folder-id", "index", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"comment-id", "comment-key", "dentry-id", "folder", "folder-id", "index", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc block insert", Aliases: map[string]string{ "body": "text", "content": "text", + "dentry-uuid": "node", "doc": "node", "document-id": "node", "parent-block-id": "parent-block", "ref-block-id": "ref-block", "reference-block-id": "ref-block", }, - Blocked: []string{"before-block-id", "comment-id", "comment-key", "folder", "folder-id", "job-id", "name", "parent-id", "revision", "task-id", "template-id", "title", "version", "workspace", "workspace-id"}, + Blocked: []string{"before-block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "title", "version", "workspace", "workspace-id"}, Ambiguous: []string{"block-id"}, }, { CLIPath: "doc block list", Aliases: map[string]string{ + "dentry-uuid": "node", "doc": "node", "document-id": "node", }, - Blocked: []string{"comment-id", "comment-key", "folder", "folder-id", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"comment-id", "comment-key", "dentry-id", "folder", "folder-id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc block update", Aliases: map[string]string{ "body": "text", "content": "text", + "dentry-uuid": "node", "doc": "node", "document-id": "node", }, - Blocked: []string{"comment-id", "comment-key", "folder", "folder-id", "job-id", "name", "parent-id", "revision", "task-id", "template-id", "title", "version", "workspace", "workspace-id"}, + Blocked: []string{"comment-id", "comment-key", "dentry-id", "folder", "folder-id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "title", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc comment create", Aliases: map[string]string{ "body": "content", + "dentry-uuid": "node", "doc": "node", "document-id": "node", "mentioned-open-conversation-ids": "mentioned-open-conversation-id", @@ -2545,30 +2613,33 @@ var generatedParamAliases = []ParamAliasEntry{ "open-conversation-ids": "mentioned-open-conversation-id", "text": "content", }, - Blocked: []string{"block-id", "chat-id", "chat-ids", "comment-id", "comment-key", "conversation-id", "conversation-ids", "folder", "folder-id", "group-id", "group-ids", "job-id", "name", "parent-id", "revision", "task-id", "template-id", "title", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "chat-id", "chat-ids", "comment-id", "comment-key", "conversation-id", "conversation-ids", "dentry-id", "folder", "folder-id", "group-id", "group-ids", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "title", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc comment create-inline", Aliases: map[string]string{ "body": "content", + "dentry-uuid": "node", "doc": "node", "document-id": "node", "text": "content", }, - Blocked: []string{"comment-id", "comment-key", "folder", "folder-id", "job-id", "name", "parent-id", "revision", "task-id", "template-id", "title", "version", "workspace", "workspace-id"}, + Blocked: []string{"comment-id", "comment-key", "dentry-id", "folder", "folder-id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "title", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc comment delete", Aliases: map[string]string{ "comment-id": "comment-key", + "dentry-uuid": "node", "doc": "node", "document-id": "node", }, - Blocked: []string{"block-id", "folder", "folder-id", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "dentry-id", "folder", "folder-id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc comment list", Aliases: map[string]string{ + "dentry-uuid": "node", "doc": "node", "document-id": "node", "max-result": "limit", @@ -2580,13 +2651,14 @@ var generatedParamAliases = []ParamAliasEntry{ "take": "limit", "top": "limit", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "count", "folder", "folder-id", "job-id", "offset", "page", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "count", "dentry-id", "folder", "folder-id", "job-id", "name", "offset", "page", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc comment reply", Aliases: map[string]string{ "body": "content", "comment-id": "comment-key", + "dentry-uuid": "node", "doc": "node", "document-id": "node", "mentioned-open-conversation-ids": "mentioned-open-conversation-id", @@ -2594,13 +2666,14 @@ var generatedParamAliases = []ParamAliasEntry{ "open-conversation-ids": "mentioned-open-conversation-id", "text": "content", }, - Blocked: []string{"block-id", "chat-id", "chat-ids", "conversation-id", "conversation-ids", "folder", "folder-id", "group-id", "group-ids", "job-id", "name", "parent-id", "revision", "task-id", "template-id", "title", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "chat-id", "chat-ids", "conversation-id", "conversation-ids", "dentry-id", "folder", "folder-id", "group-id", "group-ids", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "title", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc comment update", Aliases: map[string]string{ "body": "content", "comment-id": "comment-key", + "dentry-uuid": "node", "doc": "node", "document-id": "node", "mentioned-open-conversation-ids": "mentioned-open-conversation-id", @@ -2608,26 +2681,27 @@ var generatedParamAliases = []ParamAliasEntry{ "open-conversation-ids": "mentioned-open-conversation-id", "text": "content", }, - Blocked: []string{"block-id", "chat-id", "chat-ids", "conversation-id", "conversation-ids", "folder", "folder-id", "group-id", "group-ids", "job-id", "name", "parent-id", "revision", "task-id", "template-id", "title", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "chat-id", "chat-ids", "conversation-id", "conversation-ids", "dentry-id", "folder", "folder-id", "group-id", "group-ids", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "title", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc create", Aliases: map[string]string{ - "body": "content", - "doc-format": "content-format", - "space": "workspace", - "space-id": "workspace", - "text": "content", + "body": "content", + "doc-format": "content-format", + "knowledge-base-id": "workspace", + "text": "content", + "wiki-workspace-id": "workspace", }, - Blocked: []string{"export-format", "mime-type", "node"}, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "export-format", "folder-id", "mime-type", "node", "node-id", "space", "space-id", "storage-space-id"}, }, { CLIPath: "doc export", Aliases: map[string]string{ + "dentry-uuid": "node", "doc": "node", "document-id": "node", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc export get", @@ -2639,18 +2713,18 @@ var generatedParamAliases = []ParamAliasEntry{ { CLIPath: "doc file create", Aliases: map[string]string{ - "space": "workspace", - "space-id": "workspace", + "knowledge-base-id": "workspace", + "wiki-workspace-id": "workspace", }, - Blocked: []string{"node"}, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "folder-id", "node", "node-id", "space", "space-id", "storage-space-id"}, }, { CLIPath: "doc import", Aliases: map[string]string{ - "space": "workspace", - "space-id": "workspace", + "knowledge-base-id": "workspace", + "wiki-workspace-id": "workspace", }, - Blocked: []string{"node"}, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "node", "node-id", "space", "space-id", "storage-space-id"}, }, { CLIPath: "doc import get", @@ -2662,94 +2736,107 @@ var generatedParamAliases = []ParamAliasEntry{ { CLIPath: "doc info", Aliases: map[string]string{ + "dentry-uuid": "node", "doc": "node", "document-id": "node", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc media download", Aliases: map[string]string{ + "dentry-uuid": "node", "doc": "node", "document-id": "node", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc media insert", Aliases: map[string]string{ + "dentry-uuid": "node", "doc": "node", "document-id": "node", "ref-block-id": "ref-block", "reference-block-id": "ref-block", }, - Blocked: []string{"before-block-id", "comment-id", "comment-key", "folder", "folder-id", "job-id", "parent-block", "parent-block-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"before-block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "job-id", "parent-block", "parent-block-id", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, Ambiguous: []string{"block-id"}, }, { CLIPath: "doc media upload", Aliases: map[string]string{ + "dentry-uuid": "node", "doc": "node", "document-id": "node", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "job-id", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc read", Aliases: map[string]string{ - "doc": "node", - "document-id": "node", + "dentry-uuid": "node", + "destination-path": "output", + "doc": "node", + "document-id": "node", + "output-path": "output", + "save-path": "output", }, - Blocked: []string{"before-block-id", "comment-id", "comment-key", "folder", "folder-id", "job-id", "parent-block-id", "parent-id", "ref-block-id", "reference-block-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"before-block-id", "comment-id", "comment-key", "content-file", "dentry-id", "file", "file-path", "folder", "folder-id", "job-id", "name", "parent-block-id", "parent-id", "ref-block-id", "reference-block-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, Ambiguous: []string{"block-id"}, }, { CLIPath: "doc style background clear", Aliases: map[string]string{ + "dentry-uuid": "node", "doc": "node", "document-id": "node", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc style background set", Aliases: map[string]string{ + "dentry-uuid": "node", "doc": "node", "document-id": "node", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc style cover clear", Aliases: map[string]string{ + "dentry-uuid": "node", "doc": "node", "document-id": "node", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc style cover set", Aliases: map[string]string{ + "dentry-uuid": "node", "doc": "node", "document-id": "node", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc style get", Aliases: map[string]string{ + "dentry-uuid": "node", "doc": "node", "document-id": "node", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc template apply", Aliases: map[string]string{ - "space": "workspace", - "space-id": "workspace", + "knowledge-base-id": "workspace", + "wiki-workspace-id": "workspace", }, - Blocked: []string{"node"}, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "folder-id", "node", "node-id", "space", "space-id", "storage-space-id"}, }, { CLIPath: "doc template list", @@ -2785,16 +2872,18 @@ var generatedParamAliases = []ParamAliasEntry{ { CLIPath: "doc update", Aliases: map[string]string{ + "dentry-uuid": "node", "doc": "node", "doc-format": "content-format", "document-id": "node", "expected-revision": "revision", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "export-format", "folder", "folder-id", "job-id", "mime-type", "parent-id", "task-id", "template-id", "version", "version-no", "version-number", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "export-format", "folder", "folder-id", "job-id", "mime-type", "name", "parent-id", "role", "space-id", "stdin", "task-id", "template-id", "version", "version-no", "version-number", "workspace", "workspace-id"}, }, { CLIPath: "doc version list", Aliases: map[string]string{ + "dentry-uuid": "node", "doc": "node", "document-id": "node", "max-result": "limit", @@ -2806,53 +2895,1029 @@ var generatedParamAliases = []ParamAliasEntry{ "take": "limit", "top": "limit", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "count", "folder", "folder-id", "job-id", "offset", "page", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "count", "dentry-id", "folder", "folder-id", "job-id", "name", "offset", "page", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc version revert", Aliases: map[string]string{ + "dentry-uuid": "node", "doc": "node", "document-id": "node", "version-no": "version", "version-number": "version", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "job-id", "parent-id", "revision", "task-id", "template-id", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "workspace", "workspace-id"}, }, { CLIPath: "doc version save", Aliases: map[string]string{ + "dentry-uuid": "node", "doc": "node", "document-id": "node", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, }, { CLIPath: "doc whiteboard insert", Aliases: map[string]string{ + "dentry-uuid": "node", "doc": "node", "document-id": "node", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, + }, + { + CLIPath: "drive +copy", + Aliases: map[string]string{ + "dentry-uuid": "node", + "destination-folder-id": "folder", + "destination-workspace-id": "workspace", + "directory-id": "folder", + "folder-id": "folder", + "knowledge-base-id": "workspace", + "source-file-id": "node", + "source-node-id": "node", + "target-folder-id": "folder", + "target-workspace-id": "workspace", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "destination-node-id", "dingdrive-space-id", "document-url", "drive-space-id", "name", "node-id", "source-folder-id", "space", "space-id", "storage-space-id"}, + Ambiguous: []string{"destination-id", "target-id"}, + }, + { + CLIPath: "drive +cover", + Aliases: map[string]string{ + "dentry-uuid": "node", + "doc-id": "node", + "document-id": "node", + "document-url": "node", + "file-id": "node", + "id": "node", + "node-id": "node", + "url": "node", + }, + Blocked: []string{"dentry-id", "doc", "folder", "folder-id", "name"}, + }, + { + CLIPath: "drive +create-folder", + Aliases: map[string]string{ + "dingdrive-space-id": "space-id", + "directory-id": "folder", + "display-name": "name", + "drive-space-id": "space-id", + "folder-id": "folder", + "folder-name": "name", + "new-name": "name", + "parent-directory-id": "folder", + "parent-folder-id": "folder", + "storage-space-id": "space-id", + }, + Blocked: []string{"dentry-id", "knowledge-base-id", "parent-id", "space", "wiki-workspace-id", "workspace", "workspace-id"}, + }, + { + CLIPath: "drive +create-shortcut", + Aliases: map[string]string{ + "dentry-uuid": "node", + "destination-folder-id": "folder", + "destination-workspace-id": "workspace", + "directory-id": "folder", + "doc-id": "node", + "document-id": "node", + "document-url": "node", + "file-id": "node", + "folder-id": "folder", + "knowledge-base-id": "workspace", + "node-id": "node", + "source-file-id": "node", + "source-node-id": "node", + "target-folder-id": "folder", + "target-workspace-id": "workspace", + "url": "node", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "destination-node-id", "dingdrive-space-id", "doc", "drive-space-id", "name", "source-folder-id", "space", "space-id", "storage-space-id"}, + Ambiguous: []string{"destination-id", "id", "target-id"}, + }, + { + CLIPath: "drive +delete", + Aliases: map[string]string{ + "dentry-uuid": "node", + "file-id": "node", + "folder": "node", + "folder-id": "node", + "node-id": "node", + }, + Blocked: []string{"dentry-id", "doc", "doc-id", "document-id", "document-url", "id", "name", "url"}, + }, + { + CLIPath: "drive +download", + Aliases: map[string]string{ + "dentry-uuid": "node", + "destination-path": "output", + "dingdrive-space-id": "space-id", + "drive-space-id": "space-id", + "file-id": "node", + "node-id": "node", + "output-path": "output", + "save-path": "output", + "storage-space-id": "space-id", + }, + Blocked: []string{"content-file", "dentry-id", "doc", "doc-id", "document-id", "document-url", "file", "file-path", "folder", "folder-id", "id", "knowledge-base-id", "name", "space", "url", "wiki-workspace-id", "workspace", "workspace-id"}, + }, + { + CLIPath: "drive +find-file", + Aliases: map[string]string{ + "keyword": "query", + "keywords": "query", + "q": "query", + "search-word": "query", + }, + Blocked: []string{"name", "subject", "text", "title"}, + }, + { + CLIPath: "drive +info", + Aliases: map[string]string{ + "dentry-uuid": "node", + "dingdrive-space-id": "space-id", + "drive-space-id": "space-id", + "storage-space-id": "space-id", + }, + Blocked: []string{"dentry-id", "document-url", "knowledge-base-id", "space", "wiki-workspace-id", "workspace", "workspace-id"}, + }, + { + CLIPath: "drive +inspect", + Aliases: map[string]string{ + "dentry-uuid": "node", + "dingdrive-space-id": "space-id", + "doc-id": "node", + "document-id": "node", + "drive-space-id": "space-id", + "file-id": "node", + "folder": "node", + "folder-id": "node", + "include-public-status": "include-publish", + "include-publish-status": "include-publish", + "include-statistics": "include-stats", + "include-thumbnail": "include-cover", + "node-id": "node", + "storage-space-id": "space-id", + }, + Blocked: []string{"dentry-id", "doc", "document-url", "id", "include", "include-content", "include-history", "include-permissions", "knowledge-base-id", "name", "space", "url", "wiki-workspace-id", "workspace", "workspace-id"}, + }, + { + CLIPath: "drive +list", + Aliases: map[string]string{ + "dingdrive-space-id": "space-id", + "directory-id": "folder", + "drive-space-id": "space-id", + "folder-id": "folder", + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "order-direction": "order", + "order-field": "order-by", + "page-size": "limit", + "page-token": "cursor", + "parent-folder-id": "folder", + "per-page": "limit", + "size": "limit", + "sort": "order", + "sort-by": "order-by", + "sort-direction": "order", + "sort-field": "order-by", + "storage-space-id": "space-id", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "dentry-id", "knowledge-base-id", "offset", "page", "wiki-workspace-id", "workspace", "workspace-id"}, + Ambiguous: []string{"root-id", "space"}, + }, + { + CLIPath: "drive +move", + Aliases: map[string]string{ + "dentry-uuid": "node", + "destination-folder-id": "folder", + "destination-workspace-id": "workspace", + "directory-id": "folder", + "folder-id": "folder", + "knowledge-base-id": "workspace", + "source-file-id": "node", + "source-node-id": "node", + "target-folder-id": "folder", + "target-workspace-id": "workspace", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "destination-node-id", "dingdrive-space-id", "document-url", "drive-space-id", "name", "node-id", "source-folder-id", "space", "space-id", "storage-space-id"}, + Ambiguous: []string{"destination-id", "target-id"}, + }, + { + CLIPath: "drive +publish-get", + Aliases: map[string]string{ + "dentry-uuid": "node", + "doc-id": "node", + "document-id": "node", + "document-url": "node", + "file-id": "node", + "id": "node", + "node-id": "node", + "url": "node", + }, + Blocked: []string{"dentry-id", "doc", "folder", "folder-id", "name"}, + }, + { + CLIPath: "drive +publish-unset", + Aliases: map[string]string{ + "dentry-uuid": "node", + "doc-id": "node", + "document-id": "node", + "document-url": "node", + "file-id": "node", + "id": "node", + "node-id": "node", + "url": "node", + }, + Blocked: []string{"dentry-id", "doc", "folder", "folder-id", "name"}, + }, + { + CLIPath: "drive +recent", + Aliases: map[string]string{ + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "page-token": "cursor", + "per-page": "limit", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "offset", "page"}, + Ambiguous: []string{"type", "types"}, + }, + { + CLIPath: "drive +recycle-list", + Aliases: map[string]string{ + "dingdrive-space-id": "space-id", + "drive-space-id": "space-id", + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "page-token": "cursor", + "per-page": "limit", + "size": "limit", + "storage-space-id": "space-id", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "knowledge-base-id", "offset", "page", "space", "wiki-workspace-id", "workspace", "workspace-id"}, + }, + { + CLIPath: "drive +recycle-restore", + Aliases: map[string]string{ + "deleted-item-id": "id", + "recycle-item-id": "id", + "trash-item-id": "id", + }, + Blocked: []string{"file-id", "folder-id", "node", "node-id", "space-id", "workspace-id"}, + }, + { + CLIPath: "drive +rename", + Aliases: map[string]string{ + "dentry-uuid": "node", + "display-name": "name", + "doc-id": "node", + "document-id": "node", + "document-url": "node", + "file-id": "node", + "file-name": "name", + "folder": "node", + "folder-id": "node", + "id": "node", + "new-name": "name", + "node-id": "node", + "url": "node", + }, + Blocked: []string{"dentry-id"}, + }, + { + CLIPath: "drive +search", + Aliases: map[string]string{ + "create-time-from": "created-from", + "create-time-to": "created-to", + "created-after": "created-from", + "created-before": "created-to", + "created-by-user-ids": "creator-uids", + "created-time-end": "created-to", + "created-time-start": "created-from", + "creator-ids": "creator-uids", + "creator-user-ids": "creator-uids", + "keyword": "query", + "keywords": "query", + "max-result": "limit", + "max-results": "limit", + "modified-after": "modified-from", + "modified-before": "modified-to", + "modified-time-end": "modified-to", + "modified-time-start": "modified-from", + "modify-time-from": "modified-from", + "modify-time-to": "modified-to", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "page-token": "cursor", + "per-page": "limit", + "q": "query", + "search-word": "query", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "modifier-uids", "name", "offset", "owner-id", "page", "subject", "text", "title", "user", "user-id", "user-ids", "users"}, + Ambiguous: []string{"end", "from", "start", "time-from", "time-to", "to", "types"}, + }, + { + CLIPath: "drive +search-docs", + Aliases: map[string]string{ + "keyword": "query", + "keywords": "query", + "max-result": "limit", + "max-results": "limit", + "page-size": "limit", + "per-page": "limit", + "q": "query", + "search-word": "query", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "cursor", "name", "page", "subject", "text", "title"}, + }, + { + CLIPath: "drive +star-add", + Aliases: map[string]string{ + "dentry-uuid": "node", + "doc-id": "node", + "document-id": "node", + "document-url": "node", + "file-id": "node", + "id": "node", + "node-id": "node", + "url": "node", + }, + Blocked: []string{"dentry-id", "doc", "folder", "folder-id", "name"}, + }, + { + CLIPath: "drive +star-list", + Aliases: map[string]string{ + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "page-token": "cursor", + "per-page": "limit", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "offset", "page"}, + Ambiguous: []string{"type", "types"}, + }, + { + CLIPath: "drive +star-remove", + Aliases: map[string]string{ + "dentry-uuid": "node", + "doc-id": "node", + "document-id": "node", + "document-url": "node", + "file-id": "node", + "id": "node", + "node-id": "node", + "url": "node", + }, + Blocked: []string{"dentry-id", "doc", "folder", "folder-id", "name"}, + }, + { + CLIPath: "drive +stats", + Aliases: map[string]string{ + "dentry-uuid": "node", + "doc-id": "node", + "document-id": "node", + "document-url": "node", + "file-id": "node", + "id": "node", + "node-id": "node", + "url": "node", + }, + Blocked: []string{"dentry-id", "doc", "folder", "folder-id", "name"}, + }, + { + CLIPath: "drive +upload", + Aliases: map[string]string{ + "content-type": "mime-type", + "dentry-uuid": "node", + "dingdrive-space-id": "space-id", + "directory-id": "folder", + "display-name": "file-name", + "drive-space-id": "space-id", + "file-id": "node", + "file-path": "file", + "filename": "file-name", + "folder-id": "folder", + "local-file": "file", + "name": "file-name", + "node-id": "node", + "overwrite-node-id": "node", + "parent-directory-id": "folder", + "parent-folder-id": "folder", + "source-file": "file", + "storage-space-id": "space-id", + "target-folder-id": "folder", + "upload-name": "file-name", + }, + Blocked: []string{"dentry-id", "document-url", "knowledge-base-id", "output-path", "wiki-workspace-id", "workspace", "workspace-id"}, + Ambiguous: []string{"destination-id", "space", "target-id"}, + }, + { + CLIPath: "drive +version-download", + Aliases: map[string]string{ + "dentry-uuid": "node", + "destination-path": "output", + "file-id": "node", + "node-id": "node", + "output-path": "output", + "save-path": "output", + "version-no": "version", + "version-number": "version", + }, + Blocked: []string{"content-file", "dentry-id", "doc", "doc-id", "document-id", "document-url", "file", "file-path", "folder", "folder-id", "id", "name", "revision", "url"}, + }, + { + CLIPath: "drive +version-get", + Aliases: map[string]string{ + "dentry-uuid": "node", + "file-id": "node", + "node-id": "node", + "version-no": "version", + "version-number": "version", + }, + Blocked: []string{"dentry-id", "doc", "doc-id", "document-id", "document-url", "folder", "folder-id", "id", "name", "revision", "url"}, + }, + { + CLIPath: "drive +version-history", + Aliases: map[string]string{ + "dentry-uuid": "node", + "file-id": "node", + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "node-id": "node", + "page-size": "limit", + "page-token": "cursor", + "per-page": "limit", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "dentry-id", "doc", "doc-id", "document-id", "document-url", "folder", "folder-id", "id", "name", "offset", "page", "url"}, + }, + { + CLIPath: "drive +version-revert", + Aliases: map[string]string{ + "dentry-uuid": "node", + "file-id": "node", + "node-id": "node", + "version-no": "version", + "version-number": "version", + }, + Blocked: []string{"dentry-id", "doc", "doc-id", "document-id", "document-url", "folder", "folder-id", "id", "name", "revision", "url"}, + }, + { + CLIPath: "drive commit", + Aliases: map[string]string{ + "content-length": "file-size", + "dingdrive-space-id": "space-id", + "directory-id": "folder", + "display-name": "file-name", + "drive-space-id": "space-id", + "file-size-bytes": "file-size", + "filename": "file-name", + "folder-id": "folder", + "name": "file-name", + "parent-directory-id": "folder", + "size-bytes": "file-size", + "storage-space-id": "space-id", + "upload-name": "file-name", + "upload-session-id": "upload-id", + }, + Blocked: []string{"knowledge-base-id", "limit", "page-size", "part-size", "size", "space", "wiki-workspace-id", "workspace", "workspace-id"}, + }, + { + CLIPath: "drive copy", + Aliases: map[string]string{ + "dentry-uuid": "node", + "destination-folder-id": "folder", + "destination-workspace-id": "workspace", + "directory-id": "folder", + "document-id": "node", + "folder-id": "folder", + "knowledge-base-id": "workspace", + "source-file-id": "node", + "source-node-id": "node", + "target-folder-id": "folder", + "target-workspace-id": "workspace", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "destination-node-id", "dingdrive-space-id", "drive-space-id", "name", "source-folder-id", "space", "space-id", "storage-space-id"}, + Ambiguous: []string{"destination-id", "target-id"}, + }, + { + CLIPath: "drive cover", + Aliases: map[string]string{ + "dentry-uuid": "node", + "document-id": "node", + }, + Blocked: []string{"dentry-id"}, + }, + { + CLIPath: "drive delete", + Aliases: map[string]string{ + "dentry-uuid": "node", + }, + Blocked: []string{"dentry-id", "document-url"}, + }, + { + CLIPath: "drive download", + Aliases: map[string]string{ + "chunk-size": "part-size", + "concurrency": "parallel", + "dentry-uuid": "node", + "destination-path": "output", + "dingdrive-space-id": "space-id", + "drive-space-id": "space-id", + "output-path": "output", + "parallelism": "parallel", + "save-path": "output", + "storage-space-id": "space-id", + "version-no": "version", + "version-number": "version", + }, + Blocked: []string{"content-file", "dentry-id", "document-url", "file", "file-path", "folder", "folder-id", "id", "knowledge-base-id", "revision", "space", "wiki-workspace-id", "workspace", "workspace-id"}, + }, + { + CLIPath: "drive download-version", + Aliases: map[string]string{ + "chunk-size": "part-size", + "concurrency": "parallel", + "dentry-uuid": "node", + "destination-path": "output", + "document-id": "node", + "output-path": "output", + "parallelism": "parallel", + "save-path": "output", + "version-no": "version", + "version-number": "version", + }, + Blocked: []string{"content-file", "dentry-id", "file", "file-path", "folder", "folder-id", "revision"}, }, { CLIPath: "drive info", Aliases: map[string]string{ - "space": "space-id", - "workspace": "space-id", - "workspace-id": "space-id", + "dentry-uuid": "node", + "dingdrive-space-id": "space-id", + "drive-space-id": "space-id", + "storage-space-id": "space-id", }, - Blocked: []string{"folder"}, + Blocked: []string{"dentry-id", "document-url", "knowledge-base-id", "space", "wiki-workspace-id", "workspace", "workspace-id"}, }, { CLIPath: "drive list", Aliases: map[string]string{ - "folder-id": "folder", + "dentry-uuid": "node", + "dingdrive-space-id": "space-id", + "directory-id": "folder", + "document-id": "node", + "drive-space-id": "space-id", + "folder-id": "folder", + "knowledge-base-id": "workspace", + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "order-direction": "order", + "order-field": "order-by", + "per-page": "limit", + "size": "limit", + "sort": "order", + "sort-by": "order-by", + "sort-direction": "order", + "sort-field": "order-by", + "storage-space-id": "space-id", + "take": "limit", + "top": "limit", + "wiki-workspace-id": "workspace", }, - Ambiguous: []string{"space"}, + Blocked: []string{"count", "dentry-id", "offset", "page"}, + Ambiguous: []string{"root-id", "space"}, }, { - CLIPath: "drive upload", - Ambiguous: []string{"space"}, + CLIPath: "drive list-spaces", + Aliases: map[string]string{ + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "per-page": "limit", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "offset", "page"}, + }, + { + CLIPath: "drive mkdir", + Aliases: map[string]string{ + "dingdrive-space-id": "space-id", + "directory-id": "folder", + "display-name": "name", + "drive-space-id": "space-id", + "folder-id": "folder", + "folder-name": "name", + "new-name": "name", + "parent-directory-id": "folder", + "storage-space-id": "space-id", + }, + Blocked: []string{"knowledge-base-id", "space", "wiki-workspace-id", "workspace", "workspace-id"}, + }, + { + CLIPath: "drive move", + Aliases: map[string]string{ + "dentry-uuid": "node", + "destination-folder-id": "folder", + "destination-workspace-id": "workspace", + "directory-id": "folder", + "document-id": "node", + "folder-id": "folder", + "knowledge-base-id": "workspace", + "source-file-id": "node", + "source-node-id": "node", + "target-folder-id": "folder", + "target-workspace-id": "workspace", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "destination-node-id", "dingdrive-space-id", "drive-space-id", "name", "source-folder-id", "space", "space-id", "storage-space-id"}, + Ambiguous: []string{"destination-id", "target-id"}, + }, + { + CLIPath: "drive permission add", + Aliases: map[string]string{ + "access-role": "role", + "collaborator-ids": "users", + "dentry-uuid": "node", + "document-id": "node", + "knowledge-base-id": "workspace", + "member-role": "role", + "member-user-ids": "users", + "permission-role": "role", + "target-node-id": "node", + "target-user-ids": "users", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "filter-role", "folder", "folder-id", "permission", "public-permission", "reserve-role", "space", "space-id", "storage-space-id"}, + }, + { + CLIPath: "drive permission apply", + Aliases: map[string]string{ + "access-role": "role", + "apply-reason": "reason", + "approver-ids": "users", + "approver-user-ids": "users", + "dentry-uuid": "node", + "document-id": "node", + "member-role": "role", + "notification-mode": "notify-mode", + "permission-role": "role", + "target-node-id": "node", + }, + Blocked: []string{"dentry-id", "filter-role", "permission", "public-permission", "reserve-role"}, + }, + { + CLIPath: "drive permission apply-info", + Aliases: map[string]string{ + "dentry-uuid": "node", + "document-id": "node", + }, + Blocked: []string{"dentry-id"}, + }, + { + CLIPath: "drive permission list", + Aliases: map[string]string{ + "dentry-uuid": "node", + "document-id": "node", + "knowledge-base-id": "workspace", + "max-result": "limit", + "per-page": "limit", + "permission-role-filter": "filter-role", + "role-filter": "filter-role", + "size": "limit", + "take": "limit", + "target-node-id": "node", + "top": "limit", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"count", "cursor", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "page", "permission", "role", "space", "space-id", "storage-space-id"}, + }, + { + CLIPath: "drive permission remove", + Aliases: map[string]string{ + "collaborator-ids": "users", + "dentry-uuid": "node", + "document-id": "node", + "knowledge-base-id": "workspace", + "member-user-ids": "users", + "target-node-id": "node", + "target-user-ids": "users", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "space", "space-id", "storage-space-id"}, + }, + { + CLIPath: "drive permission transfer-owner", + Aliases: map[string]string{ + "dentry-uuid": "node", + "document-id": "node", + "keep-role": "reserve-role", + "knowledge-base-id": "workspace", + "new-owner-id": "new-owner", + "new-owner-user-id": "new-owner", + "old-owner-role": "reserve-role", + "target-node-id": "node", + "target-workspace-id": "workspace", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"current-owner", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "space", "space-id", "storage-space-id", "user-ids", "users"}, + Ambiguous: []string{"owner", "owner-user-id", "target-id", "user-id"}, + }, + { + CLIPath: "drive permission update", + Aliases: map[string]string{ + "access-role": "role", + "collaborator-ids": "users", + "dentry-uuid": "node", + "document-id": "node", + "knowledge-base-id": "workspace", + "member-role": "role", + "member-user-ids": "users", + "permission-role": "role", + "target-node-id": "node", + "target-user-ids": "users", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "filter-role", "folder", "folder-id", "permission", "public-permission", "reserve-role", "space", "space-id", "storage-space-id"}, + }, + { + CLIPath: "drive publish get", + Aliases: map[string]string{ + "dentry-uuid": "node", + "document-id": "node", + }, + Blocked: []string{"dentry-id"}, + }, + { + CLIPath: "drive publish set", + Aliases: map[string]string{ + "dentry-uuid": "node", + "document-id": "node", + "public-permission": "permission", + "public-role": "permission", + }, + Blocked: []string{"access-role", "dentry-id", "permission-role", "role"}, + }, + { + CLIPath: "drive publish unset", + Aliases: map[string]string{ + "dentry-uuid": "node", + "document-id": "node", + }, + Blocked: []string{"dentry-id"}, + }, + { + CLIPath: "drive recent", + Aliases: map[string]string{ + "max-result": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "per-page": "limit", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "offset", "page"}, + Ambiguous: []string{"type", "types"}, + }, + { + CLIPath: "drive recycle list", + Aliases: map[string]string{ + "dingdrive-space-id": "space-id", + "drive-space-id": "space-id", + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "per-page": "limit", + "size": "limit", + "storage-space-id": "space-id", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "knowledge-base-id", "offset", "page", "space", "wiki-workspace-id", "workspace", "workspace-id"}, + }, + { + CLIPath: "drive recycle restore", + Aliases: map[string]string{ + "deleted-item-id": "id", + "recycle-item-id": "id", + "trash-item-id": "id", + }, + Blocked: []string{"file-id", "folder-id", "node", "node-id", "space-id", "workspace-id"}, + }, + { + CLIPath: "drive rename", + Aliases: map[string]string{ + "dentry-uuid": "node", + "display-name": "name", + "document-id": "node", + "file-name": "name", + "new-name": "name", + }, + Blocked: []string{"dentry-id"}, + }, + { + CLIPath: "drive revert", + Aliases: map[string]string{ + "dentry-uuid": "node", + "document-id": "node", + "version-no": "version", + "version-number": "version", + }, + Blocked: []string{"dentry-id", "revision"}, + }, + { + CLIPath: "drive search", + Aliases: map[string]string{ + "create-time-from": "created-from", + "create-time-to": "created-to", + "created-after": "created-from", + "created-before": "created-to", + "created-by-user-ids": "creator-uids", + "created-time-end": "created-to", + "created-time-start": "created-from", + "creator-ids": "creator-uids", + "creator-user-ids": "creator-uids", + "keywords": "query", + "max-result": "limit", + "max-results": "limit", + "modified-after": "modified-from", + "modified-before": "modified-to", + "modified-time-end": "modified-to", + "modified-time-start": "modified-from", + "modify-time-from": "modified-from", + "modify-time-to": "modified-to", + "next-cursor": "cursor", + "next-page-token": "cursor", + "per-page": "limit", + "q": "query", + "search-word": "query", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "modifier-uids", "name", "offset", "owner-id", "page", "subject", "text", "title", "user", "user-id", "user-ids", "users"}, + Ambiguous: []string{"end", "from", "start", "time-from", "time-to", "to", "types"}, + }, + { + CLIPath: "drive shortcut", + Aliases: map[string]string{ + "dentry-uuid": "node", + "destination-folder-id": "folder", + "destination-workspace-id": "workspace", + "directory-id": "folder", + "document-id": "node", + "folder-id": "folder", + "knowledge-base-id": "workspace", + "source-file-id": "node", + "source-node-id": "node", + "target-folder-id": "folder", + "target-workspace-id": "workspace", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "destination-node-id", "dingdrive-space-id", "drive-space-id", "name", "source-folder-id", "space", "space-id", "storage-space-id"}, + Ambiguous: []string{"destination-id", "target-id"}, + }, + { + CLIPath: "drive star add", + Aliases: map[string]string{ + "dentry-uuid": "node", + "document-id": "node", + }, + Blocked: []string{"dentry-id"}, + }, + { + CLIPath: "drive star list", + Aliases: map[string]string{ + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "order": "sort", + "order-direction": "sort", + "order-field": "order-by", + "per-page": "limit", + "size": "limit", + "sort-by": "order-by", + "sort-direction": "sort", + "sort-field": "order-by", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "offset", "page"}, + Ambiguous: []string{"type", "types"}, + }, + { + CLIPath: "drive star remove", + Aliases: map[string]string{ + "dentry-uuid": "node", + "document-id": "node", + }, + Blocked: []string{"dentry-id"}, + }, + { + CLIPath: "drive stats", + Aliases: map[string]string{ + "dentry-uuid": "node", + "document-id": "node", + }, + Blocked: []string{"dentry-id"}, + }, + { + CLIPath: "drive upload", + Aliases: map[string]string{ + "content-type": "mime-type", + "dentry-uuid": "node", + "dingdrive-space-id": "space-id", + "directory-id": "folder", + "display-name": "file-name", + "drive-space-id": "space-id", + "filename": "file-name", + "folder-id": "folder", + "knowledge-base-id": "workspace", + "name": "file-name", + "overwrite-node-id": "node", + "source-file": "file", + "storage-space-id": "space-id", + "target-folder-id": "folder", + "target-workspace-id": "workspace", + "upload-name": "file-name", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "document-url", "output-path"}, + Ambiguous: []string{"destination-id", "space", "target-id"}, + }, + { + CLIPath: "drive upload-info", + Aliases: map[string]string{ + "content-length": "file-size", + "content-type": "mime-type", + "dingdrive-space-id": "space-id", + "directory-id": "folder", + "display-name": "file-name", + "drive-space-id": "space-id", + "file-size-bytes": "file-size", + "filename": "file-name", + "folder-id": "folder", + "name": "file-name", + "parent-directory-id": "folder", + "size-bytes": "file-size", + "storage-space-id": "space-id", + "upload-name": "file-name", + }, + Blocked: []string{"knowledge-base-id", "limit", "page-size", "part-size", "size", "space", "wiki-workspace-id", "workspace", "workspace-id"}, }, { CLIPath: "mail +find-mail-user", diff --git a/internal/cli/param_concepts.json b/internal/cli/param_concepts.json index db93b507..edc6a396 100644 --- a/internal/cli/param_concepts.json +++ b/internal/cli/param_concepts.json @@ -10,10 +10,10 @@ }, "concepts": { - "search_query": {"denotes": "search keyword string", "canonical_hint": "query", "members": ["query", "keyword", "keywords", "q", "search-word"], "excludes": ["name", "subject", "text", "title"], "commands": ["aitable +base-search", "contact +dept-members", "contact +resolve-dept", "contact +search-user", "doc +create-from-template", "doc +find-doc", "doc +search", "doc +template-search", "doc template search", "mail +find-mail-user", "mail user search", "oa +search-forms", "oa approval search-forms"], "risk": "green"}, - "pagination_size": {"denotes": "returned item count upper bound", "canonical_hint": "limit", "members": ["limit", "size", "page-size", "max-results", "max-result", "take", "top", "per-page"], "excludes": ["count", "page", "cursor"], "commands": ["aitable record query", "calendar event list", "chat message list", "devdoc article search", "doc +comment-list", "doc +find-doc", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list", "mail thread list", "oa +list-executed"], "risk": "green"}, + "search_query": {"denotes": "search keyword string", "canonical_hint": "query", "members": ["query", "keyword", "keywords", "q", "search-word"], "excludes": ["name", "subject", "text", "title"], "commands": ["aitable +base-search", "contact +dept-members", "contact +resolve-dept", "contact +search-user", "doc +create-from-template", "doc +find-doc", "doc +search", "doc +template-search", "doc template search", "drive +find-file", "drive +search", "drive +search-docs", "drive search", "mail +find-mail-user", "mail user search", "oa +search-forms", "oa approval search-forms"], "risk": "green"}, + "pagination_size": {"denotes": "returned item count upper bound", "canonical_hint": "limit", "members": ["limit", "size", "page-size", "max-results", "max-result", "take", "top", "per-page"], "excludes": ["count", "page", "cursor"], "commands": ["aitable record query", "calendar event list", "chat message list", "devdoc article search", "doc +comment-list", "doc +find-doc", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list", "drive +list", "drive +recent", "drive +recycle-list", "drive +search", "drive +search-docs", "drive +star-list", "drive +version-history", "drive list", "drive list-spaces", "drive permission list", "drive recent", "drive recycle list", "drive search", "drive star list", "mail thread list", "oa +list-executed"], "risk": "green"}, "page_number": {"denotes": "one-based page number", "canonical_hint": "page", "members": ["page", "page-no", "current-page", "page-num"], "excludes": ["cursor", "page-index", "page-size", "page-token"], "commands": ["devdoc article search"], "risk": "green"}, - "page_cursor": {"denotes": "pagination cursor/token", "canonical_hint": "cursor", "members": ["cursor", "next-cursor", "page-token", "next-token", "next-page-token"], "excludes": ["page", "offset"], "commands": ["calendar event list", "doc +comment-list", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list"], "risk": "green"}, + "page_cursor": {"denotes": "pagination cursor/token", "canonical_hint": "cursor", "members": ["cursor", "next-cursor", "page-token", "next-token", "next-page-token"], "excludes": ["page", "offset"], "commands": ["calendar event list", "doc +comment-list", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list", "drive +list", "drive +recent", "drive +recycle-list", "drive +search", "drive +star-list", "drive +version-history", "drive list", "drive list-spaces", "drive recent", "drive recycle list", "drive search", "drive star list"], "risk": "green"}, "content_text": {"denotes": "text body content", "canonical_hint": "text", "members": ["text", "content", "body"], "excludes": ["title", "name"], "commands": ["doc +checkpoint-update", "doc +comment-create", "doc +comment-reply", "doc +comment-update", "doc +create", "doc +doc-append", "doc block insert", "doc block update", "doc comment create", "doc comment create-inline", "doc comment reply", "doc comment update", "doc create"], "risk": "green"}, "time_start": {"denotes": "start time point with unchanged value format and unit", "canonical_hint": "start", "members": ["start", "start-time", "start-date", "from", "from-date", "begin", "since", "time-min", "min-time"], "excludes": ["date", "time", "end"], "commands": ["calendar event list", "chat message list-all", "report list"], "risk": "yellow"}, "time_end": {"denotes": "end time point with unchanged value format and unit", "canonical_hint": "end", "members": ["end", "end-time", "end-date", "time-max", "max-time"], "excludes": ["date", "time", "start"], "commands": ["calendar event list"], "risk": "yellow"}, @@ -31,20 +31,31 @@ "user_ids": {"denotes": "user id list", "canonical_hint": "user-ids", "members": ["users", "user-ids"], "excludes": ["user", "user-id", "userid", "uid", "staff-id", "at-user-ids"], "commands": ["attendance +check-result", "attendance check result", "chat +messages-batch-send-by-bot", "chat group members remove", "chat group set-admin", "chat group-mute-member", "chat message read-status", "chat message search-advanced", "chat message send-by-bot"], "risk": "yellow"}, "open_dingtalk_ids": {"denotes": "DingTalk openDingTalkId list with unchanged element values", "canonical_hint": "open-dingtalk-ids", "members": ["open-dingtalk-ids"], "excludes": ["user", "user-id", "user-ids", "staff-id", "users"], "commands": ["chat +chat-members-get", "chat category create-smart", "chat group members list-by-ids", "chat message send-by-bot"], "risk": "yellow"}, "ding_id": {"denotes": "DING id", "canonical_hint": "ding-id", "members": ["ding-id", "open-ding-id"], "excludes": ["id"], "commands": ["ding message receiver-status"], "risk": "yellow"}, - "folder_id": {"denotes": "drive folder id", "canonical_hint": "folder", "members": ["folder", "folder-id"], "excludes": ["space-id"], "commands": ["drive list", "mail folder update"], "risk": "green"}, - "space_id": {"denotes": "drive/wiki/Doc workspace id with unchanged value", "canonical_hint": "space-id", "members": ["space-id", "space", "workspace", "workspace-id"], "excludes": ["folder", "node"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +copy", "doc +create", "doc +create-from-template", "doc +grant-and-share", "doc +import", "doc +list", "doc +move", "doc create", "doc file create", "doc import", "doc template apply", "drive info"], "risk": "yellow"}, + "folder_id": {"denotes": "drive folder id", "canonical_hint": "folder", "members": ["folder", "folder-id"], "excludes": ["space-id"], "commands": ["drive +copy", "drive +create-folder", "drive +create-shortcut", "drive +list", "drive +move", "drive +upload", "drive commit", "drive copy", "drive list", "drive mkdir", "drive move", "drive shortcut", "drive upload", "drive upload-info", "mail folder update"], "risk": "green"}, + "drive_storage_space_id": {"denotes": "single numeric DingDrive storage space ID with unchanged value", "canonical_hint": "space-id", "members": ["space-id", "drive-space-id", "storage-space-id", "dingdrive-space-id"], "excludes": ["space", "workspace", "workspace-id", "knowledge-base-id", "wiki-workspace-id"], "commands": ["drive +create-folder", "drive +download", "drive +info", "drive +inspect", "drive +list", "drive +recycle-list", "drive +upload", "drive commit", "drive download", "drive info", "drive list", "drive mkdir", "drive recycle list", "drive upload", "drive upload-info"], "risk": "yellow"}, "app_id": {"denotes": "application id", "canonical_hint": "unified-app-id", "members": ["app-id", "unified-app-id", "application-id"], "excludes": ["app-key", "app-secret", "agent-id"], "commands": ["dev app get"], "risk": "yellow"}, "robot_code": {"denotes": "robot code", "canonical_hint": "robot-code", "members": ["robot-code", "robot"], "excludes": ["robot-id", "bot-id", "open-bot-id", "bot-code"], "commands": ["chat +chat-add-bot", "chat +messages-batch-recall-by-bot", "chat +messages-batch-send-by-bot", "chat +messages-recall-by-bot", "chat +messages-send-by-bot", "chat group members add-bot", "chat message recall-by-bot", "chat message send-by-bot", "ding message send"], "risk": "yellow"}, "open_bot_id": {"denotes": "single DingTalk openBotId with unchanged value", "canonical_hint": "bot-id", "members": ["bot-id", "open-bot-id"], "excludes": ["robot-code", "robot", "robot-id", "bot-code"], "commands": ["chat +chat-remove-bot", "chat group members remove-bot"], "risk": "yellow"}, - "doc_node_id": {"denotes": "single DingTalk document nodeId or accepted document URL/token with unchanged value", "canonical_hint": "node", "members": ["node", "node-id", "doc", "doc-id", "file-id", "document-id", "url"], "excludes": ["id", "folder", "folder-id", "parent-id", "workspace", "workspace-id", "block-id", "comment-id", "comment-key", "job-id", "task-id", "template-id", "version", "revision"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +background-delete", "doc +background-update", "doc +checkpoint-update", "doc +comment-create", "doc +comment-delete", "doc +comment-list", "doc +comment-reply", "doc +comment-update", "doc +copy", "doc +doc-append", "doc +export", "doc +export-submit", "doc +fetch", "doc +history-list", "doc +history-revert", "doc +history-save", "doc +inspect", "doc +move", "doc +review", "doc +version-list", "doc +version-revert", "doc +version-save", "doc block delete", "doc block insert", "doc block list", "doc block update", "doc comment create", "doc comment create-inline", "doc comment delete", "doc comment list", "doc comment reply", "doc comment update", "doc export", "doc info", "doc media download", "doc media insert", "doc media upload", "doc read", "doc style background clear", "doc style background set", "doc style cover clear", "doc style cover set", "doc style get", "doc update", "doc version list", "doc version revert", "doc version save", "doc whiteboard insert"], "risk": "yellow"}, + "doc_node_id": {"denotes": "single DingTalk document nodeId or accepted document URL/token with unchanged value", "canonical_hint": "node", "members": ["node", "node-id", "doc", "doc-id", "file-id", "document-id", "url", "dentry-uuid"], "excludes": ["id", "folder", "folder-id", "parent-id", "workspace", "workspace-id", "block-id", "comment-id", "comment-key", "job-id", "task-id", "template-id", "version", "revision", "dentry-id", "space-id", "name", "role"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +background-delete", "doc +background-update", "doc +checkpoint-update", "doc +comment-create", "doc +comment-delete", "doc +comment-list", "doc +comment-reply", "doc +comment-update", "doc +copy", "doc +doc-append", "doc +export", "doc +export-submit", "doc +fetch", "doc +history-list", "doc +history-revert", "doc +history-save", "doc +inspect", "doc +move", "doc +review", "doc +version-list", "doc +version-revert", "doc +version-save", "doc block delete", "doc block insert", "doc block list", "doc block update", "doc comment create", "doc comment create-inline", "doc comment delete", "doc comment list", "doc comment reply", "doc comment update", "doc export", "doc info", "doc media download", "doc media insert", "doc media upload", "doc read", "doc style background clear", "doc style background set", "doc style cover clear", "doc style cover set", "doc style get", "doc update", "doc version list", "doc version revert", "doc version save", "doc whiteboard insert"], "risk": "yellow"}, "doc_comment_key": {"denotes": "single DingTalk document commentKey with unchanged value", "canonical_hint": "comment-key", "members": ["comment-key", "comment-id"], "excludes": ["id", "node", "node-id", "doc-id", "block-id"], "commands": ["doc +comment-delete", "doc +comment-reply", "doc +comment-update", "doc comment delete", "doc comment reply", "doc comment update"], "risk": "yellow"}, - "doc_version_number": {"denotes": "single DingTalk document historical version number with unchanged integer value", "canonical_hint": "version", "members": ["version", "version-number", "version-no"], "excludes": ["revision", "id", "node", "node-id", "doc-id"], "commands": ["doc +history-revert", "doc +version-revert", "doc version revert"], "risk": "yellow"}, + "doc_version_number": {"denotes": "single document or Drive ordinary-file historical version number with unchanged positive integer value", "canonical_hint": "version", "members": ["version", "version-number", "version-no"], "excludes": ["revision", "id", "node", "node-id", "doc-id"], "commands": ["doc +history-revert", "doc +version-revert", "doc version revert", "drive +version-download", "drive +version-get", "drive +version-revert", "drive download", "drive download-version", "drive revert"], "risk": "yellow"}, "doc_content_format": {"denotes": "DingTalk document body format with unchanged markdown/jsonml value", "canonical_hint": "content-format", "members": ["content-format", "doc-format"], "excludes": ["format", "export-format", "mime-type"], "commands": ["doc +create", "doc +update", "doc create", "doc update"], "risk": "green"}, - "doc_edit_revision": {"denotes": "single optimistic-concurrency revision for a document edit", "canonical_hint": "revision", "members": ["revision", "expected-revision"], "excludes": ["version", "version-number", "version-no"], "commands": ["doc +update", "doc update"], "risk": "yellow"} + "doc_edit_revision": {"denotes": "single optimistic-concurrency revision for a document edit", "canonical_hint": "revision", "members": ["revision", "expected-revision"], "excludes": ["version", "version-number", "version-no"], "commands": ["doc +update", "doc update"], "risk": "yellow"}, + "drive_recycle_item_id": {"denotes": "single Drive recycle-bin item ID returned by recycle list", "canonical_hint": "id", "members": ["id", "recycle-item-id", "trash-item-id", "deleted-item-id"], "excludes": ["node", "node-id", "file-id", "folder-id", "space-id", "workspace-id"], "commands": ["drive +recycle-restore", "drive recycle restore"], "risk": "yellow"}, + "drive_modified_time_start": {"denotes": "Drive search modified-time lower bound in unchanged millisecond timestamp unit", "canonical_hint": "modified-from", "members": ["modified-from", "modified-after", "modify-time-from", "modified-time-start"], "excludes": ["modified-to", "created-from", "created-to", "start", "from"], "commands": ["drive +search", "drive search"], "risk": "yellow"}, + "drive_modified_time_end": {"denotes": "Drive search modified-time upper bound in unchanged millisecond timestamp unit", "canonical_hint": "modified-to", "members": ["modified-to", "modified-before", "modify-time-to", "modified-time-end"], "excludes": ["modified-from", "created-from", "created-to", "end", "to"], "commands": ["drive +search", "drive search"], "risk": "yellow"}, + "drive_file_size_bytes": {"denotes": "file size in bytes passed unchanged", "canonical_hint": "file-size", "members": ["file-size", "file-size-bytes", "size-bytes", "content-length"], "excludes": ["part-size", "page-size", "limit", "size"], "commands": ["drive commit", "drive upload-info"], "risk": "yellow"}, + "drive_sort_direction": {"denotes": "Drive result sort direction with unchanged asc/desc enum", "canonical_hint": "order", "members": ["order", "sort", "sort-direction", "order-direction"], "excludes": ["order-by", "sort-by", "order-field"], "commands": ["drive +list", "drive list", "drive star list"], "risk": "green"}, + "workspace_id": {"denotes": "single knowledge-base or document-space workspace ID/URL passed unchanged; never a numeric DingDrive storage space ID", "canonical_hint": "workspace", "members": ["workspace", "workspace-id", "knowledge-base-id", "wiki-workspace-id"], "excludes": ["space", "space-id", "drive-space-id", "storage-space-id", "dingdrive-space-id", "folder", "folder-id", "node", "node-id", "dentry-id"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +copy", "doc +create", "doc +create-from-template", "doc +grant-and-share", "doc +import", "doc +list", "doc +move", "doc create", "doc file create", "doc import", "doc template apply", "drive +copy", "drive +create-shortcut", "drive +move", "drive copy", "drive list", "drive move", "drive permission add", "drive permission list", "drive permission remove", "drive permission transfer-owner", "drive permission update", "drive shortcut", "drive upload"], "risk": "yellow"}, + "created_time_start": {"denotes": "Doc/Drive search created-time lower bound in unchanged millisecond timestamp unit", "canonical_hint": "created-from", "members": ["created-from", "created-after", "create-time-from", "created-time-start"], "excludes": ["created-to", "modified-from", "modified-to", "start", "from"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"}, + "created_time_end": {"denotes": "Doc/Drive search created-time upper bound in unchanged millisecond timestamp unit", "canonical_hint": "created-to", "members": ["created-to", "created-before", "create-time-to", "created-time-end"], "excludes": ["created-from", "modified-from", "modified-to", "end", "to"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"}, + "document_permission_role": {"denotes": "direct document-space permission role on grant/apply/update, passed unchanged", "canonical_hint": "role", "members": ["role", "permission-role", "access-role", "member-role"], "excludes": ["filter-role", "reserve-role", "permission", "public-permission"], "commands": ["doc +access-change", "doc +access-grant", "doc +grant-and-share", "drive permission add", "drive permission apply", "drive permission update"], "risk": "yellow"}, + "creator_user_ids": {"denotes": "creator userId list used to filter Doc/Drive search results, passed unchanged", "canonical_hint": "creator-uids", "members": ["creator-uids", "creator-user-ids", "creator-ids", "created-by-user-ids"], "excludes": ["user", "user-id", "user-ids", "users", "owner-id", "modifier-uids"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"}, + "local_output_path": {"denotes": "local destination file or directory path for a download/export result, passed unchanged", "canonical_hint": "output", "members": ["output", "output-path", "destination-path", "save-path"], "excludes": ["file", "file-path", "folder", "folder-id", "content-file"], "commands": ["doc +export", "doc +export-get", "doc +media-download", "doc +resource-download", "doc read", "drive +download", "drive +version-download", "drive download", "drive download-version"], "risk": "green"} }, "command_overrides": { - "doc +version-list": {"ambiguous": ["size", "max-results", "max-result", "take", "top", "per-page", "next-cursor", "next-token", "next-page-token"], "note": "--limit/--cursor and the shipped visible compatibility flags --page-size/--page-token remain native. Other pagination spellings cannot choose between two visible real flags and must stop before execution."}, + "doc +version-list": {"ambiguous": ["size", "max-results", "max-result", "take", "top", "per-page", "next-cursor", "next-token", "next-page-token"], "note": "--limit/--cursor and the shipped visible compatibility flags --page-size/--page-token remain native. Other pagination spellings cannot choose between two visible real flags and must stop before execution."}, "chat group rename": {"bind": {"id": "open_conversation_id"}, "note": "This command's real --id carries one openConversationId; aliases reduce to --id without changing the value."}, "chat group members": {"bind": {"id": "open_conversation_id"}}, "chat group members add": {"bind": {"id": "open_conversation_id"}, "block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a list and may contain mixed userId/openDingTalkId values; singular inputs are not promoted automatically."}, @@ -54,8 +65,8 @@ "chat message remove-emoji": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."}, "chat message remove-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."}, "chat mute": {"scoped_aliases": {"group": "conversation-id", "chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --conversation-id/--id/--chat remain unchanged; other reviewed openConversationId spellings reduce to --conversation-id."}, - "drive list": {"ambiguous": ["space"], "note": "both --space-id and native compatibility --workspace-id/--workspace exist; bare --space cannot choose one"}, - "drive upload": {"ambiguous": ["space"], "note": "both --space-id and native compatibility --workspace-id/--workspace exist; bare --space cannot choose one"}, + "drive list": {"ambiguous": ["root-id", "space"], "note": "Numeric --space-id and knowledge-base --workspace are distinct routes; bare --space/--root-id cannot select a domain or folder.", "scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "order-field": "order-by", "sort-by": "order-by", "sort-field": "order-by"}, "scope_strict": true, "block": ["dentry-id"]}, + "drive upload": {"ambiguous": ["destination-id", "space", "target-id"], "note": "Local file, display name, MIME, overwrite node, folder, storage space, and knowledge-base workspace remain distinct roles.", "scoped_aliases": {"dentry-uuid": "node", "directory-id": "folder", "overwrite-node-id": "node", "target-folder-id": "folder", "target-workspace-id": "workspace", "source-file": "file", "content-type": "mime-type", "filename": "file-name", "name": "file-name", "display-name": "file-name", "upload-name": "file-name"}, "block": ["dentry-id", "document-url", "output-path"], "scope_strict": true}, "ding +receiver-status": {"scoped_aliases": {"id": "ding-id"}, "note": "generic id reduces to ding-id"}, "ding message receiver-status": {"scoped_aliases": {"id": "ding-id"}}, "contact user profile get": {"scoped_aliases": {"id": "staff-id", "ids": "staff-id"}, "note": "user-id is reduced by the user_id concept; generic id/ids bound explicitly"}, @@ -114,7 +125,7 @@ "doc export get": {"scoped_aliases": {"export-job-id": "job-id"}, "block": ["doc-id", "document-id", "file-id", "node", "node-id", "url"], "scope_strict": true, "note": "This command queries one export jobId. Document node identifiers are rejected; native hidden --task-id remains the command's reviewed add-only compatibility alias for --job-id."}, "doc import get": {"scoped_aliases": {"import-task-id": "task-id"}, "block": ["doc-id", "document-id", "file-id", "job-id", "node", "node-id", "url"], "scope_strict": true, "note": "This command queries one import taskId. Document node identifiers and export jobId spellings are different entities and are rejected."}, "doc +share-doc": {"block": ["doc", "doc-id", "document-id", "file-id", "id", "node", "node-id"], "note": "The real --url requires a shareable document link. Name-only normalization cannot turn a document nodeId into a URL, so identifier spellings are rejected with guidance to provide --url."}, - "doc update": {"block": ["version", "version-no", "version-number"], "note": "--revision is an optimistic-concurrency revision, not a historical document version number. Version spellings must not reduce to --revision."}, + "doc update": {"block": ["stdin", "version", "version-no", "version-number"], "note": "--revision is an optimistic-concurrency revision, not a historical document version number. Version spellings must not reduce to --revision. --stdin is not a real switch: stdin input is expressed as --content -, which requires a value-form transformation outside central name aliases."}, "report outbox list": {"block": ["template-type"], "note": "type vs name are different fields"}, "chat group members add-bot": {"bind": {"id": "open_conversation_id"}}, "chat group members list-by-ids": {"bind": {"id": "open_conversation_id", "users": "open_dingtalk_ids"}, "block": ["user-id", "user-ids"], "note": "This command's --id carries openConversationId, while --users carries an openDingTalkId list."}, @@ -158,21 +169,80 @@ "chat +messages-recall-by-bot": {"block": ["msg-id", "message-id", "open-message-id", "msg-ids", "message-ids", "open-message-ids"], "note": "--keys carries processQueryKey values, not openMessageId values."}, "chat +messages-reply": {"scoped_aliases": {"msg-id": "ref-msg-id", "open-message-id": "ref-msg-id"}, "block": ["group", "msg-ids", "message-ids", "open-message-ids"], "scope_strict": true, "note": "The observed --group spelling carried a natural group name and is blocked. The only message role is the referenced message; plural IDs are not accepted, while --chat remains a CID alias and native --message-id stays native."}, "chat +messages-resource-download": {"block": ["download-dir"], "note": "--output may be a file or directory under workspace safety rules; a download directory cannot be assumed equivalent."}, + "doc +access-grant": {"block": ["target-user-id", "target-user-ids", "user-id", "user-ids"], "ambiguous": ["target-user", "user", "users"], "scope_strict": true, "note": "The real --to accepts collaborator names and resolves them before granting access. Explicit ID spellings cannot be passed through unchanged, while role-free user spellings do not prove whether their values are names or IDs."}, "doc +create": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["content-file", "parent-id"], "scope_strict": true, "note": "--content-format is value-preservingly normalized to --doc-format. A raw --content-file path cannot become --content without adding the required @file transform, so it is blocked with guidance to use @relative-path or stable doc create."}, "doc +create-from-template": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."}, "doc +import": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."}, - "doc +update": {"scoped_aliases": {"mode": "command", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node", "url": "node"}, "block": ["content-file"], "scope_strict": true, "note": "--mode append/overwrite is the same operation selector subset as --command and preserves its value. --content-file is blocked because +update requires @relative-path or stdin and central aliases cannot read/transform a file value."}, - "doc +inspect": {"scoped_aliases": {"include-versions": "include-history"}, "block": ["include", "include-info"], "scope_strict": true, "note": "Historical versions and history are the same optional section on this exact shortcut. Generic --include needs value-dependent flag expansion, while base document info is always returned, so those spellings are rejected with precise guidance."}, - "doc +fetch": {"scoped_aliases": {"start-block": "start-block-id", "end-block": "end-block-id"}, "ambiguous": ["block-id"], "scope_strict": true, "note": "Start/end block roles are preserved. A role-free --block-id cannot choose a range/section boundary and must stop before execution."}, + "doc +update": {"scoped_aliases": {"mode": "command", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node", "url": "node"}, "block": ["content-file"], "ambiguous": ["element"], "scope_strict": true, "note": "--mode append/overwrite is the same operation selector subset as --command and preserves its value. --content-file is blocked because +update requires @relative-path or stdin and central aliases cannot read/transform a file value. --element cannot choose between document content, a block target, or an insertion reference."}, + "doc +inspect": {"scoped_aliases": {"include-access": "include-permissions", "include-member": "include-permissions", "include-members": "include-permissions", "include-versions": "include-history"}, "block": ["include", "include-blocks", "include-content", "include-info", "include-meta", "include-metadata"], "scope_strict": true, "note": "Access/member spellings denote the same optional permission list, and versions/history denote the same history section. Generic --include needs value-dependent expansion; base metadata is already returned; block/content reads belong to +fetch, so those spellings stop before fuzzy correction or dispatch."}, + "doc +fetch": {"scoped_aliases": {"start-block": "start-block-id", "end-block": "end-block-id"}, "block": ["content-format", "doc-format", "range"], "ambiguous": ["block-id"], "scope_strict": true, "note": "Start/end block roles are preserved. A role-free --block-id cannot choose a range/section boundary. --range is an invented composite argument observed alongside --scope range and cannot be split into block IDs by name-only normalization; content-format spellings do not map to the independent --detail contract."}, + "doc +export": {"block": ["wait"], "scope_strict": true, "note": "The shortcut already submits, polls, and downloads in one workflow. A generic --wait value cannot be converted into the distinct integer --max-polls contract by parameter-name normalization."}, "doc +media-download": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and attachment-resource roles. Strong document spellings map to --node; --file-id and --url cannot safely choose between document and media identities."}, - "doc +media-insert": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and local-media roles. Strong document spellings map to --node; --file-id and --url cannot safely choose a role."}, + "doc +media-insert": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "block": ["after-block-id", "before-block-id"], "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and local-media roles. Strong document spellings map to --node; --file-id and --url cannot safely choose a role. Before/after block spellings each require expansion into both --ref-block and --where, which name-only normalization cannot perform."}, "doc +media-list": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "The command lists media inside one document, so only strong document spellings map to --node. File and URL spellings remain role-ambiguous."}, "doc +media-preview": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and attachment-resource roles. Strong document spellings map to --node; --file-id and --url cannot safely choose a role."}, "doc +resource-delete": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command removes a document resource while targeting the document by --node. File and URL spellings do not uniquely identify that document role."}, "doc +resource-download": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command downloads a document resource while targeting the document by --node. File and URL spellings do not uniquely identify that document role."}, "doc +resource-update": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has document-node, local-file, and HTTPS image URL roles. Only strong document spellings map to --node; --file-id and --url must stop as ambiguous."}, "doc +share": {"block": ["doc", "doc-id", "document-id", "file-id", "id", "node", "node-id"], "note": "The real --url requires a shareable document link. Name-only normalization cannot turn a node identifier into a URL, so identifier spellings are rejected with guidance to provide --url."}, - "doc +grant-and-share": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node"}, "scope_strict": true, "note": "This workflow has two different real URL roles: --node selects the document for access control and --url is the shareable link sent to recipients. Explicit document-ID spellings map only to --node; --url remains native."} + "doc +grant-and-share": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node"}, "scope_strict": true, "note": "This workflow has two different real URL roles: --node selects the document for access control and --url is the shareable link sent to recipients. Explicit document-ID spellings map only to --node; --url remains native."}, + "doc +version-save": {"block": ["message", "title"], "scope_strict": true, "note": "The current snapshot API accepts only the document target. Version title/message metadata are unsupported and cannot be represented by another existing flag."}, + "drive copy": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "ambiguous": ["destination-id", "target-id"]}, + "drive cover": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]}, + "drive download-version": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "chunk-size": "part-size", "concurrency": "parallel", "parallelism": "parallel"}, "scope_strict": true, "note": "Output path, chunk size, and concurrency names preserve values; local input and remote folder roles remain blocked.", "block": ["dentry-id", "file", "file-path", "folder", "folder-id"]}, + "drive move": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "ambiguous": ["destination-id", "target-id"]}, + "drive permission add": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "member-user-ids": "users", "collaborator-ids": "users", "target-user-ids": "users", "target-node-id": "node"}, "scope_strict": true, "note": "The user list denotes target collaborators on this exact node permission command; the native singular compatibility spellings remain native.", "block": ["dentry-id", "dingdrive-space-id", "drive-space-id", "space-id", "storage-space-id"]}, + "drive permission apply": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "approver-user-ids": "users", "approver-ids": "users", "target-node-id": "node", "apply-reason": "reason", "notification-mode": "notify-mode"}, "scope_strict": true, "note": "The user list denotes approvers, not target collaborators; values and notification enum are passed unchanged.", "block": ["dentry-id"]}, + "drive permission apply-info": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]}, + "drive permission list": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "role-filter": "filter-role", "permission-role-filter": "filter-role", "target-node-id": "node"}, "scope_strict": true, "note": "Filtering by a role is not the same as granting/updating a role.", "block": ["dentry-id", "dingdrive-space-id", "drive-space-id", "permission", "role", "space-id", "storage-space-id"]}, + "drive permission remove": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "member-user-ids": "users", "collaborator-ids": "users", "target-user-ids": "users", "target-node-id": "node"}, "scope_strict": true, "note": "The user list denotes target collaborators on this exact node permission command; the native singular compatibility spellings remain native.", "block": ["dentry-id", "dingdrive-space-id", "drive-space-id", "space-id", "storage-space-id"]}, + "drive permission transfer-owner": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "target-node-id": "node", "target-workspace-id": "workspace", "old-owner-role": "reserve-role", "keep-role": "reserve-role", "new-owner-id": "new-owner", "new-owner-user-id": "new-owner"}, "scope_strict": true, "note": "Node/workspace target and new/old owner roles are distinct on this irreversible command; role-free names stop before dispatch.", "block": ["current-owner", "dentry-id", "dingdrive-space-id", "drive-space-id", "space-id", "storage-space-id", "user-ids", "users"], "ambiguous": ["owner", "owner-user-id", "target-id", "user-id"]}, + "drive permission update": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "member-user-ids": "users", "collaborator-ids": "users", "target-user-ids": "users", "target-node-id": "node"}, "scope_strict": true, "note": "The user list denotes target collaborators on this exact node permission command; the native singular compatibility spellings remain native.", "block": ["dentry-id", "dingdrive-space-id", "drive-space-id", "space-id", "storage-space-id"]}, + "drive publish get": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]}, + "drive publish set": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "public-permission": "permission", "public-role": "permission"}, "scope_strict": true, "note": "Internet-public permission is not the same as a direct collaborator role.", "block": ["access-role", "dentry-id", "permission-role", "role"]}, + "drive publish unset": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]}, + "drive rename": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "file-name": "name", "display-name": "name", "new-name": "name"}, "scope_strict": true, "note": "The name is this exact folder/node display name; search query and local path are different roles.", "block": ["dentry-id"]}, + "drive revert": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]}, + "drive shortcut": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "ambiguous": ["destination-id", "target-id"]}, + "drive star add": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]}, + "drive star remove": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]}, + "drive stats": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]}, + "drive +cover": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "This shortcut calls the same get_cover nodeId interface as drive cover, so its reviewed ID/URL aliases are preserved unchanged. Numeric dentryId, folder-role spellings and --name remain protected."}, + "drive +copy": {"scoped_aliases": {"dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "document-url", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "ambiguous": ["destination-id", "target-id"]}, + "drive +create-folder": {"scoped_aliases": {"directory-id": "folder", "parent-directory-id": "folder", "parent-folder-id": "folder", "folder-name": "name", "display-name": "name", "new-name": "name"}, "block": ["dentry-id", "parent-id", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "The new shortcut creates a numeric DingDrive-space folder. Folder display name, parent dentryUuid and numeric storage space are separate roles; knowledge-base workspace spellings are not accepted."}, + "drive +create-shortcut": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "doc", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "scope_strict": true, "note": "Source node and destination folder/workspace are different roles. The source ID/URL aliases match drive shortcut and pass through unchanged; generic target/id spellings remain ambiguous, and storage-space IDs are not knowledge-base workspace IDs.", "ambiguous": ["destination-id", "id", "target-id"]}, + "drive +delete": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "folder": "node", "folder-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "id", "name", "url"], "scope_strict": true, "note": "Delete targets one already confirmed Drive file or folder dentryUuid. Folder spellings are the same single target role; numeric dentryId, unreviewed Doc URL spellings and --name stop before confirmation or write dispatch."}, + "drive +download": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "file", "file-path", "folder", "folder-id", "id", "knowledge-base-id", "name", "url", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "The remote ordinary-file node and local output path are different roles. Node-ID spelling is accepted, while Doc URLs, local input paths, folders, knowledge-base workspaces and numeric dentryId values are not interchangeable."}, + "drive +info": {"scoped_aliases": {"dentry-uuid": "node"}, "block": ["dentry-id", "document-url", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "This command has only a numeric DingDrive space target; knowledge-base workspace spellings are a different value domain."}, + "drive +inspect": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "folder": "node", "folder-id": "node", "include-statistics": "include-stats", "include-publish-status": "include-publish", "include-public-status": "include-publish", "include-thumbnail": "include-cover"}, "block": ["dentry-id", "doc", "document-url", "id", "include", "include-content", "include-history", "include-permissions", "name", "url"], "scope_strict": true, "note": "Drive inspect accepts one file, folder or document node ID and can aggregate only stats, public-publish status and cover. URL spellings, Doc inspect section names and a generic --include remain protected because this shortcut does not declare URL input or value splitting."}, + "drive +list": {"ambiguous": ["root-id", "space"], "scoped_aliases": {"directory-id": "folder", "parent-folder-id": "folder", "order-field": "order-by", "sort-by": "order-by", "sort-field": "order-by"}, "block": ["dentry-id", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "This shortcut accepts one numeric DingDrive space and an optional parent dentryUuid; it does not accept a knowledge-base workspace. Sort field and direction remain separate roles."}, + "drive +move": {"scoped_aliases": {"dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "document-url", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "ambiguous": ["destination-id", "target-id"]}, + "drive +publish-get": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same publication-status fileId interface as drive publish get, so its reviewed ID/URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected."}, + "drive +publish-unset": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same set_file_publish fileId interface as drive publish unset, so its reviewed ID/URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected before confirmation."}, + "drive +recycle-restore": {"bind": {"id": "drive_recycle_item_id"}, "block": ["file-id", "folder-id", "node", "node-id", "space-id", "workspace-id"], "scope_strict": true, "note": "The real --id is a recycleItemId returned by drive +recycle-list, not a normal Drive node ID."}, + "drive +rename": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node", "folder": "node", "folder-id": "node", "file-name": "name", "display-name": "name", "new-name": "name"}, "block": ["dentry-id"], "scope_strict": true, "note": "The existing document, file or folder node and the requested new display name are separate required roles. Node ID/URL aliases match drive rename and pass through unchanged; numeric dentryId remains protected."}, + "drive delete": {"scoped_aliases": {"dentry-uuid": "node"}, "block": ["dentry-id", "document-url"], "scope_strict": true, "note": "This command publicly accepts an ID-only Drive node; dentry spellings preserve the value and URL-specific spellings remain protected."}, + "drive download": {"scoped_aliases": {"dentry-uuid": "node", "chunk-size": "part-size", "concurrency": "parallel", "parallelism": "parallel"}, "block": ["dentry-id", "document-url", "file", "file-path", "folder", "folder-id", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "Output path, chunk size, and concurrency names preserve values; local input and remote folder roles remain blocked."}, + "drive info": {"scoped_aliases": {"dentry-uuid": "node"}, "block": ["dentry-id", "document-url", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "This command has only a numeric DingDrive space target; knowledge-base workspace spellings are a different value domain."}, + "drive commit": {"scoped_aliases": {"directory-id": "folder", "parent-directory-id": "folder", "upload-session-id": "upload-id", "size-bytes": "file-size", "name": "file-name", "display-name": "file-name", "filename": "file-name", "upload-name": "file-name"}, "scope_strict": true, "note": "Upload session, file name, file size in bytes, parent folder, and storage space remain separate roles.", "block": ["knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"]}, + "drive mkdir": {"scoped_aliases": {"directory-id": "folder", "parent-directory-id": "folder", "folder-name": "name", "display-name": "name", "new-name": "name"}, "scope_strict": true, "note": "The name is this exact folder/node display name; search query and local path are different roles.", "block": ["knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"]}, + "drive upload-info": {"scoped_aliases": {"directory-id": "folder", "parent-directory-id": "folder", "content-type": "mime-type", "size-bytes": "file-size", "name": "file-name", "display-name": "file-name", "filename": "file-name", "upload-name": "file-name"}, "scope_strict": true, "note": "File metadata aliases preserve MIME and byte units; file path and upload session are different stages.", "block": ["knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"]}, + "drive recycle list": {"block": ["knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "This command has only a numeric DingDrive space target; knowledge-base workspace spellings are a different value domain."}, + "drive search": {"ambiguous": ["end", "from", "start", "time-from", "time-to", "to", "types"], "block": ["offset", "page"], "scope_strict": true, "note": "Created/modified ranges and file/content type arrays are separate roles; generic time/type names are not guessed."}, + "drive +search": {"ambiguous": ["end", "from", "start", "time-from", "time-to", "to", "types"], "block": ["offset", "page"], "scope_strict": true, "note": "Created/modified ranges and file/content type arrays are separate roles; generic time/type names are not guessed."}, + "drive +star-add": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same mark_star nodeId interface as drive star add, so its reviewed document/node ID and URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected."}, + "drive +star-remove": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same unmark_star nodeId interface as drive star remove, so its reviewed document/node ID and URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected."}, + "drive +stats": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same get_node_stats nodeId interface as drive stats, so its reviewed ID/URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected."}, + "drive +version-download": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "file", "file-path", "folder", "folder-id", "id", "name", "url"], "scope_strict": true, "note": "Ordinary-file node, positive historical version number and local output path are three distinct roles; revision and Doc URL spellings must not be guessed."}, + "drive +version-get": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "folder", "folder-id", "id", "name", "url"], "scope_strict": true, "note": "This command reads one ordinary-file historical version by node ID and positive version number; document revision and URL roles are different."}, + "drive +version-history": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "folder", "folder-id", "id", "name", "url"], "scope_strict": true, "note": "This command pages versions for one ordinary-file Drive node; document URLs and numeric dentryId are not accepted."}, + "drive +version-revert": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "folder", "folder-id", "id", "name", "url"], "scope_strict": true, "note": "The high-risk revert targets one ordinary-file Drive node and a positive historical version number. Doc revisions and URLs must stop before confirmation or write dispatch."}, + "drive recycle restore": {"bind": {"id": "drive_recycle_item_id"}, "block": ["file-id", "folder-id", "node", "node-id", "space-id", "workspace-id"], "scope_strict": true, "note": "The real --id is a recycle-item ID from recycle list, not a normal Drive node ID."}, + "drive star list": {"ambiguous": ["type", "types"], "scope_strict": true, "note": "Content types and resource types are separate arrays; a generic type list cannot choose one.", "scoped_aliases": {"order-field": "order-by", "sort-by": "order-by", "sort-field": "order-by"}}, + "drive recent": {"ambiguous": ["type", "types"], "scope_strict": true, "note": "Creator type, operation type, and file type filters are distinct and keep their enum/list forms."}, + "drive +recent": {"ambiguous": ["type", "types"], "scope_strict": true, "note": "Creator type, operation type, and file type filters are distinct and keep their enum/list forms."}, + "drive +star-list": {"ambiguous": ["type", "types"], "scope_strict": true, "note": "The shortcut exposes only the API contentTypes filter. Generic type spellings may denote file extensions or node/resource types, so the current name-only layer must not guess the value domain."}, + "drive +upload": {"ambiguous": ["destination-id", "space", "target-id"], "scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "overwrite-node-id": "node", "directory-id": "folder", "parent-directory-id": "folder", "parent-folder-id": "folder", "target-folder-id": "folder", "source-file": "file", "local-file": "file", "file-path": "file", "content-type": "mime-type", "filename": "file-name", "name": "file-name", "display-name": "file-name", "upload-name": "file-name"}, "block": ["dentry-id", "document-url", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id", "output-path"], "scope_strict": true, "note": "Local source path, remote display name, MIME type, parent folder, numeric storage space and optional overwrite node are distinct roles. ID-suffixed file/node spellings denote the overwrite target; the shortcut does not expose a knowledge-base workspace route."} }, "validation_fixture": { @@ -243,7 +313,7 @@ {"command": "doc +export-get", "emitted": "node", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2}, {"command": "doc block delete", "emitted": "index", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2}, {"command": "doc block insert", "emitted": "before-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-multi-parameter-transform", "occ": 2}, - {"command": "drive info", "emitted": "workspace", "expect": "space-id", "via": "concept:space_id", "occ": 2}, + {"command": "drive info", "emitted": "workspace", "expect": "did-you-mean:blocked", "via": "guard:workspace-vs-numeric-storage-space", "occ": 2}, {"command": "mail folder update", "emitted": "folder-id", "expect": "id", "via": "override:bind(folder_id)", "occ": 2}, {"command": "report outbox list", "emitted": "template-type", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2}, {"command": "chat +group-members", "emitted": "group-name", "expect": "group", "via": "concept:group_name+bind"}, @@ -325,7 +395,7 @@ {"command": "doc +search", "emitted": "q", "expect": "query", "via": "concept:search_query"}, {"command": "doc +comment-list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size"}, {"command": "doc +list", "emitted": "page-token", "expect": "cursor", "via": "concept:page_cursor"}, - {"command": "doc +copy", "emitted": "workspace-id", "expect": "workspace", "via": "concept:space_id"}, + {"command": "doc +copy", "emitted": "workspace-id", "expect": "workspace", "via": "concept:workspace_id"}, {"command": "doc +copy", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"}, {"command": "doc +copy", "emitted": "parent-id", "expect": "did-you-mean:blocked", "via": "guard:doc-folder-value-domain"}, {"command": "doc +comment-reply", "emitted": "comment-id", "expect": "comment-key", "via": "concept:doc_comment_key"}, @@ -415,9 +485,9 @@ {"command": "doc +create", "emitted": "content-format", "expect": "doc-format", "via": "concept:doc_content_format"}, {"command": "doc +create", "emitted": "content-file", "expect": "did-you-mean:blocked", "via": "guard:requires-file-read-transform"}, {"command": "doc +inspect", "emitted": "include-versions", "expect": "include-history", "via": "override:scoped-section"}, - {"command": "doc +inspect", "emitted": "include", "expect": "did-you-mean:blocked", "via": "guard:requires-value-dependent-flag-expansion"}, - {"command": "doc +inspect", "emitted": "include-info", "expect": "did-you-mean:blocked", "via": "guard:base-info-always-returned"}, - {"command": "doc +update", "emitted": "mode", "expect": "command", "via": "override:scoped-operation"}, + {"command": "doc +inspect", "emitted": "include", "expect": "did-you-mean:blocked", "via": "guard:requires-value-dependent-flag-expansion", "occ": 1}, + {"command": "doc +inspect", "emitted": "include-info", "expect": "did-you-mean:blocked", "via": "guard:base-info-always-returned", "occ": 1}, + {"command": "doc +update", "emitted": "mode", "expect": "command", "via": "override:scoped-operation", "occ": 5}, {"command": "doc +update", "emitted": "revision", "expect": "expected-revision", "via": "concept:doc_edit_revision"}, {"command": "doc +update", "emitted": "version", "expect": "did-you-mean:blocked", "via": "guard:historical-version-vs-edit-revision"}, {"command": "doc +fetch", "emitted": "start-block", "expect": "start-block-id", "via": "override:scoped-boundary-role"}, @@ -425,11 +495,152 @@ {"command": "doc +access-grant", "emitted": "doc-id", "expect": "node", "via": "concept:doc_node_id"}, {"command": "doc +history-revert", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"}, {"command": "doc +create-from-template", "emitted": "keyword", "expect": "query", "via": "concept:search_query"}, - {"command": "doc +create-from-template", "emitted": "workspace-id", "expect": "workspace", "via": "concept:space_id"}, + {"command": "doc +create-from-template", "emitted": "workspace-id", "expect": "workspace", "via": "concept:workspace_id"}, {"command": "doc +create-from-template", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"}, {"command": "doc +media-download", "emitted": "file-id", "expect": "did-you-mean:ambiguous", "via": "guard:document-node-vs-attachment-resource-role"}, {"command": "doc +resource-update", "emitted": "url", "expect": "did-you-mean:ambiguous", "via": "guard:document-url-vs-image-url-role"}, - {"command": "doc +share", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:node-id-needs-url-conversion"} + {"command": "doc +share", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:node-id-needs-url-conversion"}, + {"command": "doc +copy", "emitted": "dentry-uuid", "expect": "node", "via": "concept:doc_node_id"}, + {"command": "doc info", "emitted": "dentry-id", "expect": "did-you-mean:blocked", "via": "guard:dentry-id-vs-dentry-uuid"}, + {"command": "doc create", "emitted": "knowledge-base-id", "expect": "workspace", "via": "concept:workspace_id"}, + {"command": "doc create", "emitted": "space-id", "expect": "did-you-mean:blocked", "via": "guard:storage-space-vs-workspace"}, + {"command": "drive list", "emitted": "knowledge-base-id", "expect": "workspace", "via": "concept:workspace_id"}, + {"command": "drive list", "emitted": "order-field", "expect": "order-by", "via": "override:scoped-sort-field"}, + {"command": "drive info", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-node-id"}, + {"command": "drive info", "emitted": "dentry-id", "expect": "did-you-mean:blocked", "via": "guard:dentry-id-vs-dentry-uuid"}, + {"command": "drive copy", "emitted": "target-folder-id", "expect": "folder", "via": "override:scoped-destination-folder"}, + {"command": "drive copy", "emitted": "target-id", "expect": "did-you-mean:ambiguous", "via": "guard:destination-role-required"}, + {"command": "drive search", "emitted": "created-after", "expect": "created-from", "via": "concept:created_time_start"}, + {"command": "drive search", "emitted": "created-before", "expect": "created-to", "via": "concept:created_time_end"}, + {"command": "drive search", "emitted": "modified-after", "expect": "modified-from", "via": "concept:drive_modified_time_start"}, + {"command": "drive search", "emitted": "modified-before", "expect": "modified-to", "via": "concept:drive_modified_time_end"}, + {"command": "drive search", "emitted": "creator-user-ids", "expect": "creator-uids", "via": "concept:creator_user_ids"}, + {"command": "drive permission add", "emitted": "permission-role", "expect": "role", "via": "concept:document_permission_role"}, + {"command": "drive permission list", "emitted": "role", "expect": "did-you-mean:blocked", "via": "guard:permission-role-vs-filter-role"}, + {"command": "drive upload", "emitted": "source-file", "expect": "file", "via": "override:scoped-local-file"}, + {"command": "doc +search", "emitted": "created-after", "expect": "created-from", "via": "concept:created_time_start"}, + {"command": "doc +search", "emitted": "creator-user-ids", "expect": "creator-uids", "via": "concept:creator_user_ids"}, + {"command": "doc +access-grant", "emitted": "permission-role", "expect": "role", "via": "concept:document_permission_role"}, + {"command": "doc +export", "emitted": "output-path", "expect": "output", "via": "concept:local_output_path"}, + {"command": "drive download", "emitted": "destination-path", "expect": "output", "via": "concept:local_output_path"}, + {"command": "drive download", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"}, + {"command": "drive recycle restore", "emitted": "recycle-item-id", "expect": "id", "via": "concept:drive_recycle_item_id"}, + {"command": "drive upload-info", "emitted": "size-bytes", "expect": "file-size", "via": "concept:drive_file_size_bytes"}, + {"command": "drive +info", "emitted": "drive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"}, + {"command": "drive commit", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"}, + {"command": "drive download", "emitted": "dingdrive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"}, + {"command": "drive info", "emitted": "drive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"}, + {"command": "drive list", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"}, + {"command": "drive list", "emitted": "sort-direction", "expect": "order", "via": "concept:drive_sort_direction"}, + {"command": "drive mkdir", "emitted": "dingdrive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"}, + {"command": "drive recycle list", "emitted": "drive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"}, + {"command": "drive upload", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"}, + {"command": "drive upload-info", "emitted": "dingdrive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"}, + {"command": "doc +access-grant", "emitted": "user", "expect": "did-you-mean:ambiguous", "via": "guard:collaborator-name-vs-id-value-domain", "occ": 6}, + {"command": "doc +access-grant", "emitted": "target-user", "expect": "did-you-mean:ambiguous", "via": "guard:collaborator-name-vs-id-value-domain", "occ": 1}, + {"command": "doc +access-grant", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:user-id-cannot-feed-name-resolver", "occ": 1}, + {"command": "doc +access-grant", "emitted": "user-ids", "expect": "did-you-mean:blocked", "via": "guard:user-id-cannot-feed-name-resolver", "occ": 1}, + {"command": "doc +access-grant", "emitted": "target-user-id", "expect": "did-you-mean:blocked", "via": "guard:user-id-cannot-feed-name-resolver"}, + {"command": "doc +access-grant", "emitted": "target-user-ids", "expect": "did-you-mean:blocked", "via": "guard:user-id-cannot-feed-name-resolver"}, + {"command": "doc +fetch", "emitted": "content-format", "expect": "did-you-mean:blocked", "via": "guard:content-format-vs-detail-contract", "occ": 3}, + {"command": "doc +fetch", "emitted": "doc-format", "expect": "did-you-mean:blocked", "via": "guard:content-format-vs-detail-contract", "occ": 1}, + {"command": "doc +fetch", "emitted": "range", "expect": "did-you-mean:blocked", "via": "guard:composite-range-needs-block-ids", "occ": 1}, + {"command": "doc +inspect", "emitted": "include-access", "expect": "include-permissions", "via": "override:scoped-permission-section", "occ": 3}, + {"command": "doc +inspect", "emitted": "include-member", "expect": "include-permissions", "via": "override:scoped-permission-section", "occ": 1}, + {"command": "doc +inspect", "emitted": "include-members", "expect": "include-permissions", "via": "override:scoped-permission-section", "occ": 2}, + {"command": "doc +inspect", "emitted": "include-meta", "expect": "did-you-mean:blocked", "via": "guard:base-metadata-already-returned", "occ": 2}, + {"command": "doc +inspect", "emitted": "include-metadata", "expect": "did-you-mean:blocked", "via": "guard:base-metadata-already-returned", "occ": 1}, + {"command": "doc +inspect", "emitted": "include-blocks", "expect": "did-you-mean:blocked", "via": "guard:content-read-belongs-to-fetch", "occ": 1}, + {"command": "doc +inspect", "emitted": "include-content", "expect": "did-you-mean:blocked", "via": "guard:content-read-belongs-to-fetch", "occ": 1}, + {"command": "doc +inspect", "emitted": "role", "expect": "did-you-mean:blocked", "via": "guard:permission-role-vs-document-node", "occ": 1}, + {"command": "doc +copy", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role", "occ": 2}, + {"command": "doc +export", "emitted": "wait", "expect": "did-you-mean:blocked", "via": "guard:workflow-wait-vs-max-polls", "occ": 1}, + {"command": "doc +media-insert", "emitted": "after-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-ref-block-and-where-expansion", "occ": 1}, + {"command": "doc +media-insert", "emitted": "before-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-ref-block-and-where-expansion"}, + {"command": "doc +update", "emitted": "content-file", "expect": "did-you-mean:blocked", "via": "guard:requires-file-read-transform", "occ": 1}, + {"command": "doc +update", "emitted": "element", "expect": "did-you-mean:ambiguous", "via": "guard:content-vs-block-vs-reference-role", "occ": 1}, + {"command": "doc update", "emitted": "stdin", "expect": "did-you-mean:blocked", "via": "guard:stdin-requires-content-dash-transform", "occ": 1}, + {"command": "doc +version-save", "emitted": "title", "expect": "did-you-mean:blocked", "via": "guard:unsupported-version-metadata", "occ": 1}, + {"command": "doc +version-save", "emitted": "message", "expect": "did-you-mean:blocked", "via": "guard:unsupported-version-metadata", "occ": 1}, + {"command": "drive +search", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 1}, + {"command": "contact +search-user", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:person-name-vs-search-query", "occ": 1}, + {"command": "drive copy", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"}, + {"command": "drive +copy", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"}, + {"command": "drive move", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"}, + {"command": "drive +move", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"}, + {"command": "drive shortcut", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"}, + {"command": "drive +cover", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"}, + {"command": "drive +cover", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-drive-node"}, + {"command": "drive +create-folder", "emitted": "folder-name", "expect": "name", "via": "override:scoped-folder-name"}, + {"command": "drive +create-folder", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"}, + {"command": "drive +create-shortcut", "emitted": "source-file-id", "expect": "node", "via": "override:scoped-source-node"}, + {"command": "drive +create-shortcut", "emitted": "target-folder-id", "expect": "folder", "via": "override:scoped-target-folder"}, + {"command": "drive +create-shortcut", "emitted": "target-workspace-id", "expect": "workspace", "via": "override:scoped-target-workspace"}, + {"command": "drive +create-shortcut", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-drive-node"}, + {"command": "drive +delete", "emitted": "file-id", "expect": "node", "via": "override:scoped-drive-node"}, + {"command": "drive +delete", "emitted": "dentry-id", "expect": "did-you-mean:blocked", "via": "guard:dentry-id-vs-node-id"}, + {"command": "drive +download", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"}, + {"command": "drive +download", "emitted": "destination-path", "expect": "output", "via": "concept:local_output_path"}, + {"command": "drive +inspect", "emitted": "include-statistics", "expect": "include-stats", "via": "override:scoped-inspect-section"}, + {"command": "drive +inspect", "emitted": "include-history", "expect": "did-you-mean:blocked", "via": "guard:doc-inspect-section"}, + {"command": "drive +list", "emitted": "folder-id", "expect": "folder", "via": "concept:folder_id"}, + {"command": "drive +list", "emitted": "page-size", "expect": "limit", "via": "concept:pagination_size"}, + {"command": "drive +list", "emitted": "next-token", "expect": "cursor", "via": "concept:page_cursor"}, + {"command": "drive +list", "emitted": "sort-direction", "expect": "order", "via": "concept:drive_sort_direction"}, + {"command": "drive +list", "emitted": "sort-by", "expect": "order-by", "via": "override:scoped-sort-field"}, + {"command": "drive +publish-get", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"}, + {"command": "drive +publish-unset", "emitted": "file-id", "expect": "node", "via": "override:scoped-drive-node"}, + {"command": "drive +recycle-list", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"}, + {"command": "drive +recycle-list", "emitted": "page-token", "expect": "cursor", "via": "concept:page_cursor"}, + {"command": "drive +recycle-restore", "emitted": "recycle-item-id", "expect": "id", "via": "override:bind(drive_recycle_item_id)"}, + {"command": "drive +recycle-restore", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:recycle-item-vs-node-id"}, + {"command": "drive +rename", "emitted": "file-id", "expect": "node", "via": "override:scoped-drive-node"}, + {"command": "drive +rename", "emitted": "new-name", "expect": "name", "via": "override:scoped-display-name"}, + {"command": "drive +star-add", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"}, + {"command": "drive +star-list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size"}, + {"command": "drive +star-list", "emitted": "types", "expect": "did-you-mean:ambiguous", "via": "guard:content-type-value-domain"}, + {"command": "drive +star-remove", "emitted": "file-id", "expect": "node", "via": "override:scoped-drive-node"}, + {"command": "drive +stats", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"}, + {"command": "drive +upload", "emitted": "source-file", "expect": "file", "via": "override:scoped-local-file"}, + {"command": "drive +upload", "emitted": "name", "expect": "file-name", "via": "override:scoped-remote-name"}, + {"command": "drive +upload", "emitted": "overwrite-node-id", "expect": "node", "via": "override:scoped-overwrite-node"}, + {"command": "drive +upload", "emitted": "workspace-id", "expect": "did-you-mean:blocked", "via": "guard:unsupported-workspace-route"}, + {"command": "drive +version-download", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"}, + {"command": "drive +version-download", "emitted": "save-path", "expect": "output", "via": "concept:local_output_path"}, + {"command": "drive +version-get", "emitted": "version-no", "expect": "version", "via": "concept:doc_version_number"}, + {"command": "drive +version-history", "emitted": "next-cursor", "expect": "cursor", "via": "concept:page_cursor"}, + {"command": "drive +version-history", "emitted": "page-size", "expect": "limit", "via": "concept:pagination_size"}, + {"command": "drive +version-revert", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"}, + {"command": "drive +version-revert", "emitted": "revision", "expect": "did-you-mean:blocked", "via": "guard:document-revision-vs-file-version"}, + {"command": "drive +cover", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"}, + {"command": "drive +create-shortcut", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"}, + {"command": "drive +delete", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"}, + {"command": "drive +download", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"}, + {"command": "drive +inspect", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"}, + {"command": "drive +publish-get", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"}, + {"command": "drive +publish-unset", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"}, + {"command": "drive +rename", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"}, + {"command": "drive +star-add", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"}, + {"command": "drive +star-remove", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"}, + {"command": "drive +stats", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"}, + {"command": "drive +upload", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"}, + {"command": "drive +version-download", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"}, + {"command": "drive +version-get", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"}, + {"command": "drive +version-history", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"}, + {"command": "drive +version-revert", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"}, + {"command": "drive +cover", "emitted": "url", "expect": "node", "via": "override:existing-command-parity"}, + {"command": "drive +create-shortcut", "emitted": "document-id", "expect": "node", "via": "override:existing-command-parity"}, + {"command": "drive +delete", "emitted": "folder-id", "expect": "node", "via": "override:single-folder-node-role"}, + {"command": "drive +inspect", "emitted": "document-id", "expect": "node", "via": "override:document-node-id"}, + {"command": "drive +inspect", "emitted": "folder-id", "expect": "node", "via": "override:single-folder-node-role"}, + {"command": "drive +publish-get", "emitted": "url", "expect": "node", "via": "override:existing-command-parity"}, + {"command": "drive +publish-unset", "emitted": "document-url", "expect": "node", "via": "override:existing-command-parity"}, + {"command": "drive +rename", "emitted": "document-id", "expect": "node", "via": "override:existing-command-parity"}, + {"command": "drive +rename", "emitted": "folder-id", "expect": "node", "via": "override:single-folder-node-role"}, + {"command": "drive +star-add", "emitted": "url", "expect": "node", "via": "override:existing-command-parity"}, + {"command": "drive +star-remove", "emitted": "doc-id", "expect": "node", "via": "override:existing-command-parity"}, + {"command": "drive +stats", "emitted": "document-url", "expect": "node", "via": "override:existing-command-parity"}, + {"command": "drive +upload", "emitted": "file-id", "expect": "node", "via": "override:overwrite-node-id-role"} ] } } From fd26152141d95ce72b93ae33080c4e76fb2cc213 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E8=B0=A8?= <2940480744@qq.com> Date: Wed, 12 Aug 2026 16:24:03 +0800 Subject: [PATCH 11/86] docs(release): note Doc and Drive parameter aliases --- .changes/doc-drive-param-aliases.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/doc-drive-param-aliases.md diff --git a/.changes/doc-drive-param-aliases.md b/.changes/doc-drive-param-aliases.md new file mode 100644 index 00000000..0bc4bdcb --- /dev/null +++ b/.changes/doc-drive-param-aliases.md @@ -0,0 +1,5 @@ +--- +category: Fixed +--- + +- **Doc and Drive parameter aliases** — normalizes reviewed identifier, pagination, path, version, and role synonyms while blocking ambiguous values before dispatch. From dacf1669353f63c6ef73c3872f18049b8660b8cc Mon Sep 17 00:00:00 2001 From: chichuan Date: Wed, 12 Aug 2026 16:34:10 +0800 Subject: [PATCH 12/86] fix(drive): require confirmation for folder sync writes --- internal/cli/schema_command_exclusions.go | 4 - internal/helpers/drive.go | 161 +++++++++- internal/helpers/drive_pull.go | 9 +- internal/helpers/drive_pull_test.go | 3 +- internal/helpers/drive_push_test.go | 3 +- .../helpers/drive_status_coverage_test.go | 17 +- internal/helpers/drive_sync.go | 20 +- .../helpers/drive_sync_confirmation_test.go | 290 ++++++++++++++++++ internal/helpers/drive_sync_coverage_test.go | 8 +- internal/helpers/drive_sync_edges_test.go | 6 +- internal/helpers/drive_sync_gaps_test.go | 111 +++++++ internal/helpers/drive_sync_test.go | 9 +- skills/mono/references/products/drive.md | 29 +- .../multi/dingtalk-drive/references/drive.md | 29 +- 14 files changed, 635 insertions(+), 64 deletions(-) create mode 100644 internal/helpers/drive_sync_confirmation_test.go create mode 100644 internal/helpers/drive_sync_gaps_test.go diff --git a/internal/cli/schema_command_exclusions.go b/internal/cli/schema_command_exclusions.go index 3fb2158f..328c1f42 100644 --- a/internal/cli/schema_command_exclusions.go +++ b/internal/cli/schema_command_exclusions.go @@ -99,10 +99,6 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{ "ding message receiver-status", "ding message send-by-message", "ding message send-personal", - "drive pull", - "drive push", - "drive status", - "drive sync", "mail allow-list add", "mail allow-list list", "mail allow-list remove", diff --git a/internal/helpers/drive.go b/internal/helpers/drive.go index 7fe1b064..b554a176 100644 --- a/internal/helpers/drive.go +++ b/internal/helpers/drive.go @@ -3311,9 +3311,12 @@ modified_time(--quick)逐文件比对。两侧各自递归遍历,rel_path --local-folder 对应路径(子目录自动创建),单向、文件级镜像。 已存在的本地文件按 --if-exists 处理: - overwrite 默认,总是下载覆盖(Drive 作为权威源) + skip 默认,安全:本地已存在则保持不动,只新增 smart 推荐增量同步:本地 modified_time 已 ≥ 远端时则跳过下载 - skip 本地已存在则保持不动 + overwrite 总是下载覆盖(Drive 作为权威源) + +该命令会写入本地文件系统,执行前需要用户确认;非交互环境先用 --dry-run +预览,确认后以相同参数追加 --yes 执行。 输出 summary(downloaded/skipped/failed)与逐文件 items。 若有文件下载失败,命令以非零退出码退出(error.type=partial_failure)。`, @@ -3325,7 +3328,7 @@ modified_time(--quick)逐文件比对。两侧各自递归遍历,rel_path drivePullCmd.Flags().String("local-folder", "", "本地文件夹绝对路径 (必填)") drivePullCmd.Flags().String("remote-folder", "", "钉盘文件夹 ID (dentryUuid) (必填)") drivePullCmd.Flags().String("space-id", "", "钉盘空间 ID,不传则使用「我的文件」(可选)") - drivePullCmd.Flags().String("if-exists", "overwrite", "本地文件已存在时的策略: overwrite|smart|skip (可选)") + drivePullCmd.Flags().String("if-exists", "skip", "本地文件已存在时的策略: skip|smart|overwrite;命令会写本地,执行需确认 (可选)") drivePushCmd := &cobra.Command{ Use: "push", @@ -3339,6 +3342,9 @@ modified_time(--quick)逐文件比对。两侧各自递归遍历,rel_path smart 增量同步:远端 modified_time 已 ≥ 本地时跳过,否则走覆盖路径 overwrite 覆盖远端同名文件 +该命令会写入钉盘,执行前需要用户确认;非交互环境先用 --dry-run 预览, +确认后以相同参数追加 --yes 执行。 + 输出 summary(uploaded/skipped/failed,uploaded 含新建与覆盖)与逐条 items (含 folder_created)。若有文件失败,命令以非零退出码退出,结构化结果仍在 stdout。`, Example: ` dws drive push --local-folder /abs/path/repo --remote-folder @@ -3349,7 +3355,7 @@ modified_time(--quick)逐文件比对。两侧各自递归遍历,rel_path drivePushCmd.Flags().String("local-folder", "", "本地文件夹绝对路径 (必填)") drivePushCmd.Flags().String("remote-folder", "", "钉盘目标文件夹 ID (dentryUuid) (必填)") drivePushCmd.Flags().String("space-id", "", "钉盘空间 ID,不传则使用「我的文件」(可选)") - drivePushCmd.Flags().String("if-exists", "skip", "远端文件已存在时的策略: skip|smart|overwrite (可选)") + drivePushCmd.Flags().String("if-exists", "skip", "远端文件已存在时的策略: skip|smart|overwrite;命令会写钉盘,执行需确认 (可选)") driveSyncCmd := &cobra.Command{ Use: "sync", @@ -3362,14 +3368,18 @@ modified_time(--quick)逐文件比对。两侧各自递归遍历,rel_path unchanged 两侧一致 → 不动 两侧都变更时的 --on-conflict 策略: - remote-wins 默认,拉取远端覆盖本地 + skip 默认,两侧都不动并保留两边内容 + remote-wins 拉取远端覆盖本地 local-wins 上传本地覆盖远端 keep-both 本地文件改名保留,再拉取远端到原路径 ask 交互式逐个询问 exact 模式下远端无可靠 MD5、内容无法核对的文件归入 unknown 并跳过(可改用 --quick)。 文件级同步——只新增/覆盖,不删除任何一侧的多余文件。输出 summary(pulled/pushed/ -skipped/failed)、diff 与逐条 items;有失败则以非零退出码退出,结构化结果仍在 stdout。`, +skipped/failed)、diff 与逐条 items;有失败则以非零退出码退出,结构化结果仍在 stdout。 + +该命令会同时写入本地与钉盘,执行前需要用户确认;非交互环境先用 --dry-run +预览,确认后以相同参数追加 --yes 执行。`, Example: ` dws drive sync --local-folder /abs/path/repo --remote-folder dws drive sync --local-folder /abs/path/repo --remote-folder --on-conflict local-wins dws drive sync --local-folder /abs/path/repo --remote-folder --quick --on-conflict keep-both`, @@ -3378,9 +3388,146 @@ skipped/failed)、diff 与逐条 items;有失败则以非零退出码退出 driveSyncCmd.Flags().String("local-folder", "", "本地文件夹绝对路径 (必填)") driveSyncCmd.Flags().String("remote-folder", "", "钉盘文件夹 ID (dentryUuid) (必填)") driveSyncCmd.Flags().String("space-id", "", "钉盘空间 ID,不传则使用「我的文件」(可选)") - driveSyncCmd.Flags().String("on-conflict", "remote-wins", "两侧都变更时的策略: remote-wins|local-wins|keep-both|ask (可选)") + driveSyncCmd.Flags().String("on-conflict", "skip", "两侧都变更时的策略: skip|remote-wins|local-wins|keep-both|ask;命令会写双端,执行需确认 (可选)") driveSyncCmd.Flags().Bool("quick", false, "快速模式:只比较 modified_time,不计算 MD5 (可选)") + DeclareLeafMetadata(driveStatusCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "unknown", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "drive", + Name: "folder_status", + CanonicalPath: "drive.folder_status", + CLIPath: "drive status", + PrimaryCLIPath: "drive status", + }, + Description: "比较本地文件夹与钉盘文件夹的差异,只读不落盘。", + Interface: &contract.InterfaceSpec{ + Mode: "composite", + Availability: "available", + Reason: "Reviewed composite workflow: the command recursively lists the remote folder through drive/list_files, walks the local tree, and compares both sides by MD5 or modification time; no single pinned RPC represents the diff.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "比较本地文件夹与钉盘文件夹的差异,只读不落盘。", + UseWhen: []string{"需要先看清本地与钉盘之间哪些文件新增、变更或一致,再决定拉取还是推送时"}, + AvoidWhen: []string{"只要单个文件的元数据用 drive info;要真正传输文件用 drive pull / push / sync"}, + Examples: []string{ + "dws drive status --local-folder /abs/path/repo --remote-folder ", + "dws drive status --local-folder /abs/path/repo --remote-folder --quick", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "local-folder", Required: boolPtr(true)}, + {Name: "remote-folder", Required: boolPtr(true)}, + }, + }, + }) + DeclareLeafMetadata(drivePullCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "high", + Confirmation: "user_required", Idempotency: "unknown", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "drive", + Name: "folder_pull", + CanonicalPath: "drive.folder_pull", + CLIPath: "drive pull", + PrimaryCLIPath: "drive pull", + }, + Description: "把钉盘文件夹单向镜像到本地;写操作需确认,默认跳过本地既有文件。", + Interface: &contract.InterfaceSpec{ + Mode: "composite", + Availability: "available", + Reason: "Reviewed composite workflow: the command recursively lists the remote folder through drive/list_files and then downloads each file through drive/download_file plus an HTTP GET into a temporary file committed by an atomic rename; no single pinned RPC represents the mirror.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "把钉盘文件夹单向镜像到本地;写操作需确认,默认跳过本地既有文件。", + UseWhen: []string{"需要把整个钉盘文件夹拉到本地目录时"}, + AvoidWhen: []string{"只下载单个文件用 drive download;要把本地推到钉盘用 drive push;要双向对齐用 drive sync"}, + Examples: []string{ + "dws drive pull --local-folder /abs/path/repo --remote-folder ", + "dws drive pull --local-folder /abs/path/repo --remote-folder --if-exists smart --dry-run", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "local-folder", Required: boolPtr(true)}, + {Name: "remote-folder", Required: boolPtr(true)}, + }, + }, + }) + DeclareLeafMetadata(drivePushCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "high", + Confirmation: "user_required", Idempotency: "unknown", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "drive", + Name: "folder_push", + CanonicalPath: "drive.folder_push", + CLIPath: "drive push", + PrimaryCLIPath: "drive push", + }, + Description: "把本地文件夹单向镜像到钉盘;写操作需确认,默认跳过远端既有文件。", + Interface: &contract.InterfaceSpec{ + Mode: "composite", + Availability: "available", + Reason: "Reviewed composite workflow: the command recursively lists the remote folder through drive/list_files, creates missing folders through drive/create_folder, and uploads each file through drive/get_upload_info plus an HTTP PUT and drive/commit_upload; no single pinned RPC represents the mirror.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "把本地文件夹单向镜像到钉盘;写操作需确认,默认跳过远端既有文件。", + UseWhen: []string{"需要把整个本地目录推送到钉盘文件夹时"}, + AvoidWhen: []string{"只上传单个文件用 drive upload;要把钉盘拉到本地用 drive pull;要双向对齐用 drive sync"}, + Examples: []string{ + "dws drive push --local-folder /abs/path/repo --remote-folder ", + "dws drive push --local-folder /abs/path/repo --remote-folder --if-exists smart --dry-run", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "local-folder", Required: boolPtr(true)}, + {Name: "remote-folder", Required: boolPtr(true)}, + }, + }, + }) + DeclareLeafMetadata(driveSyncCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "high", + Confirmation: "user_required", Idempotency: "unknown", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "drive", + Name: "folder_sync", + CanonicalPath: "drive.folder_sync", + CLIPath: "drive sync", + PrimaryCLIPath: "drive sync", + }, + Description: "本地与钉盘文件夹双向同步;写操作需确认,默认跳过双端冲突。", + Interface: &contract.InterfaceSpec{ + Mode: "composite", + Availability: "available", + Reason: "Reviewed composite workflow: the command computes the same diff as drive status and then resolves it in both directions through drive/download_file, drive/create_folder, drive/get_upload_info and drive/commit_upload according to --on-conflict; no single pinned RPC represents the bidirectional sync.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "本地与钉盘文件夹双向同步;写操作需确认,默认跳过双端冲突。", + UseWhen: []string{"需要让本地目录与钉盘文件夹互相补齐时"}, + AvoidWhen: []string{"只需单方向镜像用 drive pull / push;只想看差异用 drive status"}, + Examples: []string{ + "dws drive sync --local-folder /abs/path/repo --remote-folder ", + "dws drive sync --local-folder /abs/path/repo --remote-folder --on-conflict remote-wins --dry-run", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "local-folder", Required: boolPtr(true)}, + {Name: "remote-folder", Required: boolPtr(true)}, + }, + }, + }) + driveCmd.AddCommand( driveListCmd, driveListSpacesCmd, diff --git a/internal/helpers/drive_pull.go b/internal/helpers/drive_pull.go index 3e1d875e..9671cf88 100644 --- a/internal/helpers/drive_pull.go +++ b/internal/helpers/drive_pull.go @@ -29,9 +29,9 @@ import ( // --if-exists 的三种策略。 const ( - ifExistsOverwrite = "overwrite" // 默认:总是下载覆盖(Drive 为权威源) + ifExistsOverwrite = "overwrite" // 总是下载覆盖(Drive 为权威源) ifExistsSmart = "smart" // 推荐增量:本地 mtime 已 ≥ 远端 modified_time 则跳过 - ifExistsSkip = "skip" // 本地已存在则保持不动 + ifExistsSkip = "skip" // 默认:本地已存在则保持不动 ) // pull 动作分类。 @@ -143,7 +143,8 @@ func runDrivePull(cmd *cobra.Command, _ []string) error { ifExists, _ := cmd.Flags().GetString("if-exists") if ifExists == "" { - ifExists = ifExistsOverwrite + // 安全默认:不自动覆盖本地既有文件。 + ifExists = ifExistsSkip } switch ifExists { case ifExistsOverwrite, ifExistsSmart, ifExistsSkip: @@ -171,7 +172,7 @@ func runDrivePull(cmd *cobra.Command, _ []string) error { sort.Strings(relPaths) // 落盘前先按目标文件系统的路径等价规则做一次全局冲突检查:大小写不敏感 FS 上 - // A.txt 与 a.txt(或 NFC/NFD 异写)会落到同一本地文件,默认 overwrite 会顺序覆盖、 + // A.txt 与 a.txt(或 NFC/NFD 异写)会落到同一本地文件;overwrite 会顺序覆盖、 // 却把两项都报 downloaded 而静默丢文件。冲突的条目一律标记 failed、都不写入。 // 探针需要根目录存在,这里按 pull「自动创建目标根」的语义先建好。 if err := os.MkdirAll(absDir, 0o755); err != nil { diff --git a/internal/helpers/drive_pull_test.go b/internal/helpers/drive_pull_test.go index 032eb1d7..83233eca 100644 --- a/internal/helpers/drive_pull_test.go +++ b/internal/helpers/drive_pull_test.go @@ -145,7 +145,8 @@ func TestCrossPlatformCoverageDrivePull_nonexistentLocalRoot(t *testing.T) { root.PersistentFlags().Bool("dry-run", false, "") root.AddCommand(newDriveCommand()) - full := []string{"pull", "--local-folder", absDir, "--remote-folder", "ROOT"} + // pull/push 是 user_required 叶子,非交互环境需 --yes 才能越过统一确认门。 + full := []string{"pull", "--local-folder", absDir, "--remote-folder", "ROOT", "--yes"} os.Args = append([]string{"dws", "drive"}, full...) root.SetArgs(append([]string{"drive"}, full...)) diff --git a/internal/helpers/drive_push_test.go b/internal/helpers/drive_push_test.go index 809c0bdc..984dd582 100644 --- a/internal/helpers/drive_push_test.go +++ b/internal/helpers/drive_push_test.go @@ -194,7 +194,8 @@ func runDrivePushTest(t *testing.T, caller *driveSyncMockCaller, localDir string root.PersistentFlags().Bool("dry-run", false, "") root.AddCommand(newDriveCommand()) - full := append([]string{"push", "--local-folder", localDir, "--remote-folder", "ROOT"}, args...) + // pull/push 是 user_required 叶子,非交互环境需 --yes 才能越过统一确认门。 + full := append([]string{"push", "--local-folder", localDir, "--remote-folder", "ROOT", "--yes"}, args...) os.Args = append([]string{"dws", "drive"}, full...) root.SetArgs(append([]string{"drive"}, full...)) diff --git a/internal/helpers/drive_status_coverage_test.go b/internal/helpers/drive_status_coverage_test.go index 8c6471cb..9c14387d 100644 --- a/internal/helpers/drive_status_coverage_test.go +++ b/internal/helpers/drive_status_coverage_test.go @@ -58,9 +58,24 @@ func (m *driveScriptCaller) callsFor(tool string) []driveMCPCall { var errTestList = errors.New("list_files boom") -// runDriveCmd 以给定 caller 执行 `dws drive `,返回命令错误。 +// runDriveCmd 以给定 caller 执行 `dws drive `。 +// +// pull / push / sync 声明了 Safety.Confirmation = user_required,非交互环境下必须带 +// --yes 才能越过统一确认门;这里自动补上,让用例专注被测行为。要断言「未确认即拒绝」 +// 请改用 runDriveCmdWithoutConfirm。 func runDriveCmd(t *testing.T, caller edition.ToolCaller, args ...string) error { t.Helper() + switch { + case len(args) == 0: + case args[0] == "pull", args[0] == "push", args[0] == "sync": + args = append(args, "--yes") + } + return runDriveCmdWithoutConfirm(t, caller, args...) +} + +// runDriveCmdWithoutConfirm 原样执行,不补 --yes,用于验证确认门本身。 +func runDriveCmdWithoutConfirm(t *testing.T, caller edition.ToolCaller, args ...string) error { + t.Helper() prevDeps := deps prevOSArgs := os.Args diff --git a/internal/helpers/drive_sync.go b/internal/helpers/drive_sync.go index e86ad9a6..9019eafb 100644 --- a/internal/helpers/drive_sync.go +++ b/internal/helpers/drive_sync.go @@ -24,7 +24,8 @@ import ( // new_local 仅本地存在 → push(缺失的远端目录按需创建,再上传) // new_remote 仅钉盘存在 → pull(下载到本地对应路径) // modified 两侧都变更 → 按 --on-conflict 解决: -// remote-wins 默认:拉取远端覆盖本地 +// skip 默认:两侧都不动并保留两边内容 +// remote-wins :拉取远端覆盖本地 // local-wins :上传本地覆盖远端 // keep-both :本地改名保留,再拉取远端到原路径 // ask :交互式逐个询问 @@ -35,12 +36,13 @@ import ( // 退出码退出,结构化 summary + diff + items 仍打印在 stdout 上。 // ────────────────────────────────────────────────────────── -// --on-conflict 的四种冲突解决策略。 +// --on-conflict 的五种冲突解决策略。 const ( syncConflictLocalWins = "local-wins" // 上传本地覆盖远端 - syncConflictRemoteWins = "remote-wins" // 默认:拉取远端覆盖本地 + syncConflictRemoteWins = "remote-wins" // 拉取远端覆盖本地 syncConflictKeepBoth = "keep-both" // 本地改名保留,再拉取远端到原路径 syncConflictAsk = "ask" // 交互式逐个询问 + syncConflictSkip = "skip" // 默认:两侧都变更时什么都不做,两边内容都保留 ) // sync 动作分类(direction 标注每条记录属于哪个方向)。 @@ -112,12 +114,13 @@ func runDriveSync(cmd *cobra.Command, _ []string) error { onConflict, _ := cmd.Flags().GetString("on-conflict") if onConflict == "" { - onConflict = syncConflictRemoteWins + // 安全默认:两侧都变更时不擅自覆盖任何一侧。 + onConflict = syncConflictSkip } switch onConflict { - case syncConflictLocalWins, syncConflictRemoteWins, syncConflictKeepBoth, syncConflictAsk: + case syncConflictSkip, syncConflictLocalWins, syncConflictRemoteWins, syncConflictKeepBoth, syncConflictAsk: default: - return fmt.Errorf("--on-conflict 取值非法: %s(可选 local-wins|remote-wins|keep-both|ask)", onConflict) + return fmt.Errorf("--on-conflict 取值非法: %s(可选 skip|local-wins|remote-wins|keep-both|ask)", onConflict) } quick, _ := cmd.Flags().GetBool("quick") @@ -221,11 +224,14 @@ func runDriveSync(cmd *cobra.Command, _ []string) error { resolutions := make(map[string]string, len(modified)) for _, rel := range modified { strategy := onConflict - if strategy == syncConflictAsk { + switch strategy { + case syncConflictAsk: strategy, err = driveSyncAskConflict(rel) if err != nil { return err } + case syncConflictSkip: + strategy = "" // 与 ask 选择「跳过」同一条落地路径 } resolutions[rel] = strategy } diff --git a/internal/helpers/drive_sync_confirmation_test.go b/internal/helpers/drive_sync_confirmation_test.go new file mode 100644 index 00000000..25db77d4 --- /dev/null +++ b/internal/helpers/drive_sync_confirmation_test.go @@ -0,0 +1,290 @@ +package helpers + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// pull / push / sync 声明 Safety.Confirmation = user_required。本文件断言两件事: +// 1. 未确认(无 --yes、非 dry-run)时命令必须在任何写操作之前拒绝执行; +// 2. 明确确认(--yes)后才发出精确的工具调用并落盘。 +// status 是只读叶子(not_required),不受确认门约束。 + +// ────────────────────────────────────────────────────────── +// 1. 未确认即拒绝,且一个写操作都不发生 +// ────────────────────────────────────────────────────────── + +func TestCrossPlatformCoverageDriveSyncFamily_refusesWithoutConfirmation(t *testing.T) { + cases := []struct { + name string + args []string + setup func(t *testing.T, dir string) + listJSON string + }{ + { + name: "pull", + args: []string{"pull"}, + // 远端有文件、本地为空:即便是纯新增也必须先确认。 + listJSON: `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A","modifyTime":1}],"nextToken":""}}`, + }, + { + name: "push", + args: []string{"push"}, + setup: func(t *testing.T, dir string) { mustWrite(t, filepath.Join(dir, "a.txt"), "local") }, + listJSON: `{"result":{"items":[],"nextToken":""}}`, + }, + { + name: "sync", + args: []string{"sync", "--quick", "--on-conflict", "remote-wins"}, + setup: func(t *testing.T, dir string) { mustWrite(t, filepath.Join(dir, "a.txt"), "local") }, + listJSON: `{"result":{"items":[{"name":"b.txt","type":"file","fileId":"B","modifyTime":2}],"nextToken":""}}`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + if tc.setup != nil { + tc.setup(t, dir) + } + + caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, nth int) (string, error) { + switch tool { + case "list_files": + if nth > 0 { + return `{"result":{"items":[],"nextToken":""}}`, nil + } + return tc.listJSON, nil + case "download_file": + return `{"result":{"downloadUrl":"https://oss.example.com/get","headers":{}}}`, nil + case "get_upload_info": + return `{"result":{"resourceUrls":[{"url":"https://oss.example.com/put","headers":{}}],"uploadId":"U1"}}`, nil + } + return `{"result":{"fileId":"NEW"},"success":true}`, nil + }} + SetHTTPGetFile(func(_ context.Context, _ string, _ map[string]string, dest string) error { + t.Error("no download may happen before confirmation") + return os.WriteFile(dest, []byte("leaked"), 0o644) + }) + SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { + t.Error("no upload may happen before confirmation") + return nil + }) + t.Cleanup(func() { + SetHTTPGetFile(nil) + SetHTTPPutFile(nil) + }) + + args := append(append([]string{}, tc.args...), "--local-folder", dir, "--remote-folder", "ROOT") + err := runDriveCmdWithoutConfirm(t, caller, args...) + if err == nil { + t.Fatal("expected the command to refuse without --yes") + } + if !strings.Contains(err.Error(), "--yes") { + t.Errorf("error must tell the user to confirm with --yes, got %q", err.Error()) + } + // 拒绝必须发生在任何写工具调用之前。 + for _, tool := range []string{"download_file", "get_upload_info", "create_folder", "ln"} { + if got := caller.callsFor(tool); len(got) != 0 { + t.Errorf("%s must not be called before confirmation, got %v", tool, got) + } + } + // 本地目录不得新增任何文件。 + entries, _ := os.ReadDir(dir) + for _, e := range entries { + if e.Name() == "b.txt" || e.Name() == "a.txt" && tc.name == "pull" { + t.Errorf("%s must not be materialized before confirmation", e.Name()) + } + } + }) + } +} + +// --dry-run 无需 --yes 也能预览:这是让用户看清将发生什么的必要出口,且不写任何东西。 +func TestCrossPlatformCoverageDriveSyncFamily_dryRunNeedsNoConfirmation(t *testing.T) { + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "local.txt"), "l") + + caller := syncCaller(map[string]string{ + "ROOT": `{"result":{"items":[{"name":"remote.txt","type":"file","fileId":"R","modifyTime":2}],"nextToken":""}}`, + }) + caller.dryRun = true + SetHTTPGetFile(func(context.Context, string, map[string]string, string) error { + t.Error("dry-run must not download") + return nil + }) + SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { + t.Error("dry-run must not upload") + return nil + }) + t.Cleanup(func() { + SetHTTPGetFile(nil) + SetHTTPPutFile(nil) + }) + + if err := runDriveCmdWithoutConfirm(t, caller, "sync", + "--local-folder", dir, "--remote-folder", "ROOT", "--quick"); err != nil { + t.Fatalf("dry-run must not require confirmation: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "remote.txt")); !os.IsNotExist(err) { + t.Error("dry-run must not write remote files locally") + } +} + +// ────────────────────────────────────────────────────────── +// 2. 明确确认后才发出精确的工具调用 +// ────────────────────────────────────────────────────────── + +// sync --on-conflict remote-wins --yes:确认后只下载指定远端文件,且不上传。 +func TestCrossPlatformCoverageDriveSync_confirmedRemoteWinsIssuesExactCalls(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "f.txt") + mustWrite(t, p, "local-version") + local := readFileMTimeMillis(t, p) + withSyncTransport(t, "remote-version") + + caller := syncCaller(map[string]string{ + "ROOT": `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F","modifyTime":` + + differentMillis(local) + `}],"nextToken":""}}`, + }) + if err := runDriveCmdWithoutConfirm(t, caller, "sync", "--local-folder", dir, + "--remote-folder", "ROOT", "--quick", "--on-conflict", "remote-wins", "--yes"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + dl := caller.callsFor("download_file") + if len(dl) != 1 || dl[0].args["fileId"] != "F" { + t.Fatalf("expected exactly one download_file(fileId=F), got %v", dl) + } + if up := caller.callsFor("get_upload_info"); len(up) != 0 { + t.Errorf("remote-wins must not upload, got %v", up) + } + if b, _ := os.ReadFile(p); string(b) != "remote-version" { + t.Errorf("remote-wins must replace the local content, got %q", string(b)) + } +} + +// pull --if-exists overwrite --yes:确认后才 download_file(fileId=A) 并覆盖本地内容。 +func TestCrossPlatformCoverageDrivePull_confirmedOverwriteIssuesExactCalls(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "a.txt") + mustWrite(t, p, "local-old") + + caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":9}`) + SetHTTPGetFile(func(_ context.Context, _ string, _ map[string]string, dest string) error { + return os.WriteFile(dest, []byte("remote-new"), 0o644) + }) + t.Cleanup(func() { SetHTTPGetFile(nil) }) + + if err := runDriveCmdWithoutConfirm(t, caller, "pull", "--local-folder", dir, + "--remote-folder", "ROOT", "--if-exists", "overwrite", "--yes"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + dl := caller.callsFor("download_file") + if len(dl) != 1 || dl[0].args["fileId"] != "A" { + t.Fatalf("expected exactly one download_file(fileId=A), got %v", dl) + } + if b, _ := os.ReadFile(p); string(b) != "remote-new" { + t.Errorf("confirmed overwrite must replace the local file, got %q", string(b)) + } +} + +// push --if-exists overwrite --yes:确认后原地覆盖,必须带 overwriteFileId 且不带 parentId。 +func TestCrossPlatformCoverageDrivePush_confirmedOverwriteIssuesExactCalls(t *testing.T) { + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "a.txt"), "local") + withNoopPut(t) + + caller := pushOKCaller(map[string]string{ + "ROOT": `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A","modifyTime":1}],"nextToken":""}}`, + }) + if err := runDriveCmdWithoutConfirm(t, caller, "push", "--local-folder", dir, + "--remote-folder", "ROOT", "--if-exists", "overwrite", "--yes"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + up := caller.callsFor("get_upload_info") + if len(up) != 1 || up[0].args["overwriteFileId"] != "A" { + t.Fatalf("expected get_upload_info(overwriteFileId=A), got %v", up) + } + if _, has := up[0].args["parentId"]; has { + t.Error("in-place overwrite must not carry parentId") + } +} + +// sync --on-conflict local-wins --yes:确认后覆盖上传远端,且不下载。 +func TestCrossPlatformCoverageDriveSync_confirmedLocalWinsIssuesExactCalls(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "f.txt") + mustWrite(t, p, "local-version") + local := readFileMTimeMillis(t, p) + withSyncTransport(t, "unused") + + caller := syncCaller(map[string]string{ + "ROOT": `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F","modifyTime":` + + differentMillis(local) + `}],"nextToken":""}}`, + }) + if err := runDriveCmdWithoutConfirm(t, caller, "sync", "--local-folder", dir, + "--remote-folder", "ROOT", "--quick", "--on-conflict", "local-wins", "--yes"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + up := caller.callsFor("get_upload_info") + if len(up) != 1 || up[0].args["overwriteFileId"] != "F" { + t.Fatalf("expected get_upload_info(overwriteFileId=F), got %v", up) + } + if dl := caller.callsFor("download_file"); len(dl) != 0 { + t.Errorf("local-wins must not download, got %v", dl) + } + if b, _ := os.ReadFile(p); string(b) != "local-version" { + t.Errorf("local-wins must keep the local content, got %q", string(b)) + } +} + +// ────────────────────────────────────────────────────────── +// 3. 安全默认值本身 +// ────────────────────────────────────────────────────────── + +// sync 默认 --on-conflict=skip:两侧都变更时两边内容都保留,且不发出任何传输调用。 +func TestCrossPlatformCoverageDriveSync_defaultSkipsConflictsEntirely(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "f.txt") + mustWrite(t, p, "local-version") + local := readFileMTimeMillis(t, p) + withSyncTransport(t, "remote-version") + + caller := syncCaller(map[string]string{ + "ROOT": `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F","modifyTime":` + + differentMillis(local) + `}],"nextToken":""}}`, + }) + if err := runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT", "--quick"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := len(caller.callsFor("download_file")) + len(caller.callsFor("get_upload_info")); got != 0 { + t.Errorf("default skip must not transfer anything, got %d calls", got) + } + if b, _ := os.ReadFile(p); string(b) != "local-version" { + t.Errorf("default skip must keep the local content, got %q", string(b)) + } +} + +// pull 默认 --if-exists=skip:本地已存在的文件不被覆盖。 +func TestCrossPlatformCoverageDrivePull_defaultSkipsExistingLocalFiles(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "a.txt") + mustWrite(t, p, "local-old") + + caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":9}`) + SetHTTPGetFile(func(context.Context, string, map[string]string, string) error { + t.Error("default skip must not download over an existing local file") + return nil + }) + t.Cleanup(func() { SetHTTPGetFile(nil) }) + + if err := runDriveCmd(t, caller, "pull", "--local-folder", dir, "--remote-folder", "ROOT"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if b, _ := os.ReadFile(p); string(b) != "local-old" { + t.Errorf("default skip must keep the local file, got %q", string(b)) + } +} diff --git a/internal/helpers/drive_sync_coverage_test.go b/internal/helpers/drive_sync_coverage_test.go index 15d04b30..bdf901da 100644 --- a/internal/helpers/drive_sync_coverage_test.go +++ b/internal/helpers/drive_sync_coverage_test.go @@ -52,8 +52,8 @@ func withSyncTransport(t *testing.T, body string) { // --on-conflict 取值处理 // ────────────────────────────────────────────────────────── -// 显式传空 --on-conflict 时回落默认 remote-wins(而非报非法取值)。 -func TestCrossPlatformCoverageDriveSync_emptyOnConflictDefaultsToRemoteWins(t *testing.T) { +// 显式传空 --on-conflict 时回落安全默认 skip:两侧都变更时两边内容都不动。 +func TestCrossPlatformCoverageDriveSync_emptyOnConflictDefaultsToSkip(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "f.txt") mustWrite(t, p, "local-old") @@ -68,8 +68,8 @@ func TestCrossPlatformCoverageDriveSync_emptyOnConflictDefaultsToRemoteWins(t *t "--quick", "--on-conflict", ""); err != nil { t.Fatalf("unexpected error: %v", err) } - if b, _ := os.ReadFile(p); string(b) != "remote-new" { - t.Errorf("empty --on-conflict must behave as remote-wins, got %q", string(b)) + if b, _ := os.ReadFile(p); string(b) != "local-old" { + t.Errorf("empty --on-conflict must default to skip and keep the local file, got %q", string(b)) } } diff --git a/internal/helpers/drive_sync_edges_test.go b/internal/helpers/drive_sync_edges_test.go index 84843247..7e57e703 100644 --- a/internal/helpers/drive_sync_edges_test.go +++ b/internal/helpers/drive_sync_edges_test.go @@ -47,7 +47,7 @@ func mustSetFlags(t *testing.T, cmd *cobra.Command, kv map[string]string) { // --if-exists 显式空值回落默认 // ────────────────────────────────────────────────────────── -func TestCrossPlatformCoverageDrivePull_emptyIfExistsDefaultsToOverwrite(t *testing.T) { +func TestCrossPlatformCoverageDrivePull_emptyIfExistsDefaultsToSkip(t *testing.T) { root := t.TempDir() p := filepath.Join(root, "a.txt") mustWrite(t, p, "local-old") @@ -60,8 +60,8 @@ func TestCrossPlatformCoverageDrivePull_emptyIfExistsDefaultsToOverwrite(t *test if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT", "--if-exists", ""); err != nil { t.Fatalf("unexpected error: %v", err) } - if b, _ := os.ReadFile(p); string(b) != "remote-new" { - t.Errorf("empty --if-exists must behave as overwrite, got %q", string(b)) + if b, _ := os.ReadFile(p); string(b) != "local-old" { + t.Errorf("empty --if-exists must default to skip and keep the local file, got %q", string(b)) } } diff --git a/internal/helpers/drive_sync_gaps_test.go b/internal/helpers/drive_sync_gaps_test.go new file mode 100644 index 00000000..5165c757 --- /dev/null +++ b/internal/helpers/drive_sync_gaps_test.go @@ -0,0 +1,111 @@ +package helpers + +import ( + "context" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +// 本文件补齐几条在默认策略改为 skip 后失去覆盖的边界分支。 + +// pull 自动创建目标根失败(--local-folder 指向一个常规文件)→ 明确报错,不落盘。 +func TestCrossPlatformCoverageDrivePull_localRootIsFileFails(t *testing.T) { + dir := t.TempDir() + asFile := filepath.Join(dir, "not-a-dir") + mustWrite(t, asFile, "x") + + caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":1}`) + err := runDriveCmd(t, caller, "pull", "--local-folder", asFile, "--remote-folder", "ROOT") + if err == nil || !strings.Contains(err.Error(), "创建本地目录失败") { + t.Fatalf("expected local root creation failure, got %v", err) + } +} + +// push 遍历远端时 list_files 在**子目录**层失败 → 错误如实上抛(walkRemoteForPush 的递归错误分支)。 +func TestCrossPlatformCoverageDrivePush_remoteSubdirListFailurePropagates(t *testing.T) { + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "a.txt"), "x") + withNoopPut(t) + + caller := &driveScriptCaller{reply: func(tool string, args map[string]any, _ int) (string, error) { + if tool != "list_files" { + return `{"result":{},"success":true}`, nil + } + switch args["parentId"] { + case "ROOT": + return `{"result":{"items":[{"name":"sub","type":"folder","fileId":"SUB"}],"nextToken":""}}`, nil + default: + return "", errTestList // 递归进入子目录后失败 + } + }} + if err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT"); err == nil { + t.Fatal("expected the nested list_files failure to propagate") + } +} + +// push 的远端索引跳过非 file 类型(在线文档/快捷方式),不把它们当成可覆盖的既有文件。 +func TestCrossPlatformCoverageDrivePush_remoteNonFileEntriesAreSkipped(t *testing.T) { + caller := &driveScriptCaller{reply: func(tool string, args map[string]any, _ int) (string, error) { + if tool != "list_files" { + return `{"result":{},"success":true}`, nil + } + if args["parentId"] == "ROOT" { + return `{"result":{"items":[` + + `{"name":"online.adoc","type":"document","fileId":"D1"},` + + `{"name":"link","type":"shortcut","fileId":"S1"},` + + `{"name":"real.txt","type":"file","fileId":"F1"}` + + `],"nextToken":""}}`, nil + } + return `{"result":{"items":[],"nextToken":""}}`, nil + }} + + prevDeps, prevArgs := deps, os.Args + deps = &Deps{Caller: caller, Out: &Formatter{w: io.Discard}} + os.Args = []string{"dws", "drive"} + t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs }) + + files, _, err := fetchRemoteTreeForPush(context.Background(), "", "ROOT") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := files["real.txt"]; !ok { + t.Error("regular file must be indexed") + } + for _, skipped := range []string{"online.adoc", "link"} { + if _, ok := files[skipped]; ok { + t.Errorf("non-file entry %q must not be indexed", skipped) + } + } +} + +// list_files 返回体没有 result 字段 → 视为空列表,而不是解析错误。 +func TestCrossPlatformCoverageParseDriveList_missingResultIsEmpty(t *testing.T) { + items, token, err := parseDriveList(`{"success":true}`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(items) != 0 || token != "" { + t.Errorf("parseDriveList = (%v, %q), want empty list", items, token) + } +} + +// push 遍历远端时返回体无法解析 → 解析错误如实上抛(与调用失败是不同分支)。 +func TestCrossPlatformCoverageDrivePush_remoteListParseFailurePropagates(t *testing.T) { + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "a.txt"), "x") + withNoopPut(t) + + caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, _ int) (string, error) { + if tool != "list_files" { + return `{"result":{},"success":true}`, nil + } + return `{"result":"not-a-list"}`, nil // 既不是 {items:[]} 也不是数组 + }} + err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT") + if err == nil || !strings.Contains(err.Error(), "list_files") { + t.Fatalf("expected a list_files parse error, got %v", err) + } +} diff --git a/internal/helpers/drive_sync_test.go b/internal/helpers/drive_sync_test.go index 6dab3f7b..e2221dac 100644 --- a/internal/helpers/drive_sync_test.go +++ b/internal/helpers/drive_sync_test.go @@ -90,7 +90,8 @@ func runDriveSyncTestWithGet(t *testing.T, caller *driveSyncMockCaller, getFn fu root.PersistentFlags().Bool("dry-run", false, "") root.AddCommand(newDriveCommand()) - full := append([]string{"sync", "--local-folder", localDir, "--remote-folder", "ROOT"}, args...) + // sync 是 user_required 叶子,非交互环境需 --yes 才能越过统一确认门。 + full := append([]string{"sync", "--local-folder", localDir, "--remote-folder", "ROOT", "--yes"}, args...) os.Args = append([]string{"dws", "drive"}, full...) root.SetArgs(append([]string{"drive"}, full...)) @@ -139,7 +140,7 @@ func TestCrossPlatformCoverageDriveSync_bidirectional(t *testing.T) { } } -// modified + remote-wins:quick 模式下两侧 mtime 不同判为 modified,默认拉取远端覆盖本地。 +// modified + remote-wins:quick 模式下两侧 mtime 不同判为 modified,并显式拉取远端覆盖本地。 func TestCrossPlatformCoverageDriveSync_modifiedRemoteWins(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "f.txt") @@ -151,7 +152,7 @@ func TestCrossPlatformCoverageDriveSync_modifiedRemoteWins(t *testing.T) { listJSON: `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F_FID","modifyTime":` + differentMillis(local) + `}],"nextToken":""}}`, } - if err := runDriveSyncTest(t, caller, dir, "--quick"); err != nil { + if err := runDriveSyncTest(t, caller, dir, "--quick", "--on-conflict", "remote-wins"); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -233,7 +234,7 @@ func TestCrossPlatformCoverageDriveSync_pullFailureKeepsLocal(t *testing.T) { failGet := func(context.Context, string, map[string]string, string) error { return errTestDownload } - err := runDriveSyncTestWithGet(t, caller, failGet, dir, "--quick") // 默认 remote-wins + err := runDriveSyncTestWithGet(t, caller, failGet, dir, "--quick", "--on-conflict", "remote-wins") if err == nil { t.Fatal("下载失败时应以非零退出码退出") } diff --git a/skills/mono/references/products/drive.md b/skills/mono/references/products/drive.md index 188e0936..bb4c56cd 100644 --- a/skills/mono/references/products/drive.md +++ b/skills/mono/references/products/drive.md @@ -395,7 +395,7 @@ Flags: ### 把钉盘文件夹拉取(镜像)到本地 -只写本地命令:把 `--remote-folder` 指向的钉盘文件夹**单向、文件级**镜像到本地 `--local-folder`(Drive → 本地)。递归下载所有 `type=file` 的文件,子目录自动创建。 +只写本地命令:把 `--remote-folder` 指向的钉盘文件夹**单向、文件级**镜像到本地 `--local-folder`(Drive → 本地)。递归下载所有 `type=file` 的文件,子目录自动创建。**执行前必须获得用户确认;非交互环境先用 `--dry-run` 预览,确认后再加 `--yes`。** ``` Usage: @@ -408,16 +408,16 @@ Flags: --local-folder string 本地文件夹绝对路径 (必填) --remote-folder string 钉盘文件夹 ID (dentryUuid) (必填) --space-id string 钉盘空间 ID,不传则使用「我的文件」(可选) - --if-exists string 本地文件已存在时的策略: overwrite|smart|skip (默认 overwrite) + --if-exists string 本地文件已存在时的策略: skip|smart|overwrite (默认 skip;命令写本地,执行需确认) ``` `--if-exists` 策略: | 值 | 行为 | |----|------| -| `overwrite`(默认) | 总是下载覆盖(Drive 作为权威源) | +| `skip`(默认) | 本地已存在则保持不动,只新增 | | `smart`(推荐增量同步) | 本地 `modified_time` 已 ≥ 远端 `modified_time` 则跳过;时间戳缺失/非法时退回安全路径继续下载 | -| `skip` | 本地已存在则保持不动 | +| `overwrite` | 总是下载覆盖(Drive 作为权威源) | 输出 schema: @@ -441,7 +441,7 @@ Flags: ### 把本地文件夹推送(镜像)到钉盘 -只写远端命令:把本地 `--local-folder` **单向、文件级**镜像到钉盘 `--remote-folder` 文件夹(本地 → Drive)。递归遍历本地文件与子目录(含空目录),缺失的远端目录按需创建(已存在则复用、不重建),文件按 `--if-exists` 新建/覆盖/跳过。**只新增/覆盖,不删除远端多余文件。** +只写远端命令:把本地 `--local-folder` **单向、文件级**镜像到钉盘 `--remote-folder` 文件夹(本地 → Drive)。递归遍历本地文件与子目录(含空目录),缺失的远端目录按需创建(已存在则复用、不重建),文件按 `--if-exists` 新建/覆盖/跳过。**执行前必须获得用户确认;非交互环境先用 `--dry-run` 预览,确认后再加 `--yes`。只新增/覆盖,不删除远端多余文件。** ``` Usage: @@ -449,15 +449,15 @@ Usage: Example: dws drive push --local-folder /abs/path/repo --remote-folder dws drive push --local-folder /abs/path/repo --remote-folder --if-exists smart - dws drive push --local-folder /abs/path/repo --remote-folder --if-exists overwrite + dws drive push --local-folder /abs/path/repo --remote-folder --if-exists overwrite --yes Flags: --local-folder string 本地文件夹绝对路径 (必填) --remote-folder string 钉盘目标文件夹 ID (dentryUuid) (必填) --space-id string 钉盘空间 ID,不传则使用「我的文件」(可选) - --if-exists string 远端文件已存在时的策略: skip|smart|overwrite (默认 skip) + --if-exists string 远端文件已存在时的策略: skip|smart|overwrite (默认 skip;命令写钉盘,执行需确认) ``` -`--if-exists` 策略(注意默认与 pull 相反,push 默认 `skip` 更安全): +`--if-exists` 策略(与 pull 一样默认 `skip`,避免未显式选择时覆盖既有文件): | 值 | 行为 | |----|------| @@ -489,7 +489,7 @@ Flags: ### 本地文件夹与钉盘文件夹双向同步 -读写命令:把本地 `--local-folder` 与钉盘 `--remote-folder` 做**文件级双向同步**。先按 `status` 同源逻辑算出五类差异,再分别处理:`new_remote` 下载到本地、`new_local` 上传到钉盘、两侧都变更的 `modified` 按 `--on-conflict` 策略消解;`unchanged` 与 `unknown` 一律跳过、不动。**只新增/覆盖,两侧都不删除多余文件。** +读写命令:把本地 `--local-folder` 与钉盘 `--remote-folder` 做**文件级双向同步**。**这是写操作,非交互环境下必须显式加 `--yes`;先用 `--dry-run` 看清将发生什么。**先按 `status` 同源逻辑算出五类差异,再分别处理:`new_remote` 下载到本地、`new_local` 上传到钉盘、两侧都变更的 `modified` 按 `--on-conflict` 策略消解;`unchanged` 与 `unknown` 一律跳过、不动。**只新增/覆盖,两侧都不删除多余文件。** ``` Usage: @@ -503,7 +503,7 @@ Flags: --local-folder string 本地文件夹绝对路径 (必填) --remote-folder string 钉盘文件夹 ID (dentryUuid) (必填) --space-id string 钉盘空间 ID,不传则使用「我的文件」(可选) - --on-conflict string 两侧都变更时的策略: remote-wins|local-wins|keep-both|ask (默认 remote-wins) + --on-conflict string 两侧都变更时的策略: skip|remote-wins|local-wins|keep-both|ask (默认 skip;命令写双端,执行需确认) --quick 快速模式:只比较 modified_time,不计算 MD5 (可选) ``` @@ -511,8 +511,9 @@ Flags: | 值 | 行为 | |----|------| -| `remote-wins`(默认) | 下载远端覆盖本地 | -| `local-wins` | 覆盖上传本地到远端(原地覆盖、保留 fileId) | +| `skip`(默认) | 两侧都不动,两边内容都保留,计入 `skipped` | +| `remote-wins` | 下载远端覆盖本地(需 `--yes`) | +| `local-wins` | 覆盖上传本地到远端(原地覆盖、保留 fileId;需 `--yes`) | | `keep-both` | 本地先改名保留副本(`名.conflict-.扩展名`),再把远端拉到原名;拉取失败会回滚改名、恢复原文件 | | `ask` | 逐个交互询问;`--dry-run` 或非交互环境下等价于跳过 | @@ -569,7 +570,7 @@ Flags: 用户说"比较本地和云盘/看哪些文件变了/同步差异/diff" → `status` 用户说"把钉盘文件夹拉到本地/下载整个文件夹/镜像/同步到本地/pull" → `pull` 用户说"把本地文件夹传到钉盘/推送整个文件夹/上传目录/同步到云端/push" → `push` -用户说"双向同步/两边同步/本地和云盘互相同步/让两边一致/sync" → `sync`(两侧都变更时用 `--on-conflict` 决定谁覆盖谁) +用户说"双向同步/两边同步/本地和云盘互相同步/让两边一致/sync" → `sync`(默认两侧都变更时跳过;要覆盖须显式给 `--on-conflict` 并加 `--yes`) 关键区分: drive(文件管理) vs doc(文档内容读写) vs wiki(空间管理) @@ -632,7 +633,7 @@ dws drive pull --local-folder /abs/path/repo --remote-folder --if-e dws drive push --local-folder /abs/path/repo --remote-folder --format json dws drive push --local-folder /abs/path/repo --remote-folder --if-exists smart --format json -# 12. 本地与钉盘双向同步(两侧都变更时用 --on-conflict 消解,默认 remote-wins) +# 12. 本地与钉盘双向同步(默认 --on-conflict=skip 两侧都不动;要覆盖须显式选策略并加 --yes) dws drive sync --local-folder /abs/path/repo --remote-folder --format json dws drive sync --local-folder /abs/path/repo --remote-folder --on-conflict keep-both --format json ``` diff --git a/skills/multi/dingtalk-drive/references/drive.md b/skills/multi/dingtalk-drive/references/drive.md index 0b35d622..87d99ed2 100644 --- a/skills/multi/dingtalk-drive/references/drive.md +++ b/skills/multi/dingtalk-drive/references/drive.md @@ -380,7 +380,7 @@ Flags: ### 把钉盘文件夹拉取(镜像)到本地 -只写本地命令:把 `--remote-folder` 指向的钉盘文件夹**单向、文件级**镜像到本地 `--local-folder`(Drive → 本地)。递归下载所有 `type=file` 的文件,子目录自动创建。 +只写本地命令:把 `--remote-folder` 指向的钉盘文件夹**单向、文件级**镜像到本地 `--local-folder`(Drive → 本地)。递归下载所有 `type=file` 的文件,子目录自动创建。**执行前必须获得用户确认;非交互环境先用 `--dry-run` 预览,确认后再加 `--yes`。** ``` Usage: @@ -393,16 +393,16 @@ Flags: --local-folder string 本地文件夹绝对路径 (必填) --remote-folder string 钉盘文件夹 ID (dentryUuid) (必填) --space-id string 钉盘空间 ID,不传则使用「我的文件」(可选) - --if-exists string 本地文件已存在时的策略: overwrite|smart|skip (默认 overwrite) + --if-exists string 本地文件已存在时的策略: skip|smart|overwrite (默认 skip;命令写本地,执行需确认) ``` `--if-exists` 策略: | 值 | 行为 | |----|------| -| `overwrite`(默认) | 总是下载覆盖(Drive 作为权威源) | +| `skip`(默认) | 本地已存在则保持不动,只新增 | | `smart`(推荐增量同步) | 本地 `modified_time` 已 ≥ 远端 `modified_time` 则跳过;时间戳缺失/非法时退回安全路径继续下载 | -| `skip` | 本地已存在则保持不动 | +| `overwrite` | 总是下载覆盖(Drive 作为权威源) | 输出 schema: @@ -426,7 +426,7 @@ Flags: ### 把本地文件夹推送(镜像)到钉盘 -只写远端命令:把本地 `--local-folder` **单向、文件级**镜像到钉盘 `--remote-folder` 文件夹(本地 → Drive)。递归遍历本地文件与子目录(含空目录),缺失的远端目录按需创建(已存在则复用、不重建),文件按 `--if-exists` 新建/覆盖/跳过。**只新增/覆盖,不删除远端多余文件。** +只写远端命令:把本地 `--local-folder` **单向、文件级**镜像到钉盘 `--remote-folder` 文件夹(本地 → Drive)。递归遍历本地文件与子目录(含空目录),缺失的远端目录按需创建(已存在则复用、不重建),文件按 `--if-exists` 新建/覆盖/跳过。**执行前必须获得用户确认;非交互环境先用 `--dry-run` 预览,确认后再加 `--yes`。只新增/覆盖,不删除远端多余文件。** ``` Usage: @@ -434,15 +434,15 @@ Usage: Example: dws drive push --local-folder /abs/path/repo --remote-folder dws drive push --local-folder /abs/path/repo --remote-folder --if-exists smart - dws drive push --local-folder /abs/path/repo --remote-folder --if-exists overwrite + dws drive push --local-folder /abs/path/repo --remote-folder --if-exists overwrite --yes Flags: --local-folder string 本地文件夹绝对路径 (必填) --remote-folder string 钉盘目标文件夹 ID (dentryUuid) (必填) --space-id string 钉盘空间 ID,不传则使用「我的文件」(可选) - --if-exists string 远端文件已存在时的策略: skip|smart|overwrite (默认 skip) + --if-exists string 远端文件已存在时的策略: skip|smart|overwrite (默认 skip;命令写钉盘,执行需确认) ``` -`--if-exists` 策略(注意默认与 pull 相反,push 默认 `skip` 更安全): +`--if-exists` 策略(与 pull 一样默认 `skip`,避免未显式选择时覆盖既有文件): | 值 | 行为 | |----|------| @@ -474,7 +474,7 @@ Flags: ### 本地文件夹与钉盘文件夹双向同步 -读写命令:把本地 `--local-folder` 与钉盘 `--remote-folder` 做**文件级双向同步**。先按 `status` 同源逻辑算出五类差异,再分别处理:`new_remote` 下载到本地、`new_local` 上传到钉盘、两侧都变更的 `modified` 按 `--on-conflict` 策略消解;`unchanged` 与 `unknown` 一律跳过、不动。**只新增/覆盖,两侧都不删除多余文件。** +读写命令:把本地 `--local-folder` 与钉盘 `--remote-folder` 做**文件级双向同步**。**这是写操作,非交互环境下必须显式加 `--yes`;先用 `--dry-run` 看清将发生什么。**先按 `status` 同源逻辑算出五类差异,再分别处理:`new_remote` 下载到本地、`new_local` 上传到钉盘、两侧都变更的 `modified` 按 `--on-conflict` 策略消解;`unchanged` 与 `unknown` 一律跳过、不动。**只新增/覆盖,两侧都不删除多余文件。** ``` Usage: @@ -488,7 +488,7 @@ Flags: --local-folder string 本地文件夹绝对路径 (必填) --remote-folder string 钉盘文件夹 ID (dentryUuid) (必填) --space-id string 钉盘空间 ID,不传则使用「我的文件」(可选) - --on-conflict string 两侧都变更时的策略: remote-wins|local-wins|keep-both|ask (默认 remote-wins) + --on-conflict string 两侧都变更时的策略: skip|remote-wins|local-wins|keep-both|ask (默认 skip;命令写双端,执行需确认) --quick 快速模式:只比较 modified_time,不计算 MD5 (可选) ``` @@ -496,8 +496,9 @@ Flags: | 值 | 行为 | |----|------| -| `remote-wins`(默认) | 下载远端覆盖本地 | -| `local-wins` | 覆盖上传本地到远端(原地覆盖、保留 fileId) | +| `skip`(默认) | 两侧都不动,两边内容都保留,计入 `skipped` | +| `remote-wins` | 下载远端覆盖本地(需 `--yes`) | +| `local-wins` | 覆盖上传本地到远端(原地覆盖、保留 fileId;需 `--yes`) | | `keep-both` | 本地先改名保留副本(`名.conflict-.扩展名`),再把远端拉到原名;拉取失败会回滚改名、恢复原文件 | | `ask` | 逐个交互询问;`--dry-run` 或非交互环境下等价于跳过 | @@ -561,7 +562,7 @@ Flags: 用户说"比较本地和云盘/看哪些文件变了/同步差异/diff" → `status` 用户说"把钉盘文件夹拉到本地/下载整个文件夹/镜像/同步到本地/pull" → `pull` 用户说"把本地文件夹传到钉盘/推送整个文件夹/上传目录/同步到云端/push" → `push` -用户说"双向同步/两边同步/本地和云盘互相同步/让两边一致/sync" → `sync`(两侧都变更时用 `--on-conflict` 决定谁覆盖谁) +用户说"双向同步/两边同步/本地和云盘互相同步/让两边一致/sync" → `sync`(默认两侧都变更时跳过;要覆盖须显式给 `--on-conflict` 并加 `--yes`) 用户说"查任务状态/导出好了没/任务进度/导入状态" → 文档导出用 `dws doc export get --job-id `,导入用 `dws doc import get --task-id ` 关键区分: drive(文件管理) vs doc(文档内容读写) vs wiki(空间管理) @@ -631,7 +632,7 @@ dws drive pull --local-folder /abs/path/repo --remote-folder --if-e dws drive push --local-folder /abs/path/repo --remote-folder --format json dws drive push --local-folder /abs/path/repo --remote-folder --if-exists smart --format json -# 13. 本地与钉盘双向同步(两侧都变更时用 --on-conflict 消解,默认 remote-wins) +# 13. 本地与钉盘双向同步(默认 --on-conflict=skip 两侧都不动;要覆盖须显式选策略并加 --yes) dws drive sync --local-folder /abs/path/repo --remote-folder --format json dws drive sync --local-folder /abs/path/repo --remote-folder --on-conflict keep-both --format json ``` From 9c14d9a6e15d5b1a0e090f9fbebef24fbf40f145 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Wed, 12 Aug 2026 16:42:27 +0800 Subject: [PATCH 13/86] fix(chat): repair message time defaults checks --- internal/helpers/chat.go | 1 - internal/helpers/chat_message_pagination_test.go | 8 +++++--- internal/helpers/im_read_result_contract_test.go | 1 + 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 3cc7a516..83bc0567 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -2700,7 +2700,6 @@ func newChatCommand() *cobra.Command { AvoidWhen: []string{"跨全部会话按时间查询时使用 chat message list-all"}, Examples: []string{ "dws chat message list --group --limit 50", - "dws chat message list --group --time \"2026-07-01 00:00:00\" --limit 50", "dws chat message list --group --time \"2026-07-01 00:00:00\" --limit 50 --jq '.messages[] | {messageId, text}'", }, }, diff --git a/internal/helpers/chat_message_pagination_test.go b/internal/helpers/chat_message_pagination_test.go index 2b6c28ab..2706b621 100644 --- a/internal/helpers/chat_message_pagination_test.go +++ b/internal/helpers/chat_message_pagination_test.go @@ -199,10 +199,12 @@ func TestChatMessagePaginationUsesDefaultTimeWindows(t *testing.T) { } startMs := numericArgAsInt64(t, got.args["startTime"]) endMs := numericArgAsInt64(t, got.args["endTime"]) - if endMs < before.UnixMilli() || endMs > after.Add(time.Second).UnixMilli() { - t.Fatalf("endTime = %d, want between %d and %d", endMs, before.UnixMilli(), after.Add(time.Second).UnixMilli()) + wantEndMin := before.Truncate(time.Second).UnixMilli() + wantEndMax := after.Add(time.Second).UnixMilli() + if endMs < wantEndMin || endMs > wantEndMax { + t.Fatalf("endTime = %d, want between %d and %d", endMs, wantEndMin, wantEndMax) } - wantStartMin := before.Add(-tt.wantLookback).UnixMilli() + wantStartMin := before.Add(-tt.wantLookback).Truncate(time.Second).UnixMilli() wantStartMax := after.Add(-tt.wantLookback).Add(time.Second).UnixMilli() if startMs < wantStartMin || startMs > wantStartMax { t.Fatalf("startTime = %d, want between %d and %d", startMs, wantStartMin, wantStartMax) diff --git a/internal/helpers/im_read_result_contract_test.go b/internal/helpers/im_read_result_contract_test.go index d2df5bc2..0d983925 100644 --- a/internal/helpers/im_read_result_contract_test.go +++ b/internal/helpers/im_read_result_contract_test.go @@ -21,6 +21,7 @@ import ( "io" "os" "reflect" + "strings" "testing" "time" From 07c5d25d5599152bc6ed36fba37cd9ab60ea0fc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E8=B0=A8?= <2940480744@qq.com> Date: Wed, 12 Aug 2026 17:14:23 +0800 Subject: [PATCH 14/86] fix(cli): cover doc search time aliases --- internal/app/param_alias_payload_equivalence_test.go | 3 +++ internal/cli/param_aliases_generated.go | 6 ++++++ internal/cli/param_concepts.json | 6 ++++-- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/internal/app/param_alias_payload_equivalence_test.go b/internal/app/param_alias_payload_equivalence_test.go index 064bc154..a4fe2e16 100644 --- a/internal/app/param_alias_payload_equivalence_test.go +++ b/internal/app/param_alias_payload_equivalence_test.go @@ -196,6 +196,7 @@ var paramAliasCompleteCommandVariants = map[string]map[string][]string{ }, "doc +search": { "created-from": {"doc", "+search", "--query", "fixture", "--created-from", "1"}, + "created-to": {"doc", "+search", "--query", "fixture", "--created-to", "2"}, "creator-uids": {"doc", "+search", "--query", "fixture", "--creator-uids", "user-1,user-2"}, }, "drive list": { @@ -392,6 +393,8 @@ var paramAliasRepresentativePayloadCases = map[string]bool{ paramAliasPayloadCaseKey("doc +fetch", "start-block"): true, // section boundary role remains exact paramAliasPayloadCaseKey("doc +history-revert", "version-number"): true, // destructive history version alias keeps confirmation paramAliasPayloadCaseKey("doc +inspect", "include-versions"): true, // boolean section alias preserves value + paramAliasPayloadCaseKey("doc +search", "create-time-start"): true, // observed lower-bound spelling preserves milliseconds + paramAliasPayloadCaseKey("doc +search", "create-time-end"): true, // observed upper-bound spelling preserves milliseconds paramAliasPayloadCaseKey("doc +template-list", "next-token"): true, // Doc cursor alias on a read shortcut paramAliasPayloadCaseKey("doc +update", "mode"): true, // write operation selector alias paramAliasPayloadCaseKey("doc +update", "revision"): true, // optimistic edit revision alias diff --git a/internal/cli/param_aliases_generated.go b/internal/cli/param_aliases_generated.go index 58ffe989..c7101d5d 100644 --- a/internal/cli/param_aliases_generated.go +++ b/internal/cli/param_aliases_generated.go @@ -2427,7 +2427,9 @@ var generatedParamAliases = []ParamAliasEntry{ { CLIPath: "doc +search", Aliases: map[string]string{ + "create-time-end": "created-to", "create-time-from": "created-from", + "create-time-start": "created-from", "create-time-to": "created-to", "created-after": "created-from", "created-before": "created-to", @@ -3212,7 +3214,9 @@ var generatedParamAliases = []ParamAliasEntry{ { CLIPath: "drive +search", Aliases: map[string]string{ + "create-time-end": "created-to", "create-time-from": "created-from", + "create-time-start": "created-from", "create-time-to": "created-to", "created-after": "created-from", "created-before": "created-to", @@ -3781,7 +3785,9 @@ var generatedParamAliases = []ParamAliasEntry{ { CLIPath: "drive search", Aliases: map[string]string{ + "create-time-end": "created-to", "create-time-from": "created-from", + "create-time-start": "created-from", "create-time-to": "created-to", "created-after": "created-from", "created-before": "created-to", diff --git a/internal/cli/param_concepts.json b/internal/cli/param_concepts.json index edc6a396..6a9433f1 100644 --- a/internal/cli/param_concepts.json +++ b/internal/cli/param_concepts.json @@ -47,8 +47,8 @@ "drive_file_size_bytes": {"denotes": "file size in bytes passed unchanged", "canonical_hint": "file-size", "members": ["file-size", "file-size-bytes", "size-bytes", "content-length"], "excludes": ["part-size", "page-size", "limit", "size"], "commands": ["drive commit", "drive upload-info"], "risk": "yellow"}, "drive_sort_direction": {"denotes": "Drive result sort direction with unchanged asc/desc enum", "canonical_hint": "order", "members": ["order", "sort", "sort-direction", "order-direction"], "excludes": ["order-by", "sort-by", "order-field"], "commands": ["drive +list", "drive list", "drive star list"], "risk": "green"}, "workspace_id": {"denotes": "single knowledge-base or document-space workspace ID/URL passed unchanged; never a numeric DingDrive storage space ID", "canonical_hint": "workspace", "members": ["workspace", "workspace-id", "knowledge-base-id", "wiki-workspace-id"], "excludes": ["space", "space-id", "drive-space-id", "storage-space-id", "dingdrive-space-id", "folder", "folder-id", "node", "node-id", "dentry-id"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +copy", "doc +create", "doc +create-from-template", "doc +grant-and-share", "doc +import", "doc +list", "doc +move", "doc create", "doc file create", "doc import", "doc template apply", "drive +copy", "drive +create-shortcut", "drive +move", "drive copy", "drive list", "drive move", "drive permission add", "drive permission list", "drive permission remove", "drive permission transfer-owner", "drive permission update", "drive shortcut", "drive upload"], "risk": "yellow"}, - "created_time_start": {"denotes": "Doc/Drive search created-time lower bound in unchanged millisecond timestamp unit", "canonical_hint": "created-from", "members": ["created-from", "created-after", "create-time-from", "created-time-start"], "excludes": ["created-to", "modified-from", "modified-to", "start", "from"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"}, - "created_time_end": {"denotes": "Doc/Drive search created-time upper bound in unchanged millisecond timestamp unit", "canonical_hint": "created-to", "members": ["created-to", "created-before", "create-time-to", "created-time-end"], "excludes": ["created-from", "modified-from", "modified-to", "end", "to"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"}, + "created_time_start": {"denotes": "Doc/Drive search created-time lower bound in unchanged millisecond timestamp unit", "canonical_hint": "created-from", "members": ["created-from", "created-after", "create-time-from", "created-time-start", "create-time-start"], "excludes": ["created-to", "modified-from", "modified-to", "start", "from"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"}, + "created_time_end": {"denotes": "Doc/Drive search created-time upper bound in unchanged millisecond timestamp unit", "canonical_hint": "created-to", "members": ["created-to", "created-before", "create-time-to", "created-time-end", "create-time-end"], "excludes": ["created-from", "modified-from", "modified-to", "end", "to"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"}, "document_permission_role": {"denotes": "direct document-space permission role on grant/apply/update, passed unchanged", "canonical_hint": "role", "members": ["role", "permission-role", "access-role", "member-role"], "excludes": ["filter-role", "reserve-role", "permission", "public-permission"], "commands": ["doc +access-change", "doc +access-grant", "doc +grant-and-share", "drive permission add", "drive permission apply", "drive permission update"], "risk": "yellow"}, "creator_user_ids": {"denotes": "creator userId list used to filter Doc/Drive search results, passed unchanged", "canonical_hint": "creator-uids", "members": ["creator-uids", "creator-user-ids", "creator-ids", "created-by-user-ids"], "excludes": ["user", "user-id", "user-ids", "users", "owner-id", "modifier-uids"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"}, "local_output_path": {"denotes": "local destination file or directory path for a download/export result, passed unchanged", "canonical_hint": "output", "members": ["output", "output-path", "destination-path", "save-path"], "excludes": ["file", "file-path", "folder", "folder-id", "content-file"], "commands": ["doc +export", "doc +export-get", "doc +media-download", "doc +resource-download", "doc read", "drive +download", "drive +version-download", "drive download", "drive download-version"], "risk": "green"} @@ -519,6 +519,8 @@ {"command": "drive permission list", "emitted": "role", "expect": "did-you-mean:blocked", "via": "guard:permission-role-vs-filter-role"}, {"command": "drive upload", "emitted": "source-file", "expect": "file", "via": "override:scoped-local-file"}, {"command": "doc +search", "emitted": "created-after", "expect": "created-from", "via": "concept:created_time_start"}, + {"command": "doc +search", "emitted": "create-time-start", "expect": "created-from", "via": "concept:created_time_start", "occ": 1}, + {"command": "doc +search", "emitted": "create-time-end", "expect": "created-to", "via": "concept:created_time_end", "occ": 1}, {"command": "doc +search", "emitted": "creator-user-ids", "expect": "creator-uids", "via": "concept:creator_user_ids"}, {"command": "doc +access-grant", "emitted": "permission-role", "expect": "role", "via": "concept:document_permission_role"}, {"command": "doc +export", "emitted": "output-path", "expect": "output", "via": "concept:local_output_path"}, From 5282a55a546cf62852e17064f5fd7451775fdef0 Mon Sep 17 00:00:00 2001 From: chichuan Date: Wed, 12 Aug 2026 17:24:54 +0800 Subject: [PATCH 15/86] test(drive): make MD5 failure coverage portable --- internal/helpers/drive_status.go | 5 +++-- internal/helpers/drive_sync_coverage_test.go | 19 +++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/internal/helpers/drive_status.go b/internal/helpers/drive_status.go index eaeb360a..a1a48c88 100644 --- a/internal/helpers/drive_status.go +++ b/internal/helpers/drive_status.go @@ -356,8 +356,9 @@ func verifyNoSymlinkEscape(absDir, target string) error { } // md5File 计算文件内容的 MD5,并按 base64 编码返回(与钉盘 list_files 返回的 -// md5 字段编码一致,便于直接字符串比较)。 -func md5File(path string) (string, error) { +// md5 字段编码一致,便于直接字符串比较)。变量形式只用于测试确定性注入读取失败; +// 测试必须通过 testseam.Swap 替换并自动恢复。 +var md5File = func(path string) (string, error) { f, err := os.Open(path) if err != nil { return "", err diff --git a/internal/helpers/drive_sync_coverage_test.go b/internal/helpers/drive_sync_coverage_test.go index bdf901da..1ab48574 100644 --- a/internal/helpers/drive_sync_coverage_test.go +++ b/internal/helpers/drive_sync_coverage_test.go @@ -130,24 +130,23 @@ func TestCrossPlatformCoverageDriveSync_unknownIsSkipped(t *testing.T) { } } -// exact 模式下本地不可读 → judgeFileMatch 的 MD5 失败要中止整个 sync。 +// exact 模式下本地 MD5 计算失败 → 整个 sync 中止。错误通过 seam 确定性注入, +// 避免依赖 chmod:Windows 不遵循 POSIX 的 000 权限语义。 func TestCrossPlatformCoverageDriveSync_md5FailureAborts(t *testing.T) { - if os.Geteuid() == 0 { - t.Skip("chmod-based unreadable file needs a non-root user") - } + testseam.Swap(t, &md5File, func(string) (string, error) { + return "", errors.New("md5 boom") + }) + dir := t.TempDir() p := filepath.Join(dir, "secret.txt") mustWrite(t, p, "data") - if err := os.Chmod(p, 0o000); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = os.Chmod(p, 0o644) }) caller := syncCaller(map[string]string{ "ROOT": `{"result":{"items":[{"name":"secret.txt","type":"file","fileId":"S","md5":"abc"}],"nextToken":""}}`, }) - if err := runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT"); err == nil { - t.Fatal("expected MD5 failure to abort sync") + err := runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT") + if err == nil || !strings.Contains(err.Error(), "MD5") { + t.Fatalf("expected MD5 failure to abort sync, got %v", err) } } From f2a3025f414825cacbbd1081b78d4d71894cf9ec Mon Sep 17 00:00:00 2001 From: chichuan Date: Wed, 12 Aug 2026 17:52:38 +0800 Subject: [PATCH 16/86] test(drive): cover Windows sync branches --- internal/helpers/drive_pull.go | 6 ++++- internal/helpers/drive_status.go | 6 ++++- .../helpers/drive_status_coverage_test.go | 26 ++++++++++++++----- internal/helpers/drive_sync.go | 13 +++++++--- internal/helpers/drive_sync_conflict_test.go | 21 +++++++-------- internal/helpers/drive_sync_edges_test.go | 20 +++++--------- internal/helpers/drive_sync_platform_test.go | 9 +++++++ 7 files changed, 63 insertions(+), 38 deletions(-) diff --git a/internal/helpers/drive_pull.go b/internal/helpers/drive_pull.go index 9671cf88..4231a507 100644 --- a/internal/helpers/drive_pull.go +++ b/internal/helpers/drive_pull.go @@ -34,6 +34,10 @@ const ( ifExistsSkip = "skip" // 默认:本地已存在则保持不动 ) +// pullCreateTemp 仅作为文件系统失败分支的确定性测试 seam;测试必须通过 +// testseam.Swap 替换并自动恢复。 +var pullCreateTemp = os.CreateTemp + // pull 动作分类。 const ( pullActionDownloaded = "downloaded" @@ -271,7 +275,7 @@ func pullOneFile(ctx context.Context, spaceID string, rf *remoteFile, localPath, // 先下载到同目录下的临时文件,完整落盘后再原子 rename 覆盖目标:任何中途失败 // (网络中断、超时、写盘错误)都不会截断或破坏已存在的原文件。同目录保证 // rename 在同一文件系统内、可原子替换;rename 会替换目标符号链接本身而非跟随它。 - tmp, err := os.CreateTemp(dir, ".dws-pull-*") + tmp, err := pullCreateTemp(dir, ".dws-pull-*") if err != nil { return pullActionFailed, fmt.Errorf("创建临时文件失败: %w", err) } diff --git a/internal/helpers/drive_status.go b/internal/helpers/drive_status.go index a1a48c88..cafe7f80 100644 --- a/internal/helpers/drive_status.go +++ b/internal/helpers/drive_status.go @@ -388,6 +388,10 @@ func walkLocalTree(root string) (map[string]*localFile, error) { return files, nil } +// runDriveStatusWalkLocalTree 仅作为 runDriveStatus 扫描失败分支的确定性测试 seam; +// 测试必须通过 testseam.Swap 替换并自动恢复。 +var runDriveStatusWalkLocalTree = walkLocalTree + // walkLocalTreeEntry 是 walkLocalTree 的单条目处理逻辑。抽成命名函数只为可测: // Info() / filepath.Rel 的失败在真实 WalkDir 下几乎不可复现,单测可直接注入。 func walkLocalTreeEntry(root, path string, d fs.DirEntry, err error, files map[string]*localFile) error { @@ -531,7 +535,7 @@ func runDriveStatus(cmd *cobra.Command, _ []string) error { return err } - local, err := walkLocalTree(absDir) + local, err := runDriveStatusWalkLocalTree(absDir) if err != nil { return fmt.Errorf("扫描本地目录失败: %w", err) } diff --git a/internal/helpers/drive_status_coverage_test.go b/internal/helpers/drive_status_coverage_test.go index 9c14387d..c1b53fd2 100644 --- a/internal/helpers/drive_status_coverage_test.go +++ b/internal/helpers/drive_status_coverage_test.go @@ -11,6 +11,7 @@ import ( "strings" "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" ) @@ -201,16 +202,12 @@ func TestCrossPlatformCoverageDriveStatus_unparsableListPropagates(t *testing.T) // exact 模式下本地文件不可读 → MD5 计算失败要作为错误上抛,不能静默当成一致。 func TestCrossPlatformCoverageDriveStatus_unreadableLocalFileFailsExactCompare(t *testing.T) { - if runtime.GOOS == "windows" || os.Geteuid() == 0 { - t.Skip("chmod-based unreadable file needs POSIX perms and a non-root user") - } dir := t.TempDir() p := filepath.Join(dir, "secret.txt") mustWrite(t, p, "data") - if err := os.Chmod(p, 0o000); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = os.Chmod(p, 0o644) }) + testseam.Swap(t, &md5File, func(string) (string, error) { + return "", errors.New("MD5 boom") + }) caller := &driveScriptCaller{reply: func(_ string, _ map[string]any, _ int) (string, error) { return `{"result":{"items":[{"name":"secret.txt","type":"file","fileId":"S","md5":"abc"}],"nextToken":""}}`, nil @@ -221,6 +218,21 @@ func TestCrossPlatformCoverageDriveStatus_unreadableLocalFileFailsExactCompare(t } } +func TestCrossPlatformCoverageDriveStatus_walkLocalFailurePropagates(t *testing.T) { + dir := t.TempDir() + testseam.Swap(t, &runDriveStatusWalkLocalTree, func(string) (map[string]*localFile, error) { + return nil, errors.New("walk boom") + }) + caller := &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) { + t.Error("local scan failure must abort before MCP calls") + return "", nil + }} + err := runDriveCmd(t, caller, "status", "--local-folder", dir, "--remote-folder", "ROOT") + if err == nil || !strings.Contains(err.Error(), "扫描本地目录失败") { + t.Fatalf("expected local scan failure, got %v", err) + } +} + // --local-folder 不是绝对路径 → 校验拦下,不发起任何 MCP 调用。 func TestCrossPlatformCoverageDriveStatus_relativeLocalFolderRejected(t *testing.T) { caller := &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) { diff --git a/internal/helpers/drive_sync.go b/internal/helpers/drive_sync.go index 9019eafb..79e06e4a 100644 --- a/internal/helpers/drive_sync.go +++ b/internal/helpers/drive_sync.go @@ -14,6 +14,13 @@ import ( "github.com/spf13/cobra" ) +// syncRename 与 syncOpenFile 仅作为文件系统失败分支的确定性测试 seam;测试必须 +// 通过 testseam.Swap 替换并自动恢复。 +var ( + syncRename = os.Rename + syncOpenFile = os.OpenFile +) + // ========================================================== // drive sync — 本地与钉盘双向同步(本地 ⇄ Drive) // ========================================================== @@ -415,7 +422,7 @@ func syncKeepBoth(res *driveSyncResult, ctx context.Context, spaceID string, rf } // newAbs 此刻是我们刚用 O_EXCL 新建的空占位文件,覆盖它不会丢用户数据;且已确保它 // 不与任何等价既有文件冲突。 - if err := os.Rename(oldAbs, newAbs); err != nil { + if err := syncRename(oldAbs, newAbs); err != nil { _ = os.Remove(newAbs) // 清理空占位,避免残留 res.Summary.Failed++ res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, Error: fmt.Sprintf("本地改名保留失败: %v", err)}) @@ -434,7 +441,7 @@ func syncKeepBoth(res *driveSyncResult, ctx context.Context, spaceID string, rf if perr != nil { msg = perr.Error() } - if rbErr := os.Rename(newAbs, oldAbs); rbErr != nil { + if rbErr := syncRename(newAbs, oldAbs); rbErr != nil { if msg != "" { msg += "; " } @@ -498,7 +505,7 @@ func reserveSyncKeepBothTarget(absDir, rel, fileID string, occupied map[string]b if err != nil { return "", "", err // 逃逸/符号链接等,直接失败 } - f, oerr := os.OpenFile(abs, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + f, oerr := syncOpenFile(abs, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) if oerr == nil { _ = f.Close() return cand, abs, nil diff --git a/internal/helpers/drive_sync_conflict_test.go b/internal/helpers/drive_sync_conflict_test.go index c57fd385..b3a98bb6 100644 --- a/internal/helpers/drive_sync_conflict_test.go +++ b/internal/helpers/drive_sync_conflict_test.go @@ -6,9 +6,10 @@ import ( "io" "os" "path/filepath" - "runtime" "strings" "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" ) // failFolderCaller 让指定名字的 create_folder 失败,其余一切正常, @@ -137,15 +138,11 @@ func TestCrossPlatformCoverageSyncKeepBoth_collidedIsFailed(t *testing.T) { // 占用改名目标失败(目录不可写)→ failed。 func TestCrossPlatformCoverageSyncKeepBoth_reserveFailureIsFailed(t *testing.T) { - if runtime.GOOS == "windows" || os.Geteuid() == 0 { - t.Skip("read-only dir enforcement needs POSIX perms and a non-root user") - } root := t.TempDir() mustWrite(t, filepath.Join(root, "f.txt"), "local") - if err := os.Chmod(root, 0o500); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = os.Chmod(root, 0o755) }) + testseam.Swap(t, &syncOpenFile, func(string, int, os.FileMode) (*os.File, error) { + return nil, errors.New("open file boom") + }) res := &driveSyncResult{} syncKeepBoth(res, context.Background(), "", &remoteFile{RelPath: "f.txt", FileID: "FID12345678"}, @@ -158,10 +155,10 @@ func TestCrossPlatformCoverageSyncKeepBoth_reserveFailureIsFailed(t *testing.T) // 改名本身失败(本地原文件其实是目录)→ failed,并清理刚建的空占位。 func TestCrossPlatformCoverageSyncKeepBoth_renameFailureCleansPlaceholder(t *testing.T) { root := t.TempDir() - // rel 指向一个非空目录:os.Rename(dir, emptyFile) 会失败。 - if err := os.MkdirAll(filepath.Join(root, "f.txt", "inner"), 0o755); err != nil { - t.Fatal(err) - } + mustWrite(t, filepath.Join(root, "f.txt"), "local") + testseam.Swap(t, &syncRename, func(string, string) error { + return errors.New("rename boom") + }) res := &driveSyncResult{} occupied := map[string]bool{} syncKeepBoth(res, context.Background(), "", &remoteFile{RelPath: "f.txt", FileID: "FID12345678"}, diff --git a/internal/helpers/drive_sync_edges_test.go b/internal/helpers/drive_sync_edges_test.go index 7e57e703..e2655b68 100644 --- a/internal/helpers/drive_sync_edges_test.go +++ b/internal/helpers/drive_sync_edges_test.go @@ -87,14 +87,10 @@ func TestCrossPlatformCoverageDrivePush_emptyIfExistsDefaultsToSkip(t *testing.T // 目标目录不可写 → 创建临时文件失败。 func TestCrossPlatformCoveragePullOneFile_tempFileCreationFailure(t *testing.T) { - if runtime.GOOS == "windows" || os.Geteuid() == 0 { - t.Skip("read-only dir enforcement needs POSIX perms and a non-root user") - } root := t.TempDir() - if err := os.Chmod(root, 0o500); err != nil { // r-x:可遍历不可写 - t.Fatal(err) - } - t.Cleanup(func() { _ = os.Chmod(root, 0o755) }) + testseam.Swap(t, &pullCreateTemp, func(string, string) (*os.File, error) { + return nil, errors.New("create temp boom") + }) prevDeps, prevArgs := deps, os.Args deps = &Deps{Caller: pullListingCaller(""), Out: &Formatter{w: io.Discard}} @@ -244,14 +240,10 @@ func TestCrossPlatformCoverageReserveSyncKeepBothTarget_escapeFails(t *testing.T // 候选目标所在目录不可写 → OpenFile 以非 EEXIST 错误失败,直接上报。 func TestCrossPlatformCoverageReserveSyncKeepBothTarget_unwritableDirFails(t *testing.T) { - if runtime.GOOS == "windows" || os.Geteuid() == 0 { - t.Skip("read-only dir enforcement needs POSIX perms and a non-root user") - } root := t.TempDir() - if err := os.Chmod(root, 0o500); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = os.Chmod(root, 0o755) }) + testseam.Swap(t, &syncOpenFile, func(string, int, os.FileMode) (*os.File, error) { + return nil, errors.New("open file boom") + }) if _, _, err := reserveSyncKeepBothTarget(root, "f.txt", "FID12345678", map[string]bool{}); err == nil { t.Fatal("expected unwritable directory to fail") diff --git a/internal/helpers/drive_sync_platform_test.go b/internal/helpers/drive_sync_platform_test.go index 71f9313b..2ab28887 100644 --- a/internal/helpers/drive_sync_platform_test.go +++ b/internal/helpers/drive_sync_platform_test.go @@ -102,6 +102,15 @@ func TestCrossPlatformCoverageIsSafeRemoteSegmentPlatform_allowsPlainNames(t *te } } +func TestCrossPlatformCoverageIsSafeRemoteSegmentPlatform_rejectsNonCanonicalWindowsName(t *testing.T) { + if runtime.GOOS != "windows" { + return + } + if isSafeRemoteSegmentPlatform(`dir\..\file.txt`) { + t.Error("Windows non-canonical segment must be rejected") + } +} + // ────────────────────────────────────────────────────────── // WalkDir 单条目处理:Info() 与 filepath.Rel 的失败分支 // ────────────────────────────────────────────────────────── From 388ae0d37be84e5a16bd3ad0aa8d5359efe0bab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E8=B0=A8?= <2940480744@qq.com> Date: Wed, 12 Aug 2026 17:59:54 +0800 Subject: [PATCH 17/86] ci: shard parameter alias changes --- .github/workflows/ci.yml | 6 ++++++ test/scripts/changelog_pr_gate_test.go | 14 +++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eace8f78..ba5d31ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -151,6 +151,12 @@ jobs: filename.startsWith('internal/helpers/') || filename.startsWith('internal/generator/') || filename.startsWith('internal/cli/schema') || + // Parameter aliases are reduced against the live command tree. + // Their reverse-dependency set is too large for one focused + // race job, so use the existing full-suite shards. + filename === 'internal/cli/param_concepts.json' || + filename === 'internal/cli/param_concepts.schema.json' || + filename === 'internal/cli/param_aliases_generated.go' || filename.startsWith('internal/interfacesnapshot/') || filename.startsWith('internal/app/upgrade') || filename.startsWith('internal/transport/') || diff --git a/test/scripts/changelog_pr_gate_test.go b/test/scripts/changelog_pr_gate_test.go index bf05855b..93a73f07 100644 --- a/test/scripts/changelog_pr_gate_test.go +++ b/test/scripts/changelog_pr_gate_test.go @@ -973,7 +973,7 @@ if (!isInterfaceSensitive("internal/corecmd/corecmd.go")) { } } -func TestHighRiskClassificationShardsHelperChanges(t *testing.T) { +func TestHighRiskClassificationShardsFanoutChanges(t *testing.T) { root, err := filepath.Abs(filepath.Join("..", "..")) if err != nil { t.Fatalf("Abs(repo root) error = %v", err) @@ -996,6 +996,18 @@ if (!isHighRisk("internal/helpers/minutes.go")) { if (isHighRisk("internal/helpersx/minutes.go")) { throw new Error("helper high-risk classification must respect the path boundary"); } +for (const filename of [ + "internal/cli/param_concepts.json", + "internal/cli/param_concepts.schema.json", + "internal/cli/param_aliases_generated.go", +]) { + if (!isHighRisk(filename)) { + throw new Error(filename + " must use the sharded full suite"); + } +} +if (isHighRisk("internal/cli/param_concepts.json.bak")) { + throw new Error("parameter-alias high-risk classification must use exact paths"); +} ` cmd := exec.Command("node", "-e", probe) if output, err := cmd.CombinedOutput(); err != nil { From 21c65819754041a129b31a4b9e91d40d0fe045ee Mon Sep 17 00:00:00 2001 From: chichuan Date: Wed, 12 Aug 2026 18:31:09 +0800 Subject: [PATCH 18/86] docs(drive): keep confirmation out of examples --- skills/mono/references/products/drive.md | 2 +- skills/multi/dingtalk-drive/references/drive.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/mono/references/products/drive.md b/skills/mono/references/products/drive.md index bb4c56cd..6ee96349 100644 --- a/skills/mono/references/products/drive.md +++ b/skills/mono/references/products/drive.md @@ -449,7 +449,7 @@ Usage: Example: dws drive push --local-folder /abs/path/repo --remote-folder dws drive push --local-folder /abs/path/repo --remote-folder --if-exists smart - dws drive push --local-folder /abs/path/repo --remote-folder --if-exists overwrite --yes + dws drive push --local-folder /abs/path/repo --remote-folder --if-exists overwrite Flags: --local-folder string 本地文件夹绝对路径 (必填) --remote-folder string 钉盘目标文件夹 ID (dentryUuid) (必填) diff --git a/skills/multi/dingtalk-drive/references/drive.md b/skills/multi/dingtalk-drive/references/drive.md index 87d99ed2..e9d26c24 100644 --- a/skills/multi/dingtalk-drive/references/drive.md +++ b/skills/multi/dingtalk-drive/references/drive.md @@ -434,7 +434,7 @@ Usage: Example: dws drive push --local-folder /abs/path/repo --remote-folder dws drive push --local-folder /abs/path/repo --remote-folder --if-exists smart - dws drive push --local-folder /abs/path/repo --remote-folder --if-exists overwrite --yes + dws drive push --local-folder /abs/path/repo --remote-folder --if-exists overwrite Flags: --local-folder string 本地文件夹绝对路径 (必填) --remote-folder string 钉盘目标文件夹 ID (dentryUuid) (必填) From e08fb484a8b74cdf8beaa5f5bca7fe563dc95a2c Mon Sep 17 00:00:00 2001 From: chichuan Date: Wed, 12 Aug 2026 19:02:56 +0800 Subject: [PATCH 19/86] fix(drive): make folder dry-run side-effect free --- internal/helpers/drive_pull.go | 56 +++++++ internal/helpers/drive_push.go | 69 ++++++++- internal/helpers/drive_push_test.go | 4 + internal/helpers/drive_status.go | 11 +- .../helpers/drive_status_coverage_test.go | 4 + .../helpers/drive_sync_confirmation_test.go | 144 ++++++++++++++++++ 6 files changed, 286 insertions(+), 2 deletions(-) diff --git a/internal/helpers/drive_pull.go b/internal/helpers/drive_pull.go index 4231a507..9a40f2db 100644 --- a/internal/helpers/drive_pull.go +++ b/internal/helpers/drive_pull.go @@ -65,6 +65,15 @@ type drivePullResult struct { Items []drivePullItem `json:"items"` } +type drivePullDryRunResult struct { + DryRun bool `json:"dry_run"` + Executed bool `json:"executed"` + PreviewKind string `json:"preview_kind"` + Operation string `json:"operation"` + IfExists string `json:"if_exists"` + Plan drivePullResult `json:"plan"` +} + // drivePartialFailure 在 summary.failed > 0 时返回:以 exit=1 退出,并把 // {"error":{"type":"partial_failure","detail":{summary,items}}} 原样透传出去。 // 结构上满足 core 的 RawStderrError 与 ExitCoder 接口(无需 import internal 包)。 @@ -174,6 +183,9 @@ func runDrivePull(cmd *cobra.Command, _ []string) error { relPaths = append(relPaths, rel) } sort.Strings(relPaths) + if deps.Caller.DryRun() { + return printDrivePullDryRun(absDir, ifExists, remote, relPaths) + } // 落盘前先按目标文件系统的路径等价规则做一次全局冲突检查:大小写不敏感 FS 上 // A.txt 与 a.txt(或 NFC/NFD 异写)会落到同一本地文件;overwrite 会顺序覆盖、 @@ -234,6 +246,50 @@ func runDrivePull(cmd *cobra.Command, _ []string) error { return deps.Out.PrintJSON(res) } +func printDrivePullDryRun(absDir, ifExists string, remote map[string]*remoteFile, relPaths []string) error { + plan := drivePullResult{Items: make([]drivePullItem, 0, len(relPaths))} + caseInsensitive := runtime.GOOS == "windows" || runtime.GOOS == "darwin" + collided := detectTargetCollisions(absDir, relPaths, caseInsensitive) + for _, rel := range relPaths { + if collided[rel] { + plan.Summary.Failed++ + plan.Items = append(plan.Items, drivePullItem{ + RelPath: rel, Action: pullActionFailed, + Error: "多个远端条目在目标平台默认文件系统上映射到同一本地路径,计划已拒绝", + }) + continue + } + localPath, err := resolveLocalTarget(absDir, rel) + if err != nil { + plan.Summary.Failed++ + plan.Items = append(plan.Items, drivePullItem{RelPath: rel, Action: pullActionFailed, Error: err.Error()}) + continue + } + action := pullActionDownloaded + if fi, statErr := os.Stat(localPath); statErr == nil && fi.Mode().IsRegular() { + switch ifExists { + case ifExistsSkip: + action = pullActionSkipped + case ifExistsSmart: + rf := remote[rel] + if rf.ModifiedTimeValid && fi.ModTime().UnixMilli() >= rf.ModifiedTime { + action = pullActionSkipped + } + } + } + if action == pullActionSkipped { + plan.Summary.Skipped++ + } else { + plan.Summary.Downloaded++ + } + plan.Items = append(plan.Items, drivePullItem{RelPath: rel, Action: action}) + } + return deps.Out.PrintJSON(drivePullDryRunResult{ + DryRun: true, Executed: false, PreviewKind: "plan", Operation: "drive pull", + IfExists: ifExists, Plan: plan, + }) +} + // pullOneFile 处理单个远端文件:按 --if-exists 决定是否跳过,否则下载到 localPath。 // 返回动作分类(downloaded / skipped / failed)及失败时的 error。 func pullOneFile(ctx context.Context, spaceID string, rf *remoteFile, localPath, ifExists string) (string, error) { diff --git a/internal/helpers/drive_push.go b/internal/helpers/drive_push.go index fcce42c5..741fa7eb 100644 --- a/internal/helpers/drive_push.go +++ b/internal/helpers/drive_push.go @@ -64,6 +64,15 @@ type drivePushResult struct { Items []drivePushItem `json:"items"` } +type drivePushDryRunResult struct { + DryRun bool `json:"dry_run"` + Executed bool `json:"executed"` + PreviewKind string `json:"preview_kind"` + Operation string `json:"operation"` + IfExists string `json:"if_exists"` + Plan drivePushResult `json:"plan"` +} + // drivePushFailure 在 summary.failed > 0 时返回:结构化结果已打印到 stdout, // 这里只负责以 exit=1 退出并向 stderr 输出一行简短说明。 type drivePushFailure struct{ failed int } @@ -111,6 +120,9 @@ func runDrivePush(cmd *cobra.Command, _ []string) error { } res := drivePushResult{Items: make([]drivePushItem, 0, len(localDirs)+len(localFiles))} + if deps.Caller.DryRun() { + return printDrivePushDryRun(ifExists, remoteFiles, remoteFolders, localDirs, localFiles) + } // 第一阶段:按需创建远端目录(浅层在前,保证父目录先于子目录存在)。 for _, dir := range localDirs { @@ -224,7 +236,7 @@ func walkRemoteForPush(ctx context.Context, spaceID, parentID, relBase string, f args["nextToken"] = nextToken } - text, err := callMCPToolReturnText(ctx, "list_files", args) + text, err := callDriveListFiles(ctx, args) if err != nil { return err } @@ -275,6 +287,61 @@ func walkRemoteForPush(ctx context.Context, spaceID, parentID, relBase string, f return nil } +func printDrivePushDryRun(ifExists string, remoteFiles map[string]*remoteFile, remoteFolders map[string]string, localDirs []string, localFiles []localPushFile) error { + plan := drivePushResult{Items: make([]drivePushItem, 0, len(localDirs)+len(localFiles))} + plannedFolders := make(map[string]string, len(remoteFolders)+len(localDirs)) + for rel, fileID := range remoteFolders { + plannedFolders[rel] = fileID + } + for _, dir := range localDirs { + if _, ok := plannedFolders[dir]; ok { + continue + } + parentRel, _ := splitRel(dir) + if plannedFolders[parentRel] == "" { + plan.Summary.Failed++ + plan.Items = append(plan.Items, drivePushItem{RelPath: dir, Action: pushActionFailed, Error: "父目录未能创建"}) + continue + } + plannedFolders[dir] = "dry-run-planned-folder" + plan.Items = append(plan.Items, drivePushItem{RelPath: dir, Action: pushActionFolderCreated}) + } + for _, lf := range localFiles { + size := lf.Size + parentRel, _ := splitRel(lf.RelPath) + if plannedFolders[parentRel] == "" { + plan.Summary.Failed++ + plan.Items = append(plan.Items, drivePushItem{RelPath: lf.RelPath, Action: pushActionFailed, SizeBytes: &size, Error: "父目录未能创建"}) + continue + } + action := pushActionUploaded + if rf, exists := remoteFiles[lf.RelPath]; exists { + switch ifExists { + case ifExistsSkip: + action = pushActionSkipped + case ifExistsSmart: + if rf.ModifiedTimeValid && rf.ModifiedTime >= lf.ModTimeMillis { + action = pushActionSkipped + } else { + action = pushActionOverwritten + } + case ifExistsOverwrite: + action = pushActionOverwritten + } + } + if action == pushActionSkipped { + plan.Summary.Skipped++ + } else { + plan.Summary.Uploaded++ + } + plan.Items = append(plan.Items, drivePushItem{RelPath: lf.RelPath, Action: action, SizeBytes: &size}) + } + return deps.Out.PrintJSON(drivePushDryRunResult{ + DryRun: true, Executed: false, PreviewKind: "plan", Operation: "drive push", + IfExists: ifExists, Plan: plan, + }) +} + // walkLocalForPush 遍历本地根目录,返回所有子目录 rel_path(不含根本身,浅层在前) // 与所有常规文件。dirs 升序排序保证父目录先于子目录被创建。 func walkLocalForPush(root string) ([]string, []localPushFile, error) { diff --git a/internal/helpers/drive_push_test.go b/internal/helpers/drive_push_test.go index 984dd582..c424e75f 100644 --- a/internal/helpers/drive_push_test.go +++ b/internal/helpers/drive_push_test.go @@ -163,6 +163,10 @@ func (m *driveSyncMockCaller) CallTool(_ context.Context, _ string, toolName str return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil } +func (m *driveSyncMockCaller) CallReadTool(ctx context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) { + return m.CallTool(ctx, productID, toolName, args) +} + func (m *driveSyncMockCaller) Format() string { return "raw" } func (m *driveSyncMockCaller) DryRun() bool { return m.dryRun } func (m *driveSyncMockCaller) Fields() string { return "" } diff --git a/internal/helpers/drive_status.go b/internal/helpers/drive_status.go index cafe7f80..b62afa8b 100644 --- a/internal/helpers/drive_status.go +++ b/internal/helpers/drive_status.go @@ -121,7 +121,7 @@ func walkRemoteDir(ctx context.Context, spaceID, parentID, relBase string, out m args["nextToken"] = nextToken } - text, err := callMCPToolReturnText(ctx, "list_files", args) + text, err := callDriveListFiles(ctx, args) if err != nil { return err } @@ -180,6 +180,15 @@ func walkRemoteDir(ctx context.Context, spaceID, parentID, relBase string, out m return nil } +// callDriveListFiles 让文件夹同步族在 --dry-run 下仍可通过受控只读通道获取 +// 远端现状;普通 CallTool 在 dry-run 下只会返回执行计划,不能被当作业务数据解析。 +func callDriveListFiles(ctx context.Context, args map[string]any) (string, error) { + if deps.Caller.DryRun() { + return callMCPReadToolReturnTextOnServer(ctx, "drive", "list_files", args) + } + return callMCPToolReturnText(ctx, "list_files", args) +} + // driveItem 是 list_files 返回里的一个 dentry 条目。 // 后端字段命名存在多种历史写法,这里用带 fallback 的访问器做防御式解析。 type driveItem map[string]any diff --git a/internal/helpers/drive_status_coverage_test.go b/internal/helpers/drive_status_coverage_test.go index c1b53fd2..79222ce0 100644 --- a/internal/helpers/drive_status_coverage_test.go +++ b/internal/helpers/drive_status_coverage_test.go @@ -42,6 +42,10 @@ func (m *driveScriptCaller) CallTool(_ context.Context, _ string, toolName strin return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil } +func (m *driveScriptCaller) CallReadTool(ctx context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) { + return m.CallTool(ctx, productID, toolName, args) +} + func (m *driveScriptCaller) Format() string { return "raw" } func (m *driveScriptCaller) DryRun() bool { return m.dryRun } func (m *driveScriptCaller) Fields() string { return "" } diff --git a/internal/helpers/drive_sync_confirmation_test.go b/internal/helpers/drive_sync_confirmation_test.go index 25db77d4..6f7cbbf2 100644 --- a/internal/helpers/drive_sync_confirmation_test.go +++ b/internal/helpers/drive_sync_confirmation_test.go @@ -1,6 +1,7 @@ package helpers import ( + "bytes" "context" "os" "path/filepath" @@ -134,6 +135,149 @@ func TestCrossPlatformCoverageDriveSyncFamily_dryRunNeedsNoConfirmation(t *testi } } +func TestCrossPlatformCoverageDrivePull_dryRunPlansWithoutLocalWrites(t *testing.T) { + parent := t.TempDir() + target := filepath.Join(parent, "not-created") + caller := pullListingCaller(`{"name":"remote.txt","type":"file","fileId":"R","modifyTime":2}`) + caller.dryRun = true + SetHTTPGetFile(func(context.Context, string, map[string]string, string) error { + t.Error("dry-run must not download") + return nil + }) + t.Cleanup(func() { SetHTTPGetFile(nil) }) + + var out bytes.Buffer + prevDeps, prevArgs := deps, os.Args + deps = &Deps{Caller: caller, Out: &Formatter{w: &out}} + os.Args = []string{"dws", "drive", "pull"} + t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs }) + cmd := findDriveSubcommand(t, "pull") + mustSetFlags(t, cmd, map[string]string{"local-folder": target, "remote-folder": "ROOT"}) + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("dry-run pull: %v", err) + } + if _, err := os.Stat(target); !os.IsNotExist(err) { + t.Fatalf("dry-run must not create local root: %v", err) + } + if got := len(caller.callsFor("download_file")); got != 0 { + t.Fatalf("download_file calls = %d, want 0", got) + } + if text := out.String(); !strings.Contains(text, `"dry_run": true`) || !strings.Contains(text, `"remote.txt"`) { + t.Fatalf("missing pull plan: %s", text) + } +} + +func TestCrossPlatformCoverageDrivePush_dryRunPlansWithoutRemoteWrites(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(dir, "sub", "local.txt"), "local") + caller := syncCaller(nil) + caller.dryRun = true + SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { + t.Error("dry-run must not upload") + return nil + }) + t.Cleanup(func() { SetHTTPPutFile(nil) }) + + var out bytes.Buffer + prevDeps, prevArgs := deps, os.Args + deps = &Deps{Caller: caller, Out: &Formatter{w: &out}} + os.Args = []string{"dws", "drive", "push"} + t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs }) + cmd := findDriveSubcommand(t, "push") + mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"}) + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("dry-run push: %v", err) + } + for _, tool := range []string{"create_folder", "get_upload_info", "commit_upload"} { + if got := len(caller.callsFor(tool)); got != 0 { + t.Fatalf("%s calls = %d, want 0", tool, got) + } + } + if text := out.String(); !strings.Contains(text, `"dry_run": true`) || !strings.Contains(text, `"sub/local.txt"`) { + t.Fatalf("missing push plan: %s", text) + } +} + +func TestCrossPlatformCoverageDrivePull_dryRunPlanBranches(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "skip.txt"), "skip") + mustWrite(t, filepath.Join(root, "smart.txt"), "smart") + remote := map[string]*remoteFile{ + "A.txt": {RelPath: "A.txt"}, + "a.txt": {RelPath: "a.txt"}, + "../escape": {RelPath: "../escape"}, + "skip.txt": {RelPath: "skip.txt"}, + "smart.txt": {RelPath: "smart.txt", ModifiedTime: 1, ModifiedTimeValid: true}, + "new.txt": {RelPath: "new.txt"}, + } + var out bytes.Buffer + prevDeps := deps + deps = &Deps{Out: &Formatter{w: &out}} + t.Cleanup(func() { deps = prevDeps }) + + if err := printDrivePullDryRun(root, ifExistsSkip, remote, + []string{"A.txt", "a.txt", "../escape", "skip.txt", "new.txt"}); err != nil { + t.Fatalf("skip plan: %v", err) + } + if text := out.String(); !strings.Contains(text, pullActionFailed) || !strings.Contains(text, pullActionSkipped) || !strings.Contains(text, pullActionDownloaded) { + t.Fatalf("skip plan did not cover failed/skipped/downloaded: %s", text) + } + out.Reset() + if err := printDrivePullDryRun(root, ifExistsSmart, remote, []string{"smart.txt"}); err != nil { + t.Fatalf("smart plan: %v", err) + } + if !strings.Contains(out.String(), pullActionSkipped) { + t.Fatalf("smart plan must skip newer local file: %s", out.String()) + } +} + +func TestCrossPlatformCoverageDrivePush_dryRunPlanBranches(t *testing.T) { + var out bytes.Buffer + prevDeps := deps + deps = &Deps{Out: &Formatter{w: &out}} + t.Cleanup(func() { deps = prevDeps }) + remoteFolders := map[string]string{"": "ROOT", "existing": "EXISTING"} + remoteFiles := map[string]*remoteFile{ + "skip.txt": {RelPath: "skip.txt"}, + "smart-skip.txt": {RelPath: "smart-skip.txt", ModifiedTime: 20, ModifiedTimeValid: true}, + "smart-overwrite.txt": {RelPath: "smart-overwrite.txt", ModifiedTime: 1, ModifiedTimeValid: true}, + "overwrite.txt": {RelPath: "overwrite.txt"}, + } + files := func(paths ...string) []localPushFile { + result := make([]localPushFile, 0, len(paths)) + for _, path := range paths { + result = append(result, localPushFile{RelPath: path, Size: 1, ModTimeMillis: 10}) + } + return result + } + + if err := printDrivePushDryRun(ifExistsSkip, remoteFiles, remoteFolders, + []string{"existing", "missing/child"}, files("orphan/a.txt", "skip.txt", "new.txt")); err != nil { + t.Fatalf("skip plan: %v", err) + } + if text := out.String(); !strings.Contains(text, pushActionFailed) || !strings.Contains(text, pushActionSkipped) || !strings.Contains(text, pushActionUploaded) { + t.Fatalf("skip plan did not cover failed/skipped/uploaded: %s", text) + } + out.Reset() + if err := printDrivePushDryRun(ifExistsSmart, remoteFiles, remoteFolders, nil, + files("smart-skip.txt", "smart-overwrite.txt")); err != nil { + t.Fatalf("smart plan: %v", err) + } + if text := out.String(); !strings.Contains(text, pushActionSkipped) || !strings.Contains(text, pushActionOverwritten) { + t.Fatalf("smart plan did not cover skip/overwrite: %s", text) + } + out.Reset() + if err := printDrivePushDryRun(ifExistsOverwrite, remoteFiles, remoteFolders, nil, files("overwrite.txt")); err != nil { + t.Fatalf("overwrite plan: %v", err) + } + if !strings.Contains(out.String(), pushActionOverwritten) { + t.Fatalf("overwrite plan must overwrite existing file: %s", out.String()) + } +} + // ────────────────────────────────────────────────────────── // 2. 明确确认后才发出精确的工具调用 // ────────────────────────────────────────────────────────── From b29a12abbf84d77014c0ea2ca4170909b09412c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E8=B0=A8?= <2940480744@qq.com> Date: Wed, 12 Aug 2026 19:05:11 +0800 Subject: [PATCH 20/86] test: harden parameter alias safety gates --- .github/workflows/ci.yml | 9 ++- .../param_alias_payload_equivalence_test.go | 72 +++++++++++++++++++ test/scripts/changelog_pr_gate_test.go | 24 ++++++- 3 files changed, 100 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba5d31ab..4707eb50 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -537,10 +537,13 @@ jobs: test -n "$package_output" mapfile -t packages <<< "$package_output" test "${#packages[@]}" -gt 0 - # cli/smoke own heavy NewRootCommand / Schema assembly under -race; give - # them a dedicated budget so remaining is not SIGTERM'd by OOM/timeout. + # app/cli/smoke own heavy NewRootCommand / Schema assembly under + # -race; give them a dedicated budget so slower hosted runners do not + # hit the package timeout while the shards remain independently bounded. timeout_budget=12m - if [ "$TEST_SHARD" = "cli" ] || [ "$TEST_SHARD" = "smoke" ]; then + if [ "$TEST_SHARD" = "app" ] || + [ "$TEST_SHARD" = "cli" ] || + [ "$TEST_SHARD" = "smoke" ]; then timeout_budget=15m fi go test -v -race -count=1 -timeout="$timeout_budget" "${packages[@]}" diff --git a/internal/app/param_alias_payload_equivalence_test.go b/internal/app/param_alias_payload_equivalence_test.go index a4fe2e16..eaa2a0b7 100644 --- a/internal/app/param_alias_payload_equivalence_test.go +++ b/internal/app/param_alias_payload_equivalence_test.go @@ -4,11 +4,13 @@ package app import ( + "errors" "reflect" "strings" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" ) @@ -365,6 +367,24 @@ var paramAliasNewDriveCases = []struct { {command: "drive +upload", emitted: "file-id", canonical: "node"}, } +// paramAliasNewDriveConfirmationCases selects one newly reviewed alias for +// every Drive command in the expansion whose declared runtime safety requires +// confirmation. The full matrix below proves all spellings preserve the +// confirmed payload; this smaller matrix proves aliases cannot cross the +// confirmation boundary before any transport call is made. +var paramAliasNewDriveConfirmationCases = []struct { + command string + emitted string + canonical string +}{ + {command: "drive +delete", emitted: "file-id", canonical: "node"}, + {command: "drive +publish-unset", emitted: "document-url", canonical: "node"}, + {command: "drive +recycle-restore", emitted: "recycle-item-id", canonical: "id"}, + {command: "drive +rename", emitted: "new-name", canonical: "name"}, + {command: "drive +upload", emitted: "source-file", canonical: "file"}, + {command: "drive +version-revert", emitted: "version-number", canonical: "version"}, +} + // paramAliasRepresentativePayloadCases keeps final transport coverage across // old concept aliases, command overrides, native compatibility flags, read and // write commands, and different products. Every reviewed alias is still @@ -602,6 +622,45 @@ func TestCrossPlatformCoverageNewDriveParamAliasesReachCanonicalEquivalentFinalP } } +func TestCrossPlatformCoverageNewDriveParamAliasesCannotBypassConfirmation(t *testing.T) { + for _, test := range paramAliasNewDriveConfirmationCases { + test := test + t.Run(test.command+"/"+test.emitted, func(t *testing.T) { + complete, ok := paramAliasCompleteCommand(test.command, test.canonical) + if !ok { + t.Fatal("reviewed Drive confirmation alias has no complete-command E2E template") + } + aliasArgs, replacements := replaceLongFlag(complete, test.canonical, test.emitted) + if replacements != 1 { + t.Fatalf("complete command must contain canonical --%s exactly once; replacements=%d args=%v", test.canonical, replacements, complete) + } + unconfirmedArgs, removals := removeExactArg(aliasArgs, "--yes") + if removals != 1 { + t.Fatalf("confirmation template must contain --yes exactly once; removals=%d args=%v", removals, aliasArgs) + } + + entry, exists := cli.LookupParamAlias(test.command) + target, active := entry.ResolveAlias(test.emitted) + if !exists || !active || target != test.canonical { + t.Fatalf("reviewed Drive alias --%s resolution = exists:%v active:%v target:%q, want --%s", test.emitted, exists, active, target, test.canonical) + } + + caller := ¶mAliasCaptureCaller{} + ctx, err := executeParamAliasPayloadE2E(t, caller, unconfirmedArgs...) + if ctx == nil { + t.Fatal("unconfirmed alias command skipped PreParse") + } + var appErr *apperrors.Error + if !errors.As(err, &appErr) || appErr.Reason != "confirmation_required" { + t.Fatalf("unconfirmed alias command error = %#v, want confirmation_required\nargs=%v", err, unconfirmedArgs) + } + if len(caller.calls) != 0 { + t.Fatalf("unconfirmed alias command crossed the transport boundary: args=%v calls=%#v", unconfirmedArgs, caller.calls) + } + }) + } +} + // Some artifact commands deliberately continue past the final captured // transport call into local download/upload handling. Deterministic invalid // resource responses form their expected post-transport test boundary; @@ -674,3 +733,16 @@ func replaceLongFlag(args []string, canonical, emitted string) ([]string, int) { } return out, replacements } + +func removeExactArg(args []string, target string) ([]string, int) { + out := make([]string, 0, len(args)) + removals := 0 + for _, arg := range args { + if arg == target { + removals++ + continue + } + out = append(out, arg) + } + return out, removals +} diff --git a/test/scripts/changelog_pr_gate_test.go b/test/scripts/changelog_pr_gate_test.go index 93a73f07..d01f366e 100644 --- a/test/scripts/changelog_pr_gate_test.go +++ b/test/scripts/changelog_pr_gate_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) type changelogGateRepo struct { @@ -71,6 +72,20 @@ func newChangelogGateRepo(t *testing.T) *changelogGateRepo { t.Fatalf("Abs(repo root) error = %v", err) } root := t.TempDir() + // Git may briefly continue automatic repository maintenance after a command + // exits on hosted runners. Proactively remove the fixture with bounded + // retries before testing.TempDir performs its strict final cleanup. + t.Cleanup(func() { + var cleanupErr error + for attempt := 0; attempt < 10; attempt++ { + cleanupErr = os.RemoveAll(root) + if cleanupErr == nil { + return + } + time.Sleep(time.Duration(attempt+1) * 10 * time.Millisecond) + } + t.Errorf("RemoveAll changelog gate repository after retries: %v", cleanupErr) + }) for _, path := range []string{ "LICENSE", @@ -108,6 +123,8 @@ func newChangelogGateRepo(t *testing.T) *changelogGateRepo { changelogGateWrite(t, root, "CHANGELOG.md", changelogGateBase, 0o644) changelogGateGit(t, root, "init", "-b", "main") + changelogGateGit(t, root, "config", "gc.auto", "0") + changelogGateGit(t, root, "config", "maintenance.auto", "false") changelogGateGit(t, root, "config", "user.name", "Changelog Gate Test") changelogGateGit(t, root, "config", "user.email", "changelog-gate@example.com") changelogGateGit(t, root, "add", ".") @@ -1166,10 +1183,13 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) { } raceJob := admission[raceStart:raceEnd] // Full race shards use a dynamic package-level timeout: default/floor 12m, - // with cli/smoke raised to 15m for NewRootCommand / Schema assembly under -race. + // with app/cli/smoke raised to 15m for NewRootCommand / Schema assembly under + // -race on slower hosted runners. for _, want := range []string{ "timeout_budget=12m", - `if [ "$TEST_SHARD" = "cli" ] || [ "$TEST_SHARD" = "smoke" ]; then`, + `if [ "$TEST_SHARD" = "app" ] ||`, + `[ "$TEST_SHARD" = "cli" ] ||`, + `[ "$TEST_SHARD" = "smoke" ]; then`, "timeout_budget=15m", `go test -v -race -count=1 -timeout="$timeout_budget" "${packages[@]}"`, "- smoke", From 0e856f5a6ea792744092ffaff976e6f020bcf368 Mon Sep 17 00:00:00 2001 From: chichuan Date: Wed, 12 Aug 2026 19:25:14 +0800 Subject: [PATCH 21/86] test(drive): cover dry-run collisions on Linux --- internal/helpers/drive_pull.go | 6 +++--- internal/helpers/drive_sync_confirmation_test.go | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/helpers/drive_pull.go b/internal/helpers/drive_pull.go index 9a40f2db..1a8d3894 100644 --- a/internal/helpers/drive_pull.go +++ b/internal/helpers/drive_pull.go @@ -184,7 +184,8 @@ func runDrivePull(cmd *cobra.Command, _ []string) error { } sort.Strings(relPaths) if deps.Caller.DryRun() { - return printDrivePullDryRun(absDir, ifExists, remote, relPaths) + caseInsensitive := runtime.GOOS == "windows" || runtime.GOOS == "darwin" + return printDrivePullDryRun(absDir, ifExists, remote, relPaths, caseInsensitive) } // 落盘前先按目标文件系统的路径等价规则做一次全局冲突检查:大小写不敏感 FS 上 @@ -246,9 +247,8 @@ func runDrivePull(cmd *cobra.Command, _ []string) error { return deps.Out.PrintJSON(res) } -func printDrivePullDryRun(absDir, ifExists string, remote map[string]*remoteFile, relPaths []string) error { +func printDrivePullDryRun(absDir, ifExists string, remote map[string]*remoteFile, relPaths []string, caseInsensitive bool) error { plan := drivePullResult{Items: make([]drivePullItem, 0, len(relPaths))} - caseInsensitive := runtime.GOOS == "windows" || runtime.GOOS == "darwin" collided := detectTargetCollisions(absDir, relPaths, caseInsensitive) for _, rel := range relPaths { if collided[rel] { diff --git a/internal/helpers/drive_sync_confirmation_test.go b/internal/helpers/drive_sync_confirmation_test.go index 6f7cbbf2..73a18c03 100644 --- a/internal/helpers/drive_sync_confirmation_test.go +++ b/internal/helpers/drive_sync_confirmation_test.go @@ -219,14 +219,14 @@ func TestCrossPlatformCoverageDrivePull_dryRunPlanBranches(t *testing.T) { t.Cleanup(func() { deps = prevDeps }) if err := printDrivePullDryRun(root, ifExistsSkip, remote, - []string{"A.txt", "a.txt", "../escape", "skip.txt", "new.txt"}); err != nil { + []string{"A.txt", "a.txt", "../escape", "skip.txt", "new.txt"}, true); err != nil { t.Fatalf("skip plan: %v", err) } if text := out.String(); !strings.Contains(text, pullActionFailed) || !strings.Contains(text, pullActionSkipped) || !strings.Contains(text, pullActionDownloaded) { t.Fatalf("skip plan did not cover failed/skipped/downloaded: %s", text) } out.Reset() - if err := printDrivePullDryRun(root, ifExistsSmart, remote, []string{"smart.txt"}); err != nil { + if err := printDrivePullDryRun(root, ifExistsSmart, remote, []string{"smart.txt"}, false); err != nil { t.Fatalf("smart plan: %v", err) } if !strings.Contains(out.String(), pullActionSkipped) { From d40a22aeb02fab1cd6403c4888c44289ea269a35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Wed, 12 Aug 2026 20:17:10 +0800 Subject: [PATCH 22/86] fix(chat): default start from explicit message end --- internal/helpers/chat.go | 36 +++-- .../helpers/chat_message_pagination_test.go | 128 ++++++++++++++++++ 2 files changed, 155 insertions(+), 9 deletions(-) diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 83bc0567..a1fef36d 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -467,7 +467,10 @@ func validateNativeSearchConversationScope(conversationIDs []string) error { } func chatMessageListAllArgs(cmd *cobra.Command) (map[string]any, error) { - startRaw, endRaw := defaultChatMessageTimeRange(cmd, 24*time.Hour) + startRaw, endRaw, err := defaultChatMessageTimeRange(cmd, 24*time.Hour) + if err != nil { + return nil, err + } cursor, _ := cmd.Flags().GetString("cursor") return map[string]any{ "startTime": startRaw, @@ -486,7 +489,10 @@ func chatMessageListBySenderArgs(cmd *cobra.Command) (map[string]any, error) { if senderUserID == "" && senderOpenDingTalkID == "" { return nil, fmt.Errorf("--sender-user-id or --sender-open-dingtalk-id is required") } - startRaw, endRaw := defaultChatMessageTimeRange(cmd, 7*24*time.Hour) + startRaw, endRaw, err := defaultChatMessageTimeRange(cmd, 7*24*time.Hour) + if err != nil { + return nil, err + } startMs, err := parseISOTimeToMillis("start", startRaw) if err != nil { return nil, err @@ -514,7 +520,10 @@ func chatMessageListBySenderArgs(cmd *cobra.Command) (map[string]any, error) { } func chatMessageListMentionsArgs(cmd *cobra.Command) (map[string]any, error) { - startRaw, endRaw := defaultChatMessageTimeRange(cmd, 7*24*time.Hour) + startRaw, endRaw, err := defaultChatMessageTimeRange(cmd, 7*24*time.Hour) + if err != nil { + return nil, err + } startMs, err := parseISOTimeToMillis("start", startRaw) if err != nil { return nil, err @@ -554,7 +563,10 @@ func chatMessageSearchArgs(cmd *cobra.Command) (map[string]any, error) { if err := validateRequiredFlagWithAliases(cmd, "query", "keyword"); err != nil { return nil, err } - startRaw, endRaw := defaultChatMessageTimeRange(cmd, 7*24*time.Hour) + startRaw, endRaw, err := defaultChatMessageTimeRange(cmd, 7*24*time.Hour) + if err != nil { + return nil, err + } startMs, err := parseISOTimeToMillis("start", startRaw) if err != nil { return nil, err @@ -580,17 +592,23 @@ func chatMessageSearchArgs(cmd *cobra.Command) (map[string]any, error) { return toolArgs, nil } -func defaultChatMessageTimeRange(cmd *cobra.Command, lookback time.Duration) (string, string) { +func defaultChatMessageTimeRange(cmd *cobra.Command, lookback time.Duration) (string, string, error) { startRaw := mustGetFlag(cmd, "start") endRaw := mustGetFlag(cmd, "end") - now := time.Now() + anchor := time.Now() if endRaw == "" { - endRaw = now.Format(time.RFC3339) + endRaw = anchor.Format(time.RFC3339) + } else { + endMs, err := parseISOTimeToMillis("end", endRaw) + if err != nil { + return "", "", err + } + anchor = time.UnixMilli(endMs) } if startRaw == "" { - startRaw = now.Add(-lookback).Format(time.RFC3339) + startRaw = anchor.Add(-lookback).Format(time.RFC3339) } - return startRaw, endRaw + return startRaw, endRaw, nil } func chatMessageSearchAdvancedArgs(cmd *cobra.Command) (map[string]any, error) { diff --git a/internal/helpers/chat_message_pagination_test.go b/internal/helpers/chat_message_pagination_test.go index 2706b621..8911b5e1 100644 --- a/internal/helpers/chat_message_pagination_test.go +++ b/internal/helpers/chat_message_pagination_test.go @@ -213,6 +213,134 @@ func TestChatMessagePaginationUsesDefaultTimeWindows(t *testing.T) { } } +func TestChatMessagePaginationDefaultsStartFromExplicitEnd(t *testing.T) { + endRaw := "2026-01-01T00:00:00+08:00" + endMs, err := parseISOTimeToMillis("end", endRaw) + if err != nil { + t.Fatal(err) + } + tests := []struct { + name string + args []string + wantTool string + wantLookback time.Duration + }{ + { + name: "list-all defaults start one day before explicit end", + args: []string{"message", "list-all", "--end", endRaw}, + wantTool: "search_messages_by_time_range", + wantLookback: 24 * time.Hour, + }, + { + name: "list-by-sender defaults start seven days before explicit end", + args: []string{"message", "list-by-sender", "--sender-user-id", "u1", "--end", endRaw}, + wantTool: "search_messages_by_sender", + wantLookback: 7 * 24 * time.Hour, + }, + { + name: "list-mentions defaults start seven days before explicit end", + args: []string{"message", "list-mentions", "--end", endRaw}, + wantTool: "search_at_me_message", + wantLookback: 7 * 24 * time.Hour, + }, + { + name: "search defaults start seven days before explicit end", + args: []string{"message", "search", "--query", "发布", "--end", endRaw}, + wantTool: "search_messages_by_keyword", + wantLookback: 7 * 24 * time.Hour, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caller := &chatMessagePaginationCaller{} + _, err := executeChatMessagePaginationCommand(t, caller, tt.args...) + if err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 { + t.Fatalf("calls = %#v, want one call", caller.calls) + } + got := caller.calls[0] + if got.tool != tt.wantTool { + t.Fatalf("tool = %q, want %q", got.tool, tt.wantTool) + } + startMs := numericArgAsInt64(t, got.args["startTime"]) + gotEndMs := numericArgAsInt64(t, got.args["endTime"]) + if gotEndMs != endMs { + t.Fatalf("endTime = %d, want %d", gotEndMs, endMs) + } + wantStartMs := time.UnixMilli(endMs).Add(-tt.wantLookback).UnixMilli() + if startMs != wantStartMs { + t.Fatalf("startTime = %d, want %d", startMs, wantStartMs) + } + }) + } +} + +func TestChatMessagePaginationDefaultsEndFromNowWhenOnlyStartProvided(t *testing.T) { + startRaw := "2026-01-01T00:00:00+08:00" + startMs, err := parseISOTimeToMillis("start", startRaw) + if err != nil { + t.Fatal(err) + } + tests := []struct { + name string + args []string + wantTool string + }{ + { + name: "list-all defaults end to now", + args: []string{"message", "list-all", "--start", startRaw}, + wantTool: "search_messages_by_time_range", + }, + { + name: "list-by-sender defaults end to now", + args: []string{"message", "list-by-sender", "--sender-user-id", "u1", "--start", startRaw}, + wantTool: "search_messages_by_sender", + }, + { + name: "list-mentions defaults end to now", + args: []string{"message", "list-mentions", "--start", startRaw}, + wantTool: "search_at_me_message", + }, + { + name: "search defaults end to now", + args: []string{"message", "search", "--query", "发布", "--start", startRaw}, + wantTool: "search_messages_by_keyword", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caller := &chatMessagePaginationCaller{} + before := time.Now() + _, err := executeChatMessagePaginationCommand(t, caller, tt.args...) + after := time.Now() + if err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 { + t.Fatalf("calls = %#v, want one call", caller.calls) + } + got := caller.calls[0] + if got.tool != tt.wantTool { + t.Fatalf("tool = %q, want %q", got.tool, tt.wantTool) + } + gotStartMs := numericArgAsInt64(t, got.args["startTime"]) + if gotStartMs != startMs { + t.Fatalf("startTime = %d, want %d", gotStartMs, startMs) + } + endMs := numericArgAsInt64(t, got.args["endTime"]) + wantEndMin := before.Truncate(time.Second).UnixMilli() + wantEndMax := after.Add(time.Second).UnixMilli() + if endMs < wantEndMin || endMs > wantEndMax { + t.Fatalf("endTime = %d, want between %d and %d", endMs, wantEndMin, wantEndMax) + } + }) + } +} + func numericArgAsInt64(t *testing.T, value any) int64 { t.Helper() switch v := value.(type) { From 30f3273a17b6ec5d968e8c530d930f0eb4449d45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Wed, 12 Aug 2026 20:43:56 +0800 Subject: [PATCH 23/86] fix(chat): validate message list-all time range --- .changes/973-chat-message-time-defaults.md | 5 +++ internal/helpers/chat.go | 11 ++++++ .../helpers/chat_message_pagination_test.go | 36 +++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 .changes/973-chat-message-time-defaults.md diff --git a/.changes/973-chat-message-time-defaults.md b/.changes/973-chat-message-time-defaults.md new file mode 100644 index 00000000..1a6e5496 --- /dev/null +++ b/.changes/973-chat-message-time-defaults.md @@ -0,0 +1,5 @@ +--- +category: Fixed +--- + +- **Chat message time defaults** (#973) — validates `chat message list-all` time ranges locally and defaults omitted message-query time bounds without creating reversed windows. diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index a1fef36d..d9352996 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -471,6 +471,17 @@ func chatMessageListAllArgs(cmd *cobra.Command) (map[string]any, error) { if err != nil { return nil, err } + startMs, err := parseISOTimeToMillis("start", startRaw) + if err != nil { + return nil, err + } + endMs, err := parseISOTimeToMillis("end", endRaw) + if err != nil { + return nil, err + } + if err := validateTimeRange(startMs, endMs); err != nil { + return nil, err + } cursor, _ := cmd.Flags().GetString("cursor") return map[string]any{ "startTime": startRaw, diff --git a/internal/helpers/chat_message_pagination_test.go b/internal/helpers/chat_message_pagination_test.go index 8911b5e1..221bffa3 100644 --- a/internal/helpers/chat_message_pagination_test.go +++ b/internal/helpers/chat_message_pagination_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "io" "reflect" + "strings" "testing" "time" @@ -341,6 +342,41 @@ func TestChatMessagePaginationDefaultsEndFromNowWhenOnlyStartProvided(t *testing } } +func TestChatMessageListAllRejectsInvalidTimeRanges(t *testing.T) { + tests := []struct { + name string + args []string + wantErr string + }{ + { + name: "invalid start", + args: []string{"message", "list-all", "--start", "not-a-time", "--end", "2020-01-01T00:00:00+08:00"}, + wantErr: "cannot parse time for --start", + }, + { + name: "end before start", + args: []string{"message", "list-all", "--start", "2021-01-01T00:00:00+08:00", "--end", "2020-01-01T00:00:00+08:00"}, + wantErr: "--end must be after --start", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caller := &chatMessagePaginationCaller{} + _, err := executeChatMessagePaginationCommand(t, caller, tt.args...) + if err == nil { + t.Fatal("command succeeded, want validation error") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %v, want containing %q", err, tt.wantErr) + } + if len(caller.calls) != 0 { + t.Fatalf("calls = %#v, want no MCP call", caller.calls) + } + }) + } +} + func numericArgAsInt64(t *testing.T, value any) int64 { t.Helper() switch v := value.(type) { From 1dabfa1dc6fece3adc2b249904717be790dad4e2 Mon Sep 17 00:00:00 2001 From: chichuan Date: Wed, 12 Aug 2026 20:48:16 +0800 Subject: [PATCH 24/86] fix(drive): keep pull partial results on stdout --- internal/helpers/drive.go | 2 +- internal/helpers/drive_pull.go | 36 +++++++++---------- internal/helpers/drive_pull_coverage_test.go | 18 ++++++++-- internal/helpers/drive_pull_test.go | 10 +++--- internal/helpers/drive_sync_edges_test.go | 15 ++++++++ skills/mono/references/products/drive.md | 2 +- .../multi/dingtalk-drive/references/drive.md | 2 +- 7 files changed, 54 insertions(+), 31 deletions(-) diff --git a/internal/helpers/drive.go b/internal/helpers/drive.go index acbd8bfc..b5455fb6 100644 --- a/internal/helpers/drive.go +++ b/internal/helpers/drive.go @@ -3319,7 +3319,7 @@ modified_time(--quick)逐文件比对。两侧各自递归遍历,rel_path 预览,确认后以相同参数追加 --yes 执行。 输出 summary(downloaded/skipped/failed)与逐文件 items。 -若有文件下载失败,命令以非零退出码退出(error.type=partial_failure)。`, +若有文件下载失败,命令以非零退出码退出,结构化结果仍在 stdout。`, Example: ` dws drive pull --local-folder /abs/path/repo --remote-folder dws drive pull --local-folder /abs/path/repo --remote-folder --if-exists smart dws drive pull --local-folder /abs/path/repo --remote-folder --space-id xxxx`, diff --git a/internal/helpers/drive_pull.go b/internal/helpers/drive_pull.go index 1a8d3894..ceeea8c5 100644 --- a/internal/helpers/drive_pull.go +++ b/internal/helpers/drive_pull.go @@ -2,7 +2,6 @@ package helpers import ( "context" - "encoding/json" "fmt" "os" "path/filepath" @@ -23,8 +22,8 @@ import ( // // 递归列出 --remote-folder 指向的钉盘文件夹下所有 type=FILE 的文件,逐一下载到 // --local-folder 对应的相对路径。已存在的本地文件按 --if-exists 决定 -// overwrite / smart / skip。summary.failed > 0 时以非零退出码退出,并在 -// error.type=partial_failure / error.detail 里返回同一份 summary + items。 +// overwrite / smart / skip。结构化 summary + items 始终打印到 stdout; +// summary.failed > 0 时额外以非零退出码退出。 // ────────────────────────────────────────────────────────── // --if-exists 的三种策略。 @@ -74,13 +73,14 @@ type drivePullDryRunResult struct { Plan drivePullResult `json:"plan"` } -// drivePartialFailure 在 summary.failed > 0 时返回:以 exit=1 退出,并把 -// {"error":{"type":"partial_failure","detail":{summary,items}}} 原样透传出去。 -// 结构上满足 core 的 RawStderrError 与 ExitCoder 接口(无需 import internal 包)。 -type drivePartialFailure struct{ raw string } +// drivePartialFailure 在 summary.failed > 0 时返回:结构化结果已打印到 stdout, +// 这里只负责以 exit=1 退出并向 stderr 输出一行简短说明(与 push/sync 一致)。 +type drivePartialFailure struct{ failed int } -func (e *drivePartialFailure) Error() string { return e.raw } -func (e *drivePartialFailure) RawStderr() string { return e.raw } +func (e *drivePartialFailure) Error() string { + return fmt.Sprintf("drive pull: %d file(s) failed", e.failed) +} +func (e *drivePartialFailure) RawStderr() string { return e.Error() } func (e *drivePartialFailure) ExitCode() int { return 1 } // pathCollisionKey 把本地目标路径归一化成「目标文件系统下的等价键」,用于探测多个 @@ -233,18 +233,14 @@ func runDrivePull(cmd *cobra.Command, _ []string) error { res.Items = append(res.Items, item) } - if res.Summary.Failed > 0 { - // partial_failure:非零退出码,summary+items 一并放进 error.detail。 - payload := map[string]any{ - "error": map[string]any{ - "type": "partial_failure", - "detail": res, - }, - } - b, _ := json.Marshal(payload) - return &drivePartialFailure{raw: string(b)} + // 结构化结果始终打印到 stdout;有失败则额外以非零退出码退出。 + if perr := deps.Out.PrintJSON(res); perr != nil { + return perr } - return deps.Out.PrintJSON(res) + if res.Summary.Failed > 0 { + return &drivePartialFailure{failed: res.Summary.Failed} + } + return nil } func printDrivePullDryRun(absDir, ifExists string, remote map[string]*remoteFile, relPaths []string, caseInsensitive bool) error { diff --git a/internal/helpers/drive_pull_coverage_test.go b/internal/helpers/drive_pull_coverage_test.go index f59c93b5..8809f9b5 100644 --- a/internal/helpers/drive_pull_coverage_test.go +++ b/internal/helpers/drive_pull_coverage_test.go @@ -150,13 +150,25 @@ func TestCrossPlatformCoverageDrivePull_downloadFailureIsPartialFailure(t *testi SetHTTPGetFile(func(context.Context, string, map[string]string, string) error { return errTestDownload }) t.Cleanup(func() { SetHTTPGetFile(nil) }) - err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT") + var out strings.Builder + prevDeps, prevArgs := deps, os.Args + deps = &Deps{Caller: caller, Out: &Formatter{w: &out}} + os.Args = []string{"dws", "drive", "pull"} + t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs }) + + cmd := findDriveSubcommand(t, "pull") + mustSetFlags(t, cmd, map[string]string{"local-folder": root, "remote-folder": "ROOT"}) + err := runDrivePull(cmd, nil) var pf *drivePartialFailure if !errors.As(err, &pf) { t.Fatalf("expected drivePartialFailure, got %T %v", err, err) } - if !strings.Contains(pf.raw, "partial_failure") { - t.Errorf("detail missing partial_failure: %s", pf.raw) + if pf.failed != 1 || pf.RawStderr() != "drive pull: 1 file(s) failed" { + t.Errorf("partial failure = %#v, stderr = %q", pf, pf.RawStderr()) + } + stdout := out.String() + if !strings.Contains(stdout, `"failed": 1`) || !strings.Contains(stdout, `"rel_path": "a.txt"`) { + t.Errorf("stdout must retain the structured partial result: %s", stdout) } } diff --git a/internal/helpers/drive_pull_test.go b/internal/helpers/drive_pull_test.go index 83233eca..f7543bab 100644 --- a/internal/helpers/drive_pull_test.go +++ b/internal/helpers/drive_pull_test.go @@ -371,16 +371,16 @@ func TestCrossPlatformCoverageParseDriveDownloadInfo_invalidJSON(t *testing.T) { } // ────────────────────────────────────────────────────────── -// drivePartialFailure — pull 部分失败错误:exit=1,JSON 原样透传 +// drivePartialFailure — pull 部分失败错误:exit=1,stderr 仅保留简短说明 // ────────────────────────────────────────────────────────── func TestCrossPlatformCoverageDrivePartialFailure(t *testing.T) { - raw := `{"error":{"type":"partial_failure","detail":{}}}` - e := &drivePartialFailure{raw: raw} + e := &drivePartialFailure{failed: 2} if e.ExitCode() != 1 { t.Errorf("ExitCode() = %d, want 1", e.ExitCode()) } - if e.Error() != raw || e.RawStderr() != raw { - t.Errorf("Error()/RawStderr() should return the raw JSON verbatim") + 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) } } diff --git a/internal/helpers/drive_sync_edges_test.go b/internal/helpers/drive_sync_edges_test.go index e2655b68..c8c3cfbf 100644 --- a/internal/helpers/drive_sync_edges_test.go +++ b/internal/helpers/drive_sync_edges_test.go @@ -134,6 +134,21 @@ func TestCrossPlatformCoveragePullOneFile_renameOntoDirectoryFails(t *testing.T) // PrintJSON 失败分支 // ────────────────────────────────────────────────────────── +func TestCrossPlatformCoverageDrivePull_printFailurePropagates(t *testing.T) { + dir := t.TempDir() + + prevDeps, prevArgs := deps, os.Args + deps = &Deps{Caller: pullListingCaller(""), Out: &Formatter{w: failingWriter{}}} + os.Args = []string{"dws", "drive", "pull"} + t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs }) + + cmd := findDriveSubcommand(t, "pull") + mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"}) + if err := runDrivePull(cmd, nil); err == nil { + t.Fatal("expected the PrintJSON writer failure to propagate") + } +} + func TestCrossPlatformCoverageDrivePush_printFailurePropagates(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "a.txt"), "x") diff --git a/skills/mono/references/products/drive.md b/skills/mono/references/products/drive.md index 6ee96349..5289cf76 100644 --- a/skills/mono/references/products/drive.md +++ b/skills/mono/references/products/drive.md @@ -437,7 +437,7 @@ Flags: - 只下载钉盘 `type=file` 的二进制文件;在线文档与快捷方式会被跳过。`rel_path` 始终以 `/` 分隔。 - 下载目标始终被约束在 `--local-folder` 之内:远端名称含 `..`、路径分隔符或盘符等非规范成分的条目会被跳过,拼接后仍逃逸出根目录的路径记为 `failed`、不会落盘。 - 下载成功后本地文件 mtime 会对齐到远端 `modified_time`,便于后续 `--if-exists smart` 增量同步跳过。 -- `summary.failed > 0` 时命令以**非零退出码**退出(`error.type=partial_failure`),同一份 `summary + items` 在 `error.detail` 里返回;脚本/agent 直接看 exit code 即可判断成败。 +- `summary.failed > 0` 时命令以**非零退出码**退出;结构化 `summary + items` 仍打印在 stdout 上,stderr 只保留简短失败说明。脚本/agent 直接看 exit code 即可判断成败。 ### 把本地文件夹推送(镜像)到钉盘 diff --git a/skills/multi/dingtalk-drive/references/drive.md b/skills/multi/dingtalk-drive/references/drive.md index e9d26c24..04703443 100644 --- a/skills/multi/dingtalk-drive/references/drive.md +++ b/skills/multi/dingtalk-drive/references/drive.md @@ -422,7 +422,7 @@ Flags: - 只下载钉盘 `type=file` 的二进制文件;在线文档与快捷方式会被跳过。`rel_path` 始终以 `/` 分隔。 - 下载目标始终被约束在 `--local-folder` 之内:远端名称含 `..`、路径分隔符或盘符等非规范成分的条目会被跳过,拼接后仍逃逸出根目录的路径记为 `failed`、不会落盘。 - 下载成功后本地文件 mtime 会对齐到远端 `modified_time`,便于后续 `--if-exists smart` 增量同步跳过。 -- `summary.failed > 0` 时命令以**非零退出码**退出(`error.type=partial_failure`),同一份 `summary + items` 在 `error.detail` 里返回;脚本/agent 直接看 exit code 即可判断成败。 +- `summary.failed > 0` 时命令以**非零退出码**退出;结构化 `summary + items` 仍打印在 stdout 上,stderr 只保留简短失败说明。脚本/agent 直接看 exit code 即可判断成败。 ### 把本地文件夹推送(镜像)到钉盘 From cf8dd167a4d87e894f30dada07ca3ac78c12add2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E8=B0=A8?= <2940480744@qq.com> Date: Wed, 12 Aug 2026 20:49:57 +0800 Subject: [PATCH 25/86] fix(cli): preserve scoped space aliases --- .../param_alias_payload_equivalence_test.go | 2 + internal/cli/param_aliases_generated.go | 61 ++++++++++++----- internal/cli/param_aliases_test.go | 65 +++++++++++++++++++ internal/cli/param_concepts.json | 28 ++++---- 4 files changed, 130 insertions(+), 26 deletions(-) diff --git a/internal/app/param_alias_payload_equivalence_test.go b/internal/app/param_alias_payload_equivalence_test.go index eaa2a0b7..2db173ef 100644 --- a/internal/app/param_alias_payload_equivalence_test.go +++ b/internal/app/param_alias_payload_equivalence_test.go @@ -405,6 +405,7 @@ var paramAliasRepresentativePayloadCases = map[string]bool{ paramAliasPayloadCaseKey("devdoc article search", "current-page"): true, // command override paramAliasPayloadCaseKey("doc +comment-create", "body"): true, // write shortcut content alias paramAliasPayloadCaseKey("doc +copy", "parent-folder-id"): true, // Doc folder role on a write shortcut + paramAliasPayloadCaseKey("doc create", "space-id"): true, // published Doc workspace compatibility remains payload-equivalent paramAliasPayloadCaseKey("doc +create", "content-format"): true, // shortcut format alias preserves markdown/jsonml enum paramAliasPayloadCaseKey("doc +create-from-template", "keyword"): true, // template search alias composes with a write workflow paramAliasPayloadCaseKey("doc +create-from-template", "workspace-id"): true, // template target workspace identifier @@ -424,6 +425,7 @@ var paramAliasRepresentativePayloadCases = map[string]bool{ paramAliasPayloadCaseKey("doc block insert", "parent-block-id"): true, // scoped block-role alias paramAliasPayloadCaseKey("doc comment delete", "comment-id"): true, // destructive comment-key alias paramAliasPayloadCaseKey("doc comment reply", "mentioned-open-conversation-ids"): true, // list-valued group mention role + paramAliasPayloadCaseKey("drive info", "workspace"): true, // published numeric storage-space compatibility remains payload-equivalent paramAliasPayloadCaseKey("mail folder update", "folder-id"): true, // write-command identifier alias paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias } diff --git a/internal/cli/param_aliases_generated.go b/internal/cli/param_aliases_generated.go index c7101d5d..dc98598e 100644 --- a/internal/cli/param_aliases_generated.go +++ b/internal/cli/param_aliases_generated.go @@ -1893,11 +1893,13 @@ var generatedParamAliases = []ParamAliasEntry{ "member-role": "role", "node-id": "node", "permission-role": "role", + "space": "workspace", + "space-id": "workspace", "url": "node", "wiki-workspace-id": "workspace", "workspace-id": "workspace", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "dingdrive-space-id", "drive-space-id", "filter-role", "folder", "folder-id", "id", "job-id", "name", "parent-id", "permission", "public-permission", "reserve-role", "revision", "space", "space-id", "storage-space-id", "task-id", "template-id", "version"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "dingdrive-space-id", "drive-space-id", "filter-role", "folder", "folder-id", "id", "job-id", "name", "parent-id", "permission", "public-permission", "reserve-role", "revision", "storage-space-id", "task-id", "template-id", "version"}, }, { CLIPath: "doc +access-grant", @@ -1912,11 +1914,13 @@ var generatedParamAliases = []ParamAliasEntry{ "member-role": "role", "node-id": "node", "permission-role": "role", + "space": "workspace", + "space-id": "workspace", "url": "node", "wiki-workspace-id": "workspace", "workspace-id": "workspace", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "dingdrive-space-id", "drive-space-id", "filter-role", "folder", "folder-id", "id", "job-id", "name", "parent-id", "permission", "public-permission", "reserve-role", "revision", "space", "space-id", "storage-space-id", "target-user-id", "target-user-ids", "task-id", "template-id", "user-id", "user-ids", "version"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "dingdrive-space-id", "drive-space-id", "filter-role", "folder", "folder-id", "id", "job-id", "name", "parent-id", "permission", "public-permission", "reserve-role", "revision", "storage-space-id", "target-user-id", "target-user-ids", "task-id", "template-id", "user-id", "user-ids", "version"}, Ambiguous: []string{"target-user", "user", "users"}, }, { @@ -1929,11 +1933,13 @@ var generatedParamAliases = []ParamAliasEntry{ "file-id": "node", "knowledge-base-id": "workspace", "node-id": "node", + "space": "workspace", + "space-id": "workspace", "url": "node", "wiki-workspace-id": "workspace", "workspace-id": "workspace", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "id", "job-id", "name", "parent-id", "revision", "role", "space", "space-id", "storage-space-id", "task-id", "template-id", "version"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "id", "job-id", "name", "parent-id", "revision", "role", "storage-space-id", "task-id", "template-id", "version"}, }, { CLIPath: "doc +background-delete", @@ -2074,11 +2080,13 @@ var generatedParamAliases = []ParamAliasEntry{ "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", + "space": "workspace", + "space-id": "workspace", "url": "node", "wiki-workspace-id": "workspace", "workspace-id": "workspace", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "dingdrive-space-id", "drive-space-id", "id", "job-id", "name", "parent-id", "revision", "role", "space", "space-id", "storage-space-id", "task-id", "template-id", "version"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "dingdrive-space-id", "drive-space-id", "id", "job-id", "name", "parent-id", "revision", "role", "storage-space-id", "task-id", "template-id", "version"}, }, { CLIPath: "doc +create", @@ -2090,11 +2098,13 @@ var generatedParamAliases = []ParamAliasEntry{ "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", + "space": "workspace", + "space-id": "workspace", "text": "content", "wiki-workspace-id": "workspace", "workspace-id": "workspace", }, - Blocked: []string{"content-file", "dentry-id", "dingdrive-space-id", "drive-space-id", "export-format", "mime-type", "node", "node-id", "parent-id", "space", "space-id", "storage-space-id", "title"}, + Blocked: []string{"content-file", "dentry-id", "dingdrive-space-id", "drive-space-id", "export-format", "mime-type", "node", "node-id", "parent-id", "storage-space-id", "title"}, }, { CLIPath: "doc +create-from-template", @@ -2108,10 +2118,12 @@ var generatedParamAliases = []ParamAliasEntry{ "parent-node-id": "folder", "q": "query", "search-word": "query", + "space": "workspace", + "space-id": "workspace", "wiki-workspace-id": "workspace", "workspace-id": "workspace", }, - Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "node", "node-id", "parent-id", "space", "space-id", "storage-space-id", "subject", "text", "title"}, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "node", "node-id", "parent-id", "storage-space-id", "subject", "text", "title"}, }, { CLIPath: "doc +doc-append", @@ -2212,10 +2224,12 @@ var generatedParamAliases = []ParamAliasEntry{ "member-role": "role", "node-id": "node", "permission-role": "role", + "space": "workspace", + "space-id": "workspace", "wiki-workspace-id": "workspace", "workspace-id": "workspace", }, - Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "filter-role", "folder", "folder-id", "permission", "public-permission", "reserve-role", "space", "space-id", "storage-space-id"}, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "filter-role", "folder", "folder-id", "permission", "public-permission", "reserve-role", "storage-space-id"}, }, { CLIPath: "doc +history-list", @@ -2266,10 +2280,12 @@ var generatedParamAliases = []ParamAliasEntry{ "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", + "space": "workspace", + "space-id": "workspace", "wiki-workspace-id": "workspace", "workspace-id": "workspace", }, - Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "node", "node-id", "parent-id", "space", "space-id", "storage-space-id"}, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "node", "node-id", "parent-id", "storage-space-id"}, }, { CLIPath: "doc +inspect", @@ -2305,12 +2321,14 @@ var generatedParamAliases = []ParamAliasEntry{ "parent-node-id": "folder", "per-page": "limit", "size": "limit", + "space": "workspace", + "space-id": "workspace", "take": "limit", "top": "limit", "wiki-workspace-id": "workspace", "workspace-id": "workspace", }, - Blocked: []string{"count", "dentry-id", "dingdrive-space-id", "drive-space-id", "node", "node-id", "offset", "page", "parent-id", "space", "space-id", "storage-space-id"}, + Blocked: []string{"count", "dentry-id", "dingdrive-space-id", "drive-space-id", "node", "node-id", "offset", "page", "parent-id", "storage-space-id"}, }, { CLIPath: "doc +media-download", @@ -2371,11 +2389,13 @@ var generatedParamAliases = []ParamAliasEntry{ "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", + "space": "workspace", + "space-id": "workspace", "url": "node", "wiki-workspace-id": "workspace", "workspace-id": "workspace", }, - Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "dingdrive-space-id", "drive-space-id", "id", "job-id", "name", "parent-id", "revision", "role", "space", "space-id", "storage-space-id", "task-id", "template-id", "version"}, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "dingdrive-space-id", "drive-space-id", "id", "job-id", "name", "parent-id", "revision", "role", "storage-space-id", "task-id", "template-id", "version"}, }, { CLIPath: "doc +resource-delete", @@ -2691,10 +2711,12 @@ var generatedParamAliases = []ParamAliasEntry{ "body": "content", "doc-format": "content-format", "knowledge-base-id": "workspace", + "space": "workspace", + "space-id": "workspace", "text": "content", "wiki-workspace-id": "workspace", }, - Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "export-format", "folder-id", "mime-type", "node", "node-id", "space", "space-id", "storage-space-id"}, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "export-format", "folder-id", "mime-type", "node", "node-id", "storage-space-id"}, }, { CLIPath: "doc export", @@ -2716,17 +2738,21 @@ var generatedParamAliases = []ParamAliasEntry{ CLIPath: "doc file create", Aliases: map[string]string{ "knowledge-base-id": "workspace", + "space": "workspace", + "space-id": "workspace", "wiki-workspace-id": "workspace", }, - Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "folder-id", "node", "node-id", "space", "space-id", "storage-space-id"}, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "folder-id", "node", "node-id", "storage-space-id"}, }, { CLIPath: "doc import", Aliases: map[string]string{ "knowledge-base-id": "workspace", + "space": "workspace", + "space-id": "workspace", "wiki-workspace-id": "workspace", }, - Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "node", "node-id", "space", "space-id", "storage-space-id"}, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "node", "node-id", "storage-space-id"}, }, { CLIPath: "doc import get", @@ -2836,9 +2862,11 @@ var generatedParamAliases = []ParamAliasEntry{ CLIPath: "doc template apply", Aliases: map[string]string{ "knowledge-base-id": "workspace", + "space": "workspace", + "space-id": "workspace", "wiki-workspace-id": "workspace", }, - Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "folder-id", "node", "node-id", "space", "space-id", "storage-space-id"}, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "folder-id", "node", "node-id", "storage-space-id"}, }, { CLIPath: "doc template list", @@ -3504,9 +3532,12 @@ var generatedParamAliases = []ParamAliasEntry{ "dentry-uuid": "node", "dingdrive-space-id": "space-id", "drive-space-id": "space-id", + "space": "space-id", "storage-space-id": "space-id", + "workspace": "space-id", + "workspace-id": "space-id", }, - Blocked: []string{"dentry-id", "document-url", "knowledge-base-id", "space", "wiki-workspace-id", "workspace", "workspace-id"}, + Blocked: []string{"dentry-id", "document-url", "knowledge-base-id", "wiki-workspace-id"}, }, { CLIPath: "drive list", diff --git a/internal/cli/param_aliases_test.go b/internal/cli/param_aliases_test.go index aab959b7..64f76daa 100644 --- a/internal/cli/param_aliases_test.go +++ b/internal/cli/param_aliases_test.go @@ -515,3 +515,68 @@ func TestGeneratedParamAliasesAreWellFormed(t *testing.T) { } } } + +func TestCrossPlatformCoveragePublishedSpaceWorkspaceAliasesRemainExecutable(t *testing.T) { + docCommands := []string{ + "doc +access-change", + "doc +access-grant", + "doc +access-revoke", + "doc +copy", + "doc +create", + "doc +create-from-template", + "doc +grant-and-share", + "doc +import", + "doc +list", + "doc +move", + "doc create", + "doc file create", + "doc import", + "doc template apply", + } + for _, command := range docCommands { + entry, ok := LookupParamAlias(command) + if !ok { + t.Errorf("published Doc command %q has no generated parameter-alias entry", command) + continue + } + for _, emitted := range []string{"space", "space-id"} { + target, active := entry.ResolveAlias(emitted) + if !active || target != "workspace" { + t.Errorf("%s --%s resolution = active:%v target:%q, want --workspace", command, emitted, active, target) + } + if entry.IsBlocked(emitted) || entry.IsAmbiguous(emitted) { + t.Errorf("%s --%s remains protected after restoring its published alias", command, emitted) + } + } + } + + driveInfo, ok := LookupParamAlias("drive info") + if !ok { + t.Fatal("published drive info command has no generated parameter-alias entry") + } + for _, emitted := range []string{"space", "workspace", "workspace-id"} { + target, active := driveInfo.ResolveAlias(emitted) + if !active || target != "space-id" { + t.Errorf("drive info --%s resolution = active:%v target:%q, want --space-id", emitted, active, target) + } + if driveInfo.IsBlocked(emitted) || driveInfo.IsAmbiguous(emitted) { + t.Errorf("drive info --%s remains protected after restoring its published alias", emitted) + } + } + + // Compatibility remains exact-path scoped. Strong type spellings introduced + // after the split continue to guard the two value domains elsewhere. + for _, test := range []struct { + command string + emitted string + }{ + {command: "doc create", emitted: "storage-space-id"}, + {command: "drive +upload", emitted: "workspace-id"}, + {command: "drive info", emitted: "knowledge-base-id"}, + } { + entry, ok := LookupParamAlias(test.command) + if !ok || !entry.IsBlocked(test.emitted) { + t.Errorf("%s --%s must remain blocked outside the published compatibility set", test.command, test.emitted) + } + } +} diff --git a/internal/cli/param_concepts.json b/internal/cli/param_concepts.json index 6a9433f1..eb8f5d17 100644 --- a/internal/cli/param_concepts.json +++ b/internal/cli/param_concepts.json @@ -112,9 +112,9 @@ "chat message send-by-bot": {"scoped_aliases": {"at-users": "at-user-ids"}, "block": ["user-id", "to-user-id"], "ambiguous": ["at-ids"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain."}, "doc +export-get": {"block": ["doc-id", "document-id", "file-id", "node", "node-id", "task-id", "url"], "note": "This command queries one export jobId. Document node identifiers and import taskId spellings are different entities and are rejected.", "scoped_aliases": {"export-job-id": "job-id"}, "scope_strict": true}, "doc block delete": {"block": ["index"], "note": "index (position) vs node (node id) are different"}, - "doc +copy": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."}, - "doc +list": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."}, - "doc +move": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."}, + "doc +copy": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve the compatibility published before workspace and numeric DingDrive storage-space concepts were split; they do not make those value domains globally equivalent."}, + "doc +list": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve the compatibility published before workspace and numeric DingDrive storage-space concepts were split; they do not make those value domains globally equivalent."}, + "doc +move": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve the compatibility published before workspace and numeric DingDrive storage-space concepts were split; they do not make those value domains globally equivalent."}, "doc comment create": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."}, "doc comment reply": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."}, "doc comment update": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."}, @@ -169,10 +169,16 @@ "chat +messages-recall-by-bot": {"block": ["msg-id", "message-id", "open-message-id", "msg-ids", "message-ids", "open-message-ids"], "note": "--keys carries processQueryKey values, not openMessageId values."}, "chat +messages-reply": {"scoped_aliases": {"msg-id": "ref-msg-id", "open-message-id": "ref-msg-id"}, "block": ["group", "msg-ids", "message-ids", "open-message-ids"], "scope_strict": true, "note": "The observed --group spelling carried a natural group name and is blocked. The only message role is the referenced message; plural IDs are not accepted, while --chat remains a CID alias and native --message-id stays native."}, "chat +messages-resource-download": {"block": ["download-dir"], "note": "--output may be a file or directory under workspace safety rules; a download directory cannot be assumed equivalent."}, - "doc +access-grant": {"block": ["target-user-id", "target-user-ids", "user-id", "user-ids"], "ambiguous": ["target-user", "user", "users"], "scope_strict": true, "note": "The real --to accepts collaborator names and resolves them before granting access. Explicit ID spellings cannot be passed through unchanged, while role-free user spellings do not prove whether their values are names or IDs."}, - "doc +create": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["content-file", "parent-id"], "scope_strict": true, "note": "--content-format is value-preservingly normalized to --doc-format. A raw --content-file path cannot become --content without adding the required @file transform, so it is blocked with guidance to use @relative-path or stable doc create."}, - "doc +create-from-template": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."}, - "doc +import": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."}, + "doc +access-change": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."}, + "doc +access-grant": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "block": ["target-user-id", "target-user-ids", "user-id", "user-ids"], "ambiguous": ["target-user", "user", "users"], "scope_strict": true, "note": "The real --to accepts collaborator names and resolves them before granting access. Explicit ID spellings cannot be passed through unchanged, while role-free user spellings do not prove whether their values are names or IDs. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."}, + "doc +access-revoke": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."}, + "doc +create": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["content-file", "parent-id"], "scope_strict": true, "note": "--content-format is value-preservingly normalized to --doc-format. A raw --content-file path cannot become --content without adding the required @file transform, so it is blocked with guidance to use @relative-path or stable doc create. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."}, + "doc +create-from-template": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."}, + "doc +import": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."}, + "doc create": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."}, + "doc file create": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."}, + "doc import": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."}, + "doc template apply": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."}, "doc +update": {"scoped_aliases": {"mode": "command", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node", "url": "node"}, "block": ["content-file"], "ambiguous": ["element"], "scope_strict": true, "note": "--mode append/overwrite is the same operation selector subset as --command and preserves its value. --content-file is blocked because +update requires @relative-path or stdin and central aliases cannot read/transform a file value. --element cannot choose between document content, a block target, or an insertion reference."}, "doc +inspect": {"scoped_aliases": {"include-access": "include-permissions", "include-member": "include-permissions", "include-members": "include-permissions", "include-versions": "include-history"}, "block": ["include", "include-blocks", "include-content", "include-info", "include-meta", "include-metadata"], "scope_strict": true, "note": "Access/member spellings denote the same optional permission list, and versions/history denote the same history section. Generic --include needs value-dependent expansion; base metadata is already returned; block/content reads belong to +fetch, so those spellings stop before fuzzy correction or dispatch."}, "doc +fetch": {"scoped_aliases": {"start-block": "start-block-id", "end-block": "end-block-id"}, "block": ["content-format", "doc-format", "range"], "ambiguous": ["block-id"], "scope_strict": true, "note": "Start/end block roles are preserved. A role-free --block-id cannot choose a range/section boundary. --range is an invented composite argument observed alongside --scope range and cannot be split into block IDs by name-only normalization; content-format spellings do not map to the independent --detail contract."}, @@ -185,7 +191,7 @@ "doc +resource-download": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command downloads a document resource while targeting the document by --node. File and URL spellings do not uniquely identify that document role."}, "doc +resource-update": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has document-node, local-file, and HTTPS image URL roles. Only strong document spellings map to --node; --file-id and --url must stop as ambiguous."}, "doc +share": {"block": ["doc", "doc-id", "document-id", "file-id", "id", "node", "node-id"], "note": "The real --url requires a shareable document link. Name-only normalization cannot turn a node identifier into a URL, so identifier spellings are rejected with guidance to provide --url."}, - "doc +grant-and-share": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node"}, "scope_strict": true, "note": "This workflow has two different real URL roles: --node selects the document for access control and --url is the shareable link sent to recipients. Explicit document-ID spellings map only to --node; --url remains native."}, + "doc +grant-and-share": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node", "space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "This workflow has two different real URL roles: --node selects the document for access control and --url is the shareable link sent to recipients. Explicit document-ID spellings map only to --node; --url remains native. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."}, "doc +version-save": {"block": ["message", "title"], "scope_strict": true, "note": "The current snapshot API accepts only the document target. Version title/message metadata are unsupported and cannot be represented by another existing flag."}, "drive copy": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "ambiguous": ["destination-id", "target-id"]}, "drive cover": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]}, @@ -223,7 +229,7 @@ "drive +rename": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node", "folder": "node", "folder-id": "node", "file-name": "name", "display-name": "name", "new-name": "name"}, "block": ["dentry-id"], "scope_strict": true, "note": "The existing document, file or folder node and the requested new display name are separate required roles. Node ID/URL aliases match drive rename and pass through unchanged; numeric dentryId remains protected."}, "drive delete": {"scoped_aliases": {"dentry-uuid": "node"}, "block": ["dentry-id", "document-url"], "scope_strict": true, "note": "This command publicly accepts an ID-only Drive node; dentry spellings preserve the value and URL-specific spellings remain protected."}, "drive download": {"scoped_aliases": {"dentry-uuid": "node", "chunk-size": "part-size", "concurrency": "parallel", "parallelism": "parallel"}, "block": ["dentry-id", "document-url", "file", "file-path", "folder", "folder-id", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "Output path, chunk size, and concurrency names preserve values; local input and remote folder roles remain blocked."}, - "drive info": {"scoped_aliases": {"dentry-uuid": "node"}, "block": ["dentry-id", "document-url", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "This command has only a numeric DingDrive space target; knowledge-base workspace spellings are a different value domain."}, + "drive info": {"scoped_aliases": {"dentry-uuid": "node", "space": "space-id", "workspace": "space-id", "workspace-id": "space-id"}, "block": ["dentry-id", "document-url", "knowledge-base-id", "wiki-workspace-id"], "scope_strict": true, "note": "This command has only a numeric DingDrive space target. Its command-scoped --space/--workspace/--workspace-id aliases preserve compatibility published before the workspace/storage-space concept split and pass the value unchanged to --space-id; new commands must not infer that knowledge-base workspace IDs and numeric storage-space IDs are globally interchangeable."}, "drive commit": {"scoped_aliases": {"directory-id": "folder", "parent-directory-id": "folder", "upload-session-id": "upload-id", "size-bytes": "file-size", "name": "file-name", "display-name": "file-name", "filename": "file-name", "upload-name": "file-name"}, "scope_strict": true, "note": "Upload session, file name, file size in bytes, parent folder, and storage space remain separate roles.", "block": ["knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"]}, "drive mkdir": {"scoped_aliases": {"directory-id": "folder", "parent-directory-id": "folder", "folder-name": "name", "display-name": "name", "new-name": "name"}, "scope_strict": true, "note": "The name is this exact folder/node display name; search query and local path are different roles.", "block": ["knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"]}, "drive upload-info": {"scoped_aliases": {"directory-id": "folder", "parent-directory-id": "folder", "content-type": "mime-type", "size-bytes": "file-size", "name": "file-name", "display-name": "file-name", "filename": "file-name", "upload-name": "file-name"}, "scope_strict": true, "note": "File metadata aliases preserve MIME and byte units; file path and upload session are different stages.", "block": ["knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"]}, @@ -313,7 +319,7 @@ {"command": "doc +export-get", "emitted": "node", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2}, {"command": "doc block delete", "emitted": "index", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2}, {"command": "doc block insert", "emitted": "before-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-multi-parameter-transform", "occ": 2}, - {"command": "drive info", "emitted": "workspace", "expect": "did-you-mean:blocked", "via": "guard:workspace-vs-numeric-storage-space", "occ": 2}, + {"command": "drive info", "emitted": "workspace", "expect": "space-id", "via": "override:published-storage-space-compatibility", "occ": 2}, {"command": "mail folder update", "emitted": "folder-id", "expect": "id", "via": "override:bind(folder_id)", "occ": 2}, {"command": "report outbox list", "emitted": "template-type", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2}, {"command": "chat +group-members", "emitted": "group-name", "expect": "group", "via": "concept:group_name+bind"}, @@ -503,7 +509,7 @@ {"command": "doc +copy", "emitted": "dentry-uuid", "expect": "node", "via": "concept:doc_node_id"}, {"command": "doc info", "emitted": "dentry-id", "expect": "did-you-mean:blocked", "via": "guard:dentry-id-vs-dentry-uuid"}, {"command": "doc create", "emitted": "knowledge-base-id", "expect": "workspace", "via": "concept:workspace_id"}, - {"command": "doc create", "emitted": "space-id", "expect": "did-you-mean:blocked", "via": "guard:storage-space-vs-workspace"}, + {"command": "doc create", "emitted": "space-id", "expect": "workspace", "via": "override:published-workspace-compatibility"}, {"command": "drive list", "emitted": "knowledge-base-id", "expect": "workspace", "via": "concept:workspace_id"}, {"command": "drive list", "emitted": "order-field", "expect": "order-by", "via": "override:scoped-sort-field"}, {"command": "drive info", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-node-id"}, From ae6d9aa16db21e977cedcbab1ec00703084d68fc Mon Sep 17 00:00:00 2001 From: chichuan Date: Wed, 12 Aug 2026 22:04:57 +0800 Subject: [PATCH 26/86] fix(drive): reject push path type conflicts Check opposite-type remote entries before dry-run planning or actual writes, and cover both file-folder conflict directions. --- internal/helpers/drive_push.go | 35 ++++++ .../helpers/drive_push_type_conflict_test.go | 108 ++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 internal/helpers/drive_push_type_conflict_test.go diff --git a/internal/helpers/drive_push.go b/internal/helpers/drive_push.go index 741fa7eb..d1d10ec2 100644 --- a/internal/helpers/drive_push.go +++ b/internal/helpers/drive_push.go @@ -126,6 +126,11 @@ func runDrivePush(cmd *cobra.Command, _ []string) error { // 第一阶段:按需创建远端目录(浅层在前,保证父目录先于子目录存在)。 for _, dir := range localDirs { + if msg := pushTypeConflictError(dir, true, remoteFiles, remoteFolders); msg != "" { + res.Summary.Failed++ + res.Items = append(res.Items, drivePushItem{RelPath: dir, Action: pushActionFailed, Error: msg}) + continue + } if _, ok := remoteFolders[dir]; ok { continue // 远端已存在,复用 fileId,不留痕 } @@ -154,6 +159,11 @@ func runDrivePush(cmd *cobra.Command, _ []string) error { for i := range localFiles { lf := localFiles[i] size := lf.Size + if msg := pushTypeConflictError(lf.RelPath, false, remoteFiles, remoteFolders); msg != "" { + res.Summary.Failed++ + res.Items = append(res.Items, drivePushItem{RelPath: lf.RelPath, Action: pushActionFailed, SizeBytes: &size, Error: msg}) + continue + } parentRel, name := splitRel(lf.RelPath) parentID, ok := remoteFolders[parentRel] if !ok || parentID == "" { @@ -294,6 +304,11 @@ func printDrivePushDryRun(ifExists string, remoteFiles map[string]*remoteFile, r plannedFolders[rel] = fileID } for _, dir := range localDirs { + if msg := pushTypeConflictError(dir, true, remoteFiles, remoteFolders); msg != "" { + plan.Summary.Failed++ + plan.Items = append(plan.Items, drivePushItem{RelPath: dir, Action: pushActionFailed, Error: msg}) + continue + } if _, ok := plannedFolders[dir]; ok { continue } @@ -308,6 +323,11 @@ func printDrivePushDryRun(ifExists string, remoteFiles map[string]*remoteFile, r } for _, lf := range localFiles { size := lf.Size + if msg := pushTypeConflictError(lf.RelPath, false, remoteFiles, remoteFolders); msg != "" { + plan.Summary.Failed++ + plan.Items = append(plan.Items, drivePushItem{RelPath: lf.RelPath, Action: pushActionFailed, SizeBytes: &size, Error: msg}) + continue + } parentRel, _ := splitRel(lf.RelPath) if plannedFolders[parentRel] == "" { plan.Summary.Failed++ @@ -342,6 +362,21 @@ func printDrivePushDryRun(ifExists string, remoteFiles map[string]*remoteFile, r }) } +// pushTypeConflictError 在任何写操作或 dry-run 成功预览之前,拒绝同一路径下 +// “本地目录 ↔ 远端文件”或“本地文件 ↔ 远端目录”的类型冲突。 +func pushTypeConflictError(rel string, localIsFolder bool, remoteFiles map[string]*remoteFile, remoteFolders map[string]string) string { + if localIsFolder { + if _, exists := remoteFiles[rel]; exists { + return "远端同路径已存在文件,无法创建目录" + } + return "" + } + if _, exists := remoteFolders[rel]; exists { + return "远端同路径已存在目录,无法上传文件" + } + return "" +} + // walkLocalForPush 遍历本地根目录,返回所有子目录 rel_path(不含根本身,浅层在前) // 与所有常规文件。dirs 升序排序保证父目录先于子目录被创建。 func walkLocalForPush(root string) ([]string, []localPushFile, error) { diff --git a/internal/helpers/drive_push_type_conflict_test.go b/internal/helpers/drive_push_type_conflict_test.go new file mode 100644 index 00000000..e6e294fb --- /dev/null +++ b/internal/helpers/drive_push_type_conflict_test.go @@ -0,0 +1,108 @@ +package helpers + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" +) + +func TestCrossPlatformCoverageDrivePushDryRun_typeConflictsFail(t *testing.T) { + var out bytes.Buffer + prevDeps := deps + deps = &Deps{Out: &Formatter{w: &out}} + t.Cleanup(func() { deps = prevDeps }) + + tests := []struct { + name string + remoteFiles map[string]*remoteFile + remoteFolders map[string]string + localDirs []string + localFiles []localPushFile + }{ + { + name: "remote file conflicts with local folder", + remoteFiles: map[string]*remoteFile{"conflict": {RelPath: "conflict", FileID: "REMOTE_FILE"}}, + remoteFolders: map[string]string{"": "ROOT"}, + localDirs: []string{"conflict"}, + }, + { + name: "remote folder conflicts with local file", + remoteFiles: map[string]*remoteFile{}, + remoteFolders: map[string]string{"": "ROOT", "conflict": "REMOTE_FOLDER"}, + localFiles: []localPushFile{{RelPath: "conflict", Size: 1}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out.Reset() + if err := printDrivePushDryRun(ifExistsSkip, tt.remoteFiles, tt.remoteFolders, tt.localDirs, tt.localFiles); err != nil { + t.Fatalf("printDrivePushDryRun: %v", err) + } + + var got drivePushDryRunResult + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("decode dry-run result: %v\n%s", err, out.String()) + } + if got.Plan.Summary.Failed != 1 || got.Plan.Summary.Uploaded != 0 { + t.Fatalf("summary = %+v, want failed=1 uploaded=0", got.Plan.Summary) + } + if len(got.Plan.Items) != 1 || got.Plan.Items[0].Action != pushActionFailed || got.Plan.Items[0].Error == "" { + t.Fatalf("items = %+v, want one failed item with an error", got.Plan.Items) + } + }) + } +} + +func TestCrossPlatformCoverageDrivePush_typeConflictsFailBeforeWrites(t *testing.T) { + tests := []struct { + name string + prepare func(*testing.T, string) + remoteItem string + }{ + { + name: "remote file conflicts with local folder", + prepare: func(t *testing.T, root string) { + t.Helper() + if err := os.Mkdir(filepath.Join(root, "conflict"), 0o755); err != nil { + t.Fatal(err) + } + }, + remoteItem: `{"name":"conflict","type":"file","fileId":"REMOTE_FILE"}`, + }, + { + name: "remote folder conflicts with local file", + prepare: func(t *testing.T, root string) { + t.Helper() + mustWrite(t, filepath.Join(root, "conflict"), "local") + }, + remoteItem: `{"name":"conflict","type":"folder","fileId":"REMOTE_FOLDER"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := t.TempDir() + tt.prepare(t, root) + withNoopPut(t) + + caller := pushOKCaller(map[string]string{ + "ROOT": `{"result":{"items":[` + tt.remoteItem + `],"nextToken":""}}`, + }) + err := runDriveCmd(t, caller, "push", "--local-folder", root, "--remote-folder", "ROOT") + var failure *drivePushFailure + if !errors.As(err, &failure) || failure.failed != 1 { + t.Fatalf("error = %T %v, want drivePushFailure(failed=1)", err, err) + } + if calls := caller.callsFor("create_folder"); len(calls) != 0 { + t.Fatalf("type conflict must not create folders, got %v", calls) + } + if calls := caller.callsFor("get_upload_info"); len(calls) != 0 { + t.Fatalf("type conflict must not upload, got %v", calls) + } + }) + } +} From 657f9ee3684a4a35a1dc5fb12d49829593f46306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Wed, 12 Aug 2026 22:29:22 +0800 Subject: [PATCH 27/86] ci(test): extend app race shard timeout --- .github/workflows/ci.yml | 4 ++-- test/scripts/changelog_pr_gate_test.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eace8f78..2815611e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -531,10 +531,10 @@ jobs: test -n "$package_output" mapfile -t packages <<< "$package_output" test "${#packages[@]}" -gt 0 - # cli/smoke own heavy NewRootCommand / Schema assembly under -race; give + # app/cli/smoke own heavy NewRootCommand / Schema assembly under -race; give # them a dedicated budget so remaining is not SIGTERM'd by OOM/timeout. timeout_budget=12m - if [ "$TEST_SHARD" = "cli" ] || [ "$TEST_SHARD" = "smoke" ]; then + if [ "$TEST_SHARD" = "app" ] || [ "$TEST_SHARD" = "cli" ] || [ "$TEST_SHARD" = "smoke" ]; then timeout_budget=15m fi go test -v -race -count=1 -timeout="$timeout_budget" "${packages[@]}" diff --git a/test/scripts/changelog_pr_gate_test.go b/test/scripts/changelog_pr_gate_test.go index bf05855b..0a24168d 100644 --- a/test/scripts/changelog_pr_gate_test.go +++ b/test/scripts/changelog_pr_gate_test.go @@ -1154,10 +1154,10 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) { } raceJob := admission[raceStart:raceEnd] // Full race shards use a dynamic package-level timeout: default/floor 12m, - // with cli/smoke raised to 15m for NewRootCommand / Schema assembly under -race. + // with app/cli/smoke raised to 15m for NewRootCommand / Schema assembly under -race. for _, want := range []string{ "timeout_budget=12m", - `if [ "$TEST_SHARD" = "cli" ] || [ "$TEST_SHARD" = "smoke" ]; then`, + `if [ "$TEST_SHARD" = "app" ] || [ "$TEST_SHARD" = "cli" ] || [ "$TEST_SHARD" = "smoke" ]; then`, "timeout_budget=15m", `go test -v -race -count=1 -timeout="$timeout_budget" "${packages[@]}"`, "- smoke", From 18c8e8390c41f7bfdda28f1432377b521f406eda Mon Sep 17 00:00:00 2001 From: chichuan Date: Wed, 12 Aug 2026 22:30:09 +0800 Subject: [PATCH 28/86] fix(drive): reject duplicate remote paths Reserve each remote file or folder rel_path exactly once so pagination and traversal order cannot silently discard mirror entries. --- internal/helpers/drive_push.go | 13 ++- .../helpers/drive_remote_duplicate_test.go | 86 +++++++++++++++++++ internal/helpers/drive_status.go | 29 +++++-- 3 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 internal/helpers/drive_remote_duplicate_test.go diff --git a/internal/helpers/drive_push.go b/internal/helpers/drive_push.go index d1d10ec2..42cf2242 100644 --- a/internal/helpers/drive_push.go +++ b/internal/helpers/drive_push.go @@ -223,13 +223,14 @@ func runDrivePush(cmd *cobra.Command, _ []string) error { func fetchRemoteTreeForPush(ctx context.Context, spaceID, rootID string) (map[string]*remoteFile, map[string]string, error) { files := make(map[string]*remoteFile) folders := map[string]string{"": rootID} - if err := walkRemoteForPush(ctx, spaceID, rootID, "", files, folders, 0); err != nil { + occupied := make(map[string]string) + if err := walkRemoteForPush(ctx, spaceID, rootID, "", files, folders, occupied, 0); err != nil { return nil, nil, err } return files, folders, nil } -func walkRemoteForPush(ctx context.Context, spaceID, parentID, relBase string, files map[string]*remoteFile, folders map[string]string, depth int) error { +func walkRemoteForPush(ctx context.Context, spaceID, parentID, relBase string, files map[string]*remoteFile, folders map[string]string, occupied map[string]string, depth int) error { if depth > remoteMaxDepth { return fmt.Errorf("drive 目录层级超过 %d 层,疑似循环引用,已中止", remoteMaxDepth) } @@ -270,8 +271,11 @@ func walkRemoteForPush(ctx context.Context, spaceID, parentID, relBase string, f childRel = relBase + "/" + name } if it.isFolder() { + if err := claimRemotePath(occupied, childRel, "目录"); err != nil { + return err + } folders[childRel] = it.id() - if err := walkRemoteForPush(ctx, spaceID, it.id(), childRel, files, folders, depth+1); err != nil { + if err := walkRemoteForPush(ctx, spaceID, it.id(), childRel, files, folders, occupied, depth+1); err != nil { return err } continue @@ -279,6 +283,9 @@ func walkRemoteForPush(ctx context.Context, spaceID, parentID, relBase string, f if !it.isFile() { continue } + if err := claimRemotePath(occupied, childRel, "文件"); err != nil { + return err + } modMillis, modValid := it.modifiedMillis() files[childRel] = &remoteFile{ RelPath: childRel, diff --git a/internal/helpers/drive_remote_duplicate_test.go b/internal/helpers/drive_remote_duplicate_test.go new file mode 100644 index 00000000..d8b25830 --- /dev/null +++ b/internal/helpers/drive_remote_duplicate_test.go @@ -0,0 +1,86 @@ +package helpers + +import ( + "context" + "io" + "os" + "strings" + "testing" +) + +func TestCrossPlatformCoverageDriveRemoteTree_duplicatePathsFail(t *testing.T) { + tests := []struct { + name string + reply func(string, map[string]any, int) (string, error) + path string + }{ + { + name: "same file path across pages", + path: "duplicate.txt", + reply: func(_ string, args map[string]any, _ int) (string, error) { + if _, ok := args["nextToken"]; !ok { + return `{"result":{"items":[{"name":"duplicate.txt","type":"file","fileId":"F1"}],"nextToken":"PAGE2"}}`, nil + } + return `{"result":{"items":[{"name":"duplicate.txt","type":"file","fileId":"F2"}],"nextToken":""}}`, nil + }, + }, + { + name: "same folder path with duplicate descendants", + path: "duplicate", + reply: func(_ string, args map[string]any, _ int) (string, error) { + switch args["parentId"] { + case "ROOT": + return `{"result":{"items":[` + + `{"name":"duplicate","type":"folder","fileId":"D1"},` + + `{"name":"duplicate","type":"folder","fileId":"D2"}` + + `],"nextToken":""}}`, nil + case "D1": + return `{"result":{"items":[{"name":"child.txt","type":"file","fileId":"C1"}],"nextToken":""}}`, nil + case "D2": + return `{"result":{"items":[{"name":"child.txt","type":"file","fileId":"C2"}],"nextToken":""}}`, nil + default: + return `{"result":{"items":[],"nextToken":""}}`, nil + } + }, + }, + } + + fetchers := []struct { + name string + fetch func(context.Context) error + }{ + { + name: "status and pull tree", + fetch: func(ctx context.Context) error { + _, err := fetchRemoteDriveTree(ctx, "", "ROOT", true) + return err + }, + }, + { + name: "push and sync tree", + fetch: func(ctx context.Context) error { + _, _, err := fetchRemoteTreeForPush(ctx, "", "ROOT") + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, fetcher := range fetchers { + t.Run(fetcher.name, func(t *testing.T) { + caller := &driveScriptCaller{reply: tt.reply} + prevDeps, prevArgs := deps, os.Args + deps = &Deps{Caller: caller, Out: &Formatter{w: io.Discard}} + os.Args = []string{"dws", "drive"} + t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs }) + + err := fetcher.fetch(context.Background()) + if err == nil || !strings.Contains(err.Error(), "重复远端路径") || !strings.Contains(err.Error(), tt.path) { + t.Fatalf("error = %v, want duplicate remote path %q", err, tt.path) + } + }) + } + }) + } +} diff --git a/internal/helpers/drive_status.go b/internal/helpers/drive_status.go index b62afa8b..1da6b41b 100644 --- a/internal/helpers/drive_status.go +++ b/internal/helpers/drive_status.go @@ -91,17 +91,18 @@ const remoteMaxDepth = 64 // - 只纳入明确 type=FILE 的条目,其余类型(在线文档、快捷方式等)一律跳过。 func fetchRemoteDriveTree(ctx context.Context, spaceID, parentID string, quick bool) (map[string]*remoteFile, error) { out := make(map[string]*remoteFile) - if err := walkRemoteDir(ctx, spaceID, parentID, "", out, 0); err != nil { + occupied := make(map[string]string) + if err := walkRemoteDir(ctx, spaceID, parentID, "", out, occupied, 0); err != nil { return nil, err } _ = quick // hash 与 modified_time 都会被填充,比对模式由 compareTrees 决定 return out, nil } -// walkRemoteDir 遍历 parentID 指向的文件夹,把其中的二进制文件写入 out(key 为 rel_path, -// 同名后者覆盖),并递归进入子文件夹。relBase 是当前文件夹相对遍历起点的路径前缀, -// 用于拼接子项的 rel_path。 -func walkRemoteDir(ctx context.Context, spaceID, parentID, relBase string, out map[string]*remoteFile, depth int) error { +// walkRemoteDir 遍历 parentID 指向的文件夹,把其中的二进制文件写入 out(key 为 rel_path), +// 并递归进入子文件夹。occupied 同时记录文件和目录;精确路径被第二个条目占用时立即失败, +// 防止分页或返回顺序决定最终镜像。relBase 是当前文件夹相对遍历起点的路径前缀。 +func walkRemoteDir(ctx context.Context, spaceID, parentID, relBase string, out map[string]*remoteFile, occupied map[string]string, depth int) error { if depth > remoteMaxDepth { return fmt.Errorf("drive 目录层级超过 %d 层,疑似循环引用,已中止", remoteMaxDepth) } @@ -149,8 +150,11 @@ func walkRemoteDir(ctx context.Context, spaceID, parentID, relBase string, out m } if it.isFolder() { + if err := claimRemotePath(occupied, childRel, "目录"); err != nil { + return err + } // 用子文件夹的 dentryUuid 作为下一层 parentId 递归进入。 - if err := walkRemoteDir(ctx, spaceID, it.id(), childRel, out, depth+1); err != nil { + if err := walkRemoteDir(ctx, spaceID, it.id(), childRel, out, occupied, depth+1); err != nil { return err } continue @@ -159,6 +163,9 @@ func walkRemoteDir(ctx context.Context, spaceID, parentID, relBase string, out m if !it.isFile() { continue } + if err := claimRemotePath(occupied, childRel, "文件"); err != nil { + return err + } modMillis, modValid := it.modifiedMillis() out[childRel] = &remoteFile{ @@ -180,6 +187,16 @@ func walkRemoteDir(ctx context.Context, spaceID, parentID, relBase string, out m return nil } +// claimRemotePath 保证一棵远端树中的每个精确 rel_path 只对应一个文件或目录。 +// 重复条目会让本地镜像不可完整表达,因此必须失败,不能任意选择其中一个 fileId。 +func claimRemotePath(occupied map[string]string, rel, kind string) error { + if previous, exists := occupied[rel]; exists { + return fmt.Errorf("检测到重复远端路径 %q(已有%s,重复%s),无法构建确定性镜像", rel, previous, kind) + } + occupied[rel] = kind + return nil +} + // callDriveListFiles 让文件夹同步族在 --dry-run 下仍可通过受控只读通道获取 // 远端现状;普通 CallTool 在 dry-run 下只会返回执行计划,不能被当作业务数据解析。 func callDriveListFiles(ctx context.Context, args map[string]any) (string, error) { From 6bf78f1783f20fed1b45cd733cd406e9d25b4e3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E8=B0=A8?= <2940480744@qq.com> Date: Wed, 12 Aug 2026 22:46:05 +0800 Subject: [PATCH 29/86] test(ci): isolate app race partitions --- .github/workflows/ci.yml | 19 ++-- internal/app/pat_auth_retry_test.go | 11 ++ internal/app/testmain_test.go | 4 + scripts/ci/run-app-race-tests.sh | 126 ++++++++++++++++++++++ test/scripts/changelog_pr_gate_test.go | 13 ++- test/scripts/ci_test_package_plan_test.go | 19 ++++ 6 files changed, 181 insertions(+), 11 deletions(-) create mode 100755 scripts/ci/run-app-race-tests.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4707eb50..cff2c0dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -501,7 +501,8 @@ jobs: needs: lint if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }} runs-on: ubuntu-latest - # cli/smoke shards need headroom beyond go test -timeout for setup + assembly. + # app runs several independently bounded processes; cli/smoke need headroom + # beyond go test -timeout for setup + assembly. timeout-minutes: 20 strategy: fail-fast: false @@ -537,12 +538,18 @@ jobs: test -n "$package_output" mapfile -t packages <<< "$package_output" test "${#packages[@]}" -gt 0 - # app/cli/smoke own heavy NewRootCommand / Schema assembly under - # -race; give them a dedicated budget so slower hosted runners do not - # hit the package timeout while the shards remain independently bounded. + if [ "$TEST_SHARD" = "app" ]; then + # A single long-lived app test process retains every constructed + # command tree in framework registries. Isolate Schema assembly and + # bounded name ranges so each process releases that state on exit. + test "${#packages[@]}" -eq 1 + ./scripts/ci/run-app-race-tests.sh run "${packages[0]}" + exit 0 + fi + # cli/smoke own heavy NewRootCommand / Schema assembly under -race; + # give them a dedicated package timeout on slower hosted runners. timeout_budget=12m - if [ "$TEST_SHARD" = "app" ] || - [ "$TEST_SHARD" = "cli" ] || + if [ "$TEST_SHARD" = "cli" ] || [ "$TEST_SHARD" = "smoke" ]; then timeout_budget=15m fi diff --git a/internal/app/pat_auth_retry_test.go b/internal/app/pat_auth_retry_test.go index 11b8eae5..2d803e37 100644 --- a/internal/app/pat_auth_retry_test.go +++ b/internal/app/pat_auth_retry_test.go @@ -30,6 +30,7 @@ import ( apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct" ) @@ -218,8 +219,16 @@ func TestPatScopeError_Error(t *testing.T) { // /cli/oauth/device/poll?flowId= with the given status sequence. // It also writes the server URL into a temp DWS_CONFIG_DIR/mcp_url so that // GetMCPBaseURL() returns the test server address. +func stubPATPollAccessToken(t *testing.T) { + t.Helper() + testseam.Swap(t, &patResolveAccessToken, func(context.Context, string, string) (string, error) { + return "", authpkg.ErrTokenDataNotFound + }) +} + func setupPollServer(t *testing.T, statuses []authpkg.DevicePollResponse) (*httptest.Server, string) { t.Helper() + stubPATPollAccessToken(t) var callCount atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -381,6 +390,7 @@ func TestPollPatDeviceFlow_ServerErrorFallback(t *testing.T) { } func TestPollPatDeviceFlow_RedirectSkipped(t *testing.T) { + stubPATPollAccessToken(t) // When server returns 302 (SSO redirect), poll should continue until real response. var callCount int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -540,6 +550,7 @@ func (m *mockRunner) Run(ctx context.Context, inv executor.Invocation) (executor // It responds to device poll requests with the given status after the first poll. func setupHandlePATServer(t *testing.T, terminalStatus string, authCode string) (*httptest.Server, string) { t.Helper() + stubPATPollAccessToken(t) var pollCount atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/app/testmain_test.go b/internal/app/testmain_test.go index 5bed65f4..34e1adaa 100644 --- a/internal/app/testmain_test.go +++ b/internal/app/testmain_test.go @@ -83,6 +83,10 @@ func TestMain(m *testing.M) { // remove a TempDir while the audit lock is still open. setupAuditSink() openBrowserFunc = func(string) error { return nil } + // App tests may run in filtered CI processes. Install the same lazy Schema + // source factory as NewRootCommand without constructing a root so ResolveMeta + // tests never depend on an earlier test having initialized process state. + registerSchemaRuntimeDelivery() code := m.Run() StopAllStdioClients() CloseAuditSink() diff --git a/scripts/ci/run-app-race-tests.sh b/scripts/ci/run-app-race-tests.sh new file mode 100755 index 00000000..9922e9ab --- /dev/null +++ b/scripts/ci/run-app-race-tests.sh @@ -0,0 +1,126 @@ +#!/bin/sh +set -eu + +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" + +usage() { + printf '%s\n' \ + "usage: $0 verify " \ + " $0 run " >&2 + exit 2 +} + +[ "$#" -eq 2 ] || usage +mode="$1" +app_package="$2" + +case "$mode" in + verify|run) ;; + *) usage ;; +esac + +workdir="$(mktemp -d "${TMPDIR:-/tmp}/dws-app-race-tests.XXXXXX")" +trap 'rm -rf "$workdir"' EXIT HUP INT TERM + +tests="$workdir/tests" +duplicates="$workdir/duplicates" +list_output="$workdir/list-output" + +cd "$ROOT" +if ! go test "$app_package" -list '^(Test|Example|Fuzz)' > "$list_output"; then + printf 'app race partition discovery failed for %s\n' "$app_package" >&2 + exit 1 +fi +awk '/^(Test|Example|Fuzz)/ { print $1 }' "$list_output" > "$tests" + +if [ ! -s "$tests" ]; then + printf 'app race partition discovery found no tests in %s\n' "$app_package" >&2 + exit 1 +fi + +LC_ALL=C sort "$tests" | uniq -d > "$duplicates" +if [ -s "$duplicates" ]; then + printf '%s\n' 'app race partition discovery found duplicate top-level tests:' >&2 + sed 's/^/ /' "$duplicates" >&2 + exit 1 +fi + +schema_count=0 +ab_count=0 +c_count=0 +dr_count=0 +sz_count=0 +unmatched_count=0 + +while IFS= read -r test_name; do + case "$test_name" in + Test*Schema*) schema_count=$((schema_count + 1)) ;; + Test[A-B]*) ab_count=$((ab_count + 1)) ;; + TestC*) c_count=$((c_count + 1)) ;; + Test[D-R]*) dr_count=$((dr_count + 1)) ;; + Test[S-Z]*|Example*|Fuzz*) sz_count=$((sz_count + 1)) ;; + *) + printf 'unmatched app race test: %s\n' "$test_name" >&2 + unmatched_count=$((unmatched_count + 1)) + ;; + esac +done < "$tests" + +if [ "$unmatched_count" -ne 0 ]; then + exit 1 +fi + +for partition in \ + "schema:$schema_count" \ + "a-b:$ab_count" \ + "c:$c_count" \ + "d-r:$dr_count" \ + "s-z-example-fuzz:$sz_count" +do + name="${partition%%:*}" + count="${partition#*:}" + if [ "$count" -eq 0 ]; then + printf 'app race partition %s is empty\n' "$name" >&2 + exit 1 + fi +done + +total_count="$(wc -l < "$tests" | tr -d ' ')" +assigned_count=$((schema_count + ab_count + c_count + dr_count + sz_count)) +if [ "$assigned_count" -ne "$total_count" ]; then + printf 'app race partitions assigned %s tests, want %s\n' "$assigned_count" "$total_count" >&2 + exit 1 +fi + +printf 'app race partitions cover %s top-level tests exactly once: schema=%s a-b=%s c=%s d-r=%s s-z-example-fuzz=%s\n' \ + "$total_count" "$schema_count" "$ab_count" "$c_count" "$dr_count" "$sz_count" + +if [ "$mode" = "verify" ]; then + exit 0 +fi + +run_partition() { + name="$1" + run_pattern="$2" + skip_pattern="${3:-}" + + printf 'running internal/app race partition %s\n' "$name" + if [ -n "$skip_pattern" ]; then + go test -v -race -count=1 -timeout=15m \ + -run "$run_pattern" -skip "$skip_pattern" "$app_package" + else + go test -v -race -count=1 -timeout=15m \ + -run "$run_pattern" "$app_package" + fi +} + +# Schema assembly has the largest transient memory footprint. Run those tests +# in a fresh process, then keep each remaining name range in its own process so +# command trees retained by process-global registries are released between +# partitions. The complementary run/skip patterns preserve the full test set. +schema_pattern='^Test.*Schema' +run_partition schema "$schema_pattern" +run_partition a-b '^Test[A-B]' "$schema_pattern" +run_partition c '^TestC' "$schema_pattern" +run_partition d-r '^Test[D-R]' "$schema_pattern" +run_partition s-z-example-fuzz '^(Test[S-Z]|Example|Fuzz)' "$schema_pattern" diff --git a/test/scripts/changelog_pr_gate_test.go b/test/scripts/changelog_pr_gate_test.go index d01f366e..f8d91288 100644 --- a/test/scripts/changelog_pr_gate_test.go +++ b/test/scripts/changelog_pr_gate_test.go @@ -1182,13 +1182,16 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) { t.Fatal("Code Admission workflow missing race test job boundaries") } raceJob := admission[raceStart:raceEnd] - // Full race shards use a dynamic package-level timeout: default/floor 12m, - // with app/cli/smoke raised to 15m for NewRootCommand / Schema assembly under - // -race on slower hosted runners. + // The app shard uses independently bounded test processes so process-global + // command registries are released before the Schema assembly peak. Other full + // race shards retain the dynamic package timeout: default/floor 12m, with + // cli/smoke raised to 15m on slower hosted runners. for _, want := range []string{ + `if [ "$TEST_SHARD" = "app" ]; then`, + `test "${#packages[@]}" -eq 1`, + `./scripts/ci/run-app-race-tests.sh run "${packages[0]}"`, "timeout_budget=12m", - `if [ "$TEST_SHARD" = "app" ] ||`, - `[ "$TEST_SHARD" = "cli" ] ||`, + `if [ "$TEST_SHARD" = "cli" ] ||`, `[ "$TEST_SHARD" = "smoke" ]; then`, "timeout_budget=15m", `go test -v -race -count=1 -timeout="$timeout_budget" "${packages[@]}"`, diff --git a/test/scripts/ci_test_package_plan_test.go b/test/scripts/ci_test_package_plan_test.go index 51a1943a..136ce8ec 100644 --- a/test/scripts/ci_test_package_plan_test.go +++ b/test/scripts/ci_test_package_plan_test.go @@ -47,6 +47,25 @@ func TestCITestPackagePlanRoutesPublicTestSuites(t *testing.T) { } } +func TestCIAppRacePartitionsCoverTopLevelTestsExactlyOnce(t *testing.T) { + root := testPackagePlanRoot(t) + packages := strings.Fields(runTestPackagePlan(t, root, "list", "app")) + if len(packages) != 1 { + t.Fatalf("app package shard = %v, want exactly one package", packages) + } + + script := filepath.Join(root, "scripts", "ci", "run-app-race-tests.sh") + cmd := exec.Command("sh", script, "verify", packages[0]) + cmd.Dir = root + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s verify %s failed: %v\n%s", script, packages[0], err, output) + } + if !strings.Contains(string(output), "top-level tests exactly once") { + t.Fatalf("verify output = %q, want exact coverage summary", output) + } +} + func TestCITestPackagePlanFailsClosedWhenGoListFails(t *testing.T) { root := testPackagePlanRoot(t) fakeBin := t.TempDir() From 4d13905cb86d8abfb460f787a00356228409ee1f Mon Sep 17 00:00:00 2001 From: chichuan Date: Wed, 12 Aug 2026 23:06:55 +0800 Subject: [PATCH 30/86] fix(drive): fail closed on invalid remote folders Use explicit platform replace semantics for pull and sync, and reject recursive folder entries without a supported non-empty node ID. --- internal/helpers/drive_pull.go | 2 +- internal/helpers/drive_push.go | 8 +- .../helpers/drive_remote_folder_id_test.go | 79 +++++++++++++++++++ internal/helpers/drive_replace_unix.go | 11 +++ internal/helpers/drive_replace_windows.go | 11 +++ .../helpers/drive_replace_windows_test.go | 33 ++++++++ internal/helpers/drive_status.go | 8 +- internal/helpers/drive_sync.go | 2 +- 8 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 internal/helpers/drive_remote_folder_id_test.go create mode 100644 internal/helpers/drive_replace_unix.go create mode 100644 internal/helpers/drive_replace_windows.go create mode 100644 internal/helpers/drive_replace_windows_test.go diff --git a/internal/helpers/drive_pull.go b/internal/helpers/drive_pull.go index ceeea8c5..c894d7f3 100644 --- a/internal/helpers/drive_pull.go +++ b/internal/helpers/drive_pull.go @@ -349,7 +349,7 @@ func pullOneFile(ctx context.Context, spaceID string, rf *remoteFile, localPath, t := time.UnixMilli(rf.ModifiedTime) _ = os.Chtimes(tmpPath, t, t) } - if err := os.Rename(tmpPath, localPath); err != nil { + if err := driveReplaceFile(tmpPath, localPath); err != nil { return pullActionFailed, fmt.Errorf("替换目标文件失败: %w", err) } committed = true diff --git a/internal/helpers/drive_push.go b/internal/helpers/drive_push.go index 42cf2242..5d790824 100644 --- a/internal/helpers/drive_push.go +++ b/internal/helpers/drive_push.go @@ -274,8 +274,12 @@ func walkRemoteForPush(ctx context.Context, spaceID, parentID, relBase string, f if err := claimRemotePath(occupied, childRel, "目录"); err != nil { return err } - folders[childRel] = it.id() - if err := walkRemoteForPush(ctx, spaceID, it.id(), childRel, files, folders, occupied, depth+1); err != nil { + folderID := it.id() + if folderID == "" { + return fmt.Errorf("远端目录 %q 缺少目录 ID,已中止递归", childRel) + } + folders[childRel] = folderID + if err := walkRemoteForPush(ctx, spaceID, folderID, childRel, files, folders, occupied, depth+1); err != nil { return err } continue diff --git a/internal/helpers/drive_remote_folder_id_test.go b/internal/helpers/drive_remote_folder_id_test.go new file mode 100644 index 00000000..364a6429 --- /dev/null +++ b/internal/helpers/drive_remote_folder_id_test.go @@ -0,0 +1,79 @@ +package helpers + +import ( + "context" + "io" + "os" + "strings" + "testing" +) + +func TestCrossPlatformCoverageDriveRemoteTree_folderIDFallbacks(t *testing.T) { + for _, key := range []string{"fileId", "dentryUuid", "dentryId", "id", "nodeId"} { + t.Run(key, func(t *testing.T) { + caller := &driveScriptCaller{reply: func(_ string, args map[string]any, _ int) (string, error) { + if args["parentId"] == "ROOT" { + return `{"result":{"items":[{"name":"sub","type":"folder","` + key + `":"SUB"}],"nextToken":""}}`, nil + } + if args["parentId"] != "SUB" { + t.Fatalf("nested parentId = %v, want SUB", args["parentId"]) + } + return `{"result":{"items":[],"nextToken":""}}`, nil + }} + prevDeps, prevArgs := deps, os.Args + deps = &Deps{Caller: caller, Out: &Formatter{w: io.Discard}} + os.Args = []string{"dws", "drive"} + t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs }) + + if _, err := fetchRemoteDriveTree(context.Background(), "", "ROOT", true); err != nil { + t.Fatalf("fetch with %s: %v", key, err) + } + if got := len(caller.callsFor("list_files")); got != 2 { + t.Fatalf("list_files calls = %d, want root + nested", got) + } + }) + } +} + +func TestCrossPlatformCoverageDriveRemoteTree_missingFolderIDFailsClosed(t *testing.T) { + for _, fetcher := range []struct { + name string + fetch func(context.Context) error + }{ + { + name: "status and pull tree", + fetch: func(ctx context.Context) error { + _, err := fetchRemoteDriveTree(ctx, "", "ROOT", true) + return err + }, + }, + { + name: "push and sync tree", + fetch: func(ctx context.Context) error { + _, _, err := fetchRemoteTreeForPush(ctx, "", "ROOT") + return err + }, + }, + } { + t.Run(fetcher.name, func(t *testing.T) { + caller := &driveScriptCaller{reply: func(_ string, args map[string]any, nth int) (string, error) { + if nth > 0 || args["parentId"] != "ROOT" { + t.Fatalf("missing folder ID must not trigger another list_files call: nth=%d args=%v", nth, args) + } + return `{"result":{"items":[{"name":"sub","type":"folder"}],"nextToken":""}}`, nil + }} + prevDeps, prevArgs := deps, os.Args + deps = &Deps{Caller: caller, Out: &Formatter{w: io.Discard}} + os.Args = []string{"dws", "drive"} + t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs }) + + err := fetcher.fetch(context.Background()) + if err == nil || !strings.Contains(err.Error(), "目录 ID") || !strings.Contains(err.Error(), "sub") { + t.Fatalf("error = %v, want missing folder ID for sub", err) + } + if got := len(caller.callsFor("list_files")); got != 1 { + t.Fatalf("list_files calls = %d, want only root query", got) + } + }) + } +} diff --git a/internal/helpers/drive_replace_unix.go b/internal/helpers/drive_replace_unix.go new file mode 100644 index 00000000..8e10f947 --- /dev/null +++ b/internal/helpers/drive_replace_unix.go @@ -0,0 +1,11 @@ +//go:build !windows + +package helpers + +import "os" + +// driveReplaceFile 在同一文件系统内以 source 替换既有非目录 target。 +// Unix 的 rename(2) 原生提供原子替换语义。 +func driveReplaceFile(source, target string) error { + return os.Rename(source, target) +} diff --git a/internal/helpers/drive_replace_windows.go b/internal/helpers/drive_replace_windows.go new file mode 100644 index 00000000..2d729549 --- /dev/null +++ b/internal/helpers/drive_replace_windows.go @@ -0,0 +1,11 @@ +//go:build windows + +package helpers + +import "golang.org/x/sys/windows" + +// driveReplaceFile 使用 MoveFileEx(MOVEFILE_REPLACE_EXISTING) 替换既有非目录目标。 +// 不使用“先删除再改名”,避免覆盖过程中出现目标暂时缺失或原文件丢失。 +func driveReplaceFile(source, target string) error { + return windows.Rename(source, target) +} diff --git a/internal/helpers/drive_replace_windows_test.go b/internal/helpers/drive_replace_windows_test.go new file mode 100644 index 00000000..7efa8827 --- /dev/null +++ b/internal/helpers/drive_replace_windows_test.go @@ -0,0 +1,33 @@ +//go:build windows + +package helpers + +import ( + "os" + "path/filepath" + "testing" +) + +// Windows 必须通过 MOVEFILE_REPLACE_EXISTING 原子替换既有非目录目标;该路径同时 +// 服务 pull overwrite、sync remote-wins 与 keep-both 的占位文件替换。 +func TestCrossPlatformCoverageDriveReplaceFileWindows_replacesExistingTarget(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source.tmp") + target := filepath.Join(dir, "target.txt") + if err := os.WriteFile(source, []byte("new"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + + if err := driveReplaceFile(source, target); err != nil { + t.Fatalf("replace existing target: %v", err) + } + if got, err := os.ReadFile(target); err != nil || string(got) != "new" { + t.Fatalf("target = %q, err=%v; want new content", got, err) + } + if _, err := os.Stat(source); !os.IsNotExist(err) { + t.Fatalf("source must be moved, stat err=%v", err) + } +} diff --git a/internal/helpers/drive_status.go b/internal/helpers/drive_status.go index 1da6b41b..c1075e73 100644 --- a/internal/helpers/drive_status.go +++ b/internal/helpers/drive_status.go @@ -153,8 +153,12 @@ func walkRemoteDir(ctx context.Context, spaceID, parentID, relBase string, out m if err := claimRemotePath(occupied, childRel, "目录"); err != nil { return err } + folderID := it.id() + if folderID == "" { + return fmt.Errorf("远端目录 %q 缺少目录 ID,已中止递归", childRel) + } // 用子文件夹的 dentryUuid 作为下一层 parentId 递归进入。 - if err := walkRemoteDir(ctx, spaceID, it.id(), childRel, out, occupied, depth+1); err != nil { + if err := walkRemoteDir(ctx, spaceID, folderID, childRel, out, occupied, depth+1); err != nil { return err } continue @@ -220,7 +224,7 @@ func (d driveItem) str(keys ...string) string { } func (d driveItem) name() string { return d.str("name") } -func (d driveItem) id() string { return d.str("fileId") } +func (d driveItem) id() string { return d.str("fileId", "dentryUuid", "dentryId", "id", "nodeId") } func (d driveItem) typ() string { return d.str("type") } func (d driveItem) hash() string { return d.str("md5") } func (d driveItem) path() string { return d.str("path") } diff --git a/internal/helpers/drive_sync.go b/internal/helpers/drive_sync.go index 79e06e4a..7400add3 100644 --- a/internal/helpers/drive_sync.go +++ b/internal/helpers/drive_sync.go @@ -17,7 +17,7 @@ import ( // syncRename 与 syncOpenFile 仅作为文件系统失败分支的确定性测试 seam;测试必须 // 通过 testseam.Swap 替换并自动恢复。 var ( - syncRename = os.Rename + syncRename = driveReplaceFile syncOpenFile = os.OpenFile ) From b92ac4db0fab5603b3b35c183e21bb9f87ffb548 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Wed, 12 Aug 2026 23:16:33 +0800 Subject: [PATCH 31/86] fix(chat): preserve list-all time format --- internal/helpers/chat.go | 15 +++--- internal/helpers/chat_command_edges_test.go | 2 + .../helpers/chat_message_pagination_test.go | 52 ++++++++++++++++--- 3 files changed, 57 insertions(+), 12 deletions(-) diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 464095b8..532a1584 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -475,17 +475,16 @@ func chatMessageListAllArgs(cmd *cobra.Command) (map[string]any, error) { if err != nil { return nil, err } - endMs, err := parseISOTimeToMillis("end", endRaw) - if err != nil { - return nil, err - } + endMs, _ := parseISOTimeToMillis("end", endRaw) if err := validateTimeRange(startMs, endMs); err != nil { return nil, err } + startTime := formatChatMessageListAllTime(startMs) + endTime := formatChatMessageListAllTime(endMs) cursor, _ := cmd.Flags().GetString("cursor") return map[string]any{ - "startTime": startRaw, - "endTime": endRaw, + "startTime": startTime, + "endTime": endTime, "limit": chatIntFlagOrFallback(cmd, "limit", "size"), "cursor": cursor, }, nil @@ -622,6 +621,10 @@ func defaultChatMessageTimeRange(cmd *cobra.Command, lookback time.Duration) (st return startRaw, endRaw, nil } +func formatChatMessageListAllTime(ms int64) string { + return time.UnixMilli(ms).In(shanghaiLocation()).Format("2006-01-02 15:04:05") +} + func chatMessageSearchAdvancedArgs(cmd *cobra.Command) (map[string]any, error) { toolArgs := map[string]any{} if v := flagOrFallback(cmd, "query", "keyword"); v != "" { diff --git a/internal/helpers/chat_command_edges_test.go b/internal/helpers/chat_command_edges_test.go index 6b12c044..cadc2207 100644 --- a/internal/helpers/chat_command_edges_test.go +++ b/internal/helpers/chat_command_edges_test.go @@ -168,6 +168,8 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T) {"message", "list", "--user=D-user", "--time=2026-01-01", "--limit=1"}, {"message", "list", "--open-dingtalk-id=D-user", "--time=2026-01-01", "--direction=sideways"}, {"message", "list-direct", "--user=D-user", "--limit=1"}, + {"message", "list-all", "--start=2026-01-01 00:00:00", "--end=bad"}, + {"message", "list-all", "--start=2026-01-02 00:00:00", "--end=2026-01-01 00:00:00"}, {"message", "list-by-sender", "--sender-user-id=u1", "--start=bad"}, {"message", "list-by-sender", "--sender-user-id=u1", "--start=2026-01-01T00:00:00Z", "--end=bad"}, {"message", "list-by-sender", "--sender-user-id=u1", "--start=2026-01-02T00:00:00Z", "--end=2026-01-01T00:00:00Z"}, diff --git a/internal/helpers/chat_message_pagination_test.go b/internal/helpers/chat_message_pagination_test.go index 221bffa3..78eca26c 100644 --- a/internal/helpers/chat_message_pagination_test.go +++ b/internal/helpers/chat_message_pagination_test.go @@ -198,8 +198,8 @@ func TestChatMessagePaginationUsesDefaultTimeWindows(t *testing.T) { if got.tool != tt.wantTool { t.Fatalf("tool = %q, want %q", got.tool, tt.wantTool) } - startMs := numericArgAsInt64(t, got.args["startTime"]) - endMs := numericArgAsInt64(t, got.args["endTime"]) + startMs := chatMessageTimeArgAsMillis(t, got.args["startTime"], tt.wantTool) + endMs := chatMessageTimeArgAsMillis(t, got.args["endTime"], tt.wantTool) wantEndMin := before.Truncate(time.Second).UnixMilli() wantEndMax := after.Add(time.Second).UnixMilli() if endMs < wantEndMin || endMs > wantEndMax { @@ -266,8 +266,8 @@ func TestChatMessagePaginationDefaultsStartFromExplicitEnd(t *testing.T) { if got.tool != tt.wantTool { t.Fatalf("tool = %q, want %q", got.tool, tt.wantTool) } - startMs := numericArgAsInt64(t, got.args["startTime"]) - gotEndMs := numericArgAsInt64(t, got.args["endTime"]) + startMs := chatMessageTimeArgAsMillis(t, got.args["startTime"], tt.wantTool) + gotEndMs := chatMessageTimeArgAsMillis(t, got.args["endTime"], tt.wantTool) if gotEndMs != endMs { t.Fatalf("endTime = %d, want %d", gotEndMs, endMs) } @@ -275,6 +275,10 @@ func TestChatMessagePaginationDefaultsStartFromExplicitEnd(t *testing.T) { if startMs != wantStartMs { t.Fatalf("startTime = %d, want %d", startMs, wantStartMs) } + if tt.wantTool == "search_messages_by_time_range" { + assertStringArg(t, got.args["startTime"], "2025-12-31 00:00:00") + assertStringArg(t, got.args["endTime"], "2026-01-01 00:00:00") + } }) } } @@ -328,16 +332,20 @@ func TestChatMessagePaginationDefaultsEndFromNowWhenOnlyStartProvided(t *testing if got.tool != tt.wantTool { t.Fatalf("tool = %q, want %q", got.tool, tt.wantTool) } - gotStartMs := numericArgAsInt64(t, got.args["startTime"]) + gotStartMs := chatMessageTimeArgAsMillis(t, got.args["startTime"], tt.wantTool) if gotStartMs != startMs { t.Fatalf("startTime = %d, want %d", gotStartMs, startMs) } - endMs := numericArgAsInt64(t, got.args["endTime"]) + endMs := chatMessageTimeArgAsMillis(t, got.args["endTime"], tt.wantTool) wantEndMin := before.Truncate(time.Second).UnixMilli() wantEndMax := after.Add(time.Second).UnixMilli() if endMs < wantEndMin || endMs > wantEndMax { t.Fatalf("endTime = %d, want between %d and %d", endMs, wantEndMin, wantEndMax) } + if tt.wantTool == "search_messages_by_time_range" { + assertStringArg(t, got.args["startTime"], "2026-01-01 00:00:00") + parseChatMessageListAllTimeArg(t, got.args["endTime"]) + } }) } } @@ -398,6 +406,38 @@ func numericArgAsInt64(t *testing.T, value any) int64 { } } +func chatMessageTimeArgAsMillis(t *testing.T, value any, tool string) int64 { + t.Helper() + if tool == "search_messages_by_time_range" { + return parseChatMessageListAllTimeArg(t, value).UnixMilli() + } + return numericArgAsInt64(t, value) +} + +func parseChatMessageListAllTimeArg(t *testing.T, value any) time.Time { + t.Helper() + raw, ok := value.(string) + if !ok { + t.Fatalf("time arg = %#v, want yyyy-MM-dd HH:mm:ss string", value) + } + if strings.Contains(raw, "T") { + t.Fatalf("time arg = %q, want yyyy-MM-dd HH:mm:ss without RFC3339 separator", raw) + } + parsed, err := time.ParseInLocation("2006-01-02 15:04:05", raw, shanghaiLocation()) + if err != nil { + t.Fatalf("time arg = %q, parse err = %v", raw, err) + } + return parsed +} + +func assertStringArg(t *testing.T, value any, want string) { + t.Helper() + got, ok := value.(string) + if !ok || got != want { + t.Fatalf("arg = %#v, want %q", value, want) + } +} + func TestChatMessagePaginationPageAllAggregatesSevenCommands(t *testing.T) { tests := []struct { name string From fad41d4d99c13c4ecbf22630b16cfa883c491ddb Mon Sep 17 00:00:00 2001 From: Dennis Date: Wed, 12 Aug 2026 12:29:07 +0800 Subject: [PATCH 32/86] fix(drive): verify upload and move readback --- internal/shortcut/drive/catalog_operations.go | 7 ++ internal/shortcut/drive/common.go | 35 +++++++++ internal/shortcut/drive/drive.go | 19 +++++ .../shortcut/drive/drive_shortcuts_test.go | 76 ++++++++++++++++++- 4 files changed, 136 insertions(+), 1 deletion(-) diff --git a/internal/shortcut/drive/catalog_operations.go b/internal/shortcut/drive/catalog_operations.go index 0a23fbf3..394a1638 100644 --- a/internal/shortcut/drive/catalog_operations.go +++ b/internal/shortcut/drive/catalog_operations.go @@ -489,6 +489,13 @@ var Upload = shortcut.Shortcut{ if remoteName := firstString(verified, "name", "fileName"); remoteName == "" || !strings.HasPrefix(remoteName, strings.TrimSuffix(name, filepath.Ext(name))) { return driveResponseError("drive/commit_upload", "readback_mismatch", fmt.Sprintf("上传后读回名称 %q 与请求 %q 不一致", remoteName, name)) } + remoteSize, ok := firstInt64(verified, "fileSize", "size", "byteSize", "length") + if !ok { + return driveResponseError("drive/commit_upload", "readback_missing_size", "上传后读回缺少有效文件大小;无法证明远端文件完整") + } + if remoteSize != info.Size() { + return driveResponseError("drive/commit_upload", "readback_size_mismatch", fmt.Sprintf("上传后读回大小 %d 与本地文件大小 %d 不一致", remoteSize, info.Size())) + } return rt.Output(map[string]any{"success": true, "nodeId": nodeID, "sizeBytes": info.Size(), "file": verified}) }, } diff --git a/internal/shortcut/drive/common.go b/internal/shortcut/drive/common.go index 220e5ffd..d528b10f 100644 --- a/internal/shortcut/drive/common.go +++ b/internal/shortcut/drive/common.go @@ -6,6 +6,8 @@ package drive import ( "encoding/json" "fmt" + "math" + "strconv" "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" @@ -216,6 +218,39 @@ func nestedString(data map[string]any, keys ...string) string { return "" } +func firstInt64(data map[string]any, keys ...string) (int64, bool) { + for _, key := range keys { + value, present := data[key] + if !present { + continue + } + switch typed := value.(type) { + case int: + return int64(typed), true + case int32: + return int64(typed), true + case int64: + return typed, true + case float64: + if !math.IsNaN(typed) && !math.IsInf(typed, 0) && typed == math.Trunc(typed) && typed >= math.MinInt64 && typed < math.MaxInt64 { + return int64(typed), true + } + case json.Number: + parsed, err := strconv.ParseInt(typed.String(), 10, 64) + if err == nil { + return parsed, true + } + case string: + parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) + if err == nil { + return parsed, true + } + } + return 0, false + } + return 0, false +} + func driveResponseError(operation, reason, message string) error { return apperrors.NewAPI(message, apperrors.WithOperation(operation), diff --git a/internal/shortcut/drive/drive.go b/internal/shortcut/drive/drive.go index 4cbf239b..507f7aad 100644 --- a/internal/shortcut/drive/drive.go +++ b/internal/shortcut/drive/drive.go @@ -20,6 +20,7 @@ package drive import ( + "fmt" "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" @@ -546,6 +547,24 @@ var Move = shortcut.Shortcut{ if err != nil { return err } + if rt.Changed("folder") { + remoteFolder := firstString(verified, "folderId", "targetFolderId", "parentId") + if remoteFolder == "" { + return driveResponseError("doc/move_document", "readback_missing_folder", "移动后读回缺少目标文件夹 ID;无法证明移动已到达请求位置") + } + if remoteFolder != rt.Str("folder") { + return driveResponseError("doc/move_document", "readback_folder_mismatch", fmt.Sprintf("移动后读回文件夹 %q 与请求 %q 不一致", remoteFolder, rt.Str("folder"))) + } + } + if rt.Changed("workspace") { + remoteWorkspace := firstString(verified, "workspaceId", "spaceId") + if remoteWorkspace == "" { + return driveResponseError("doc/move_document", "readback_missing_workspace", "移动后读回缺少目标知识库 ID;无法证明移动已到达请求位置") + } + if remoteWorkspace != rt.Str("workspace") { + return driveResponseError("doc/move_document", "readback_workspace_mismatch", fmt.Sprintf("移动后读回知识库 %q 与请求 %q 不一致", remoteWorkspace, rt.Str("workspace"))) + } + } return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "file": verified}) }, } diff --git a/internal/shortcut/drive/drive_shortcuts_test.go b/internal/shortcut/drive/drive_shortcuts_test.go index 6fddea7a..5ff95517 100644 --- a/internal/shortcut/drive/drive_shortcuts_test.go +++ b/internal/shortcut/drive/drive_shortcuts_test.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "io" + "math" "os" "path/filepath" "strings" @@ -176,6 +177,25 @@ func TestCrossPlatformCoverageDriveDownloadAndUploadRequireArtifactsAndReadback( if _, _, err := resolveDriveUploadInput("../escape.bin"); err == nil { t.Fatal("upload path escape was accepted") } + for _, tc := range []struct { + name string + readback string + want string + }{ + {"missing remote size", `{"success":true,"result":{"fileId":"uploaded-2","name":"input.bin"}}`, "缺少有效文件大小"}, + {"mismatched remote size", `{"success":true,"result":{"fileId":"uploaded-3","name":"input.bin","fileSize":17}}`, "与本地文件大小 18 不一致"}, + } { + t.Run(tc.name, func(t *testing.T) { + testseam.Swap(t, &uploadDriveFile, func(context.Context, helpers.DriveUploadRequest) (map[string]any, error) { + return map[string]any{"success": true, "result": map[string]any{"fileId": strings.TrimSuffix(tc.name, " remote size")}}, nil + }) + caller := &driveCoverageCaller{responses: map[string][]string{"get_file_info": {tc.readback}}} + err := runDriveCoverage(t, Upload, caller, "--file", "input.bin", "--yes") + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want %q", err, tc.want) + } + }) + } testseam.Swap(t, &driveDownload, func(_ context.Context, _ string, options localio.DownloadOptions) (localio.DownloadResult, error) { if options.Output != "downloads/file.bin" || options.Headers["x-token"] != "secret" { @@ -218,6 +238,39 @@ func TestCrossPlatformCoverageDriveCopyPreservesSchemaProperties(t *testing.T) { } } +func TestCrossPlatformCoverageDriveFirstInt64(t *testing.T) { + for _, tc := range []struct { + name string + value any + want int64 + ok bool + }{ + {"int", int(1), 1, true}, + {"int32", int32(2), 2, true}, + {"int64", int64(3), 3, true}, + {"float", float64(4), 4, true}, + {"json number", json.Number("5"), 5, true}, + {"string", " 6 ", 6, true}, + {"fraction", 1.5, 0, false}, + {"nan", math.NaN(), 0, false}, + {"infinity", math.Inf(1), 0, false}, + {"overflow", float64(math.MaxInt64), 0, false}, + {"bad json number", json.Number("bad"), 0, false}, + {"bad string", "bad", 0, false}, + {"unsupported", true, 0, false}, + } { + t.Run(tc.name, func(t *testing.T) { + got, ok := firstInt64(map[string]any{"size": tc.value}, "missing", "size") + if ok != tc.ok || got != tc.want { + t.Fatalf("firstInt64(%#v) = (%d, %t), want (%d, %t)", tc.value, got, ok, tc.want, tc.ok) + } + }) + } + if got, ok := firstInt64(map[string]any{}, "size"); ok || got != 0 { + t.Fatalf("missing firstInt64 = (%d, %t), want (0, false)", got, ok) + } +} + func TestCrossPlatformCoverageDriveVersionAndPublishContracts(t *testing.T) { versionPayload := `{"success":true,"versions":[{"version":1,"fileSize":3},{"versionNumber":"2","fileSize":4}],"hasMore":false}` caller := &driveCoverageCaller{responses: map[string][]string{"list_file_versions": {versionPayload}}} @@ -449,11 +502,32 @@ func TestCrossPlatformCoverageDriveCreateRestoreCopyMoveRename(t *testing.T) { move := &driveCoverageCaller{responses: map[string][]string{ "move_document": {`{"success":true}`}, - "get_document_info": {`{"success":true,"result":{"nodeId":"n1"}}`}, + "get_document_info": {`{"success":true,"result":{"nodeId":"n1","folderId":"target","workspaceId":"space"}}`}, }} if err := runDriveCoverage(t, Move, move, "--node", "n1", "--folder", "target", "--workspace", "space", "--yes"); err != nil { t.Fatal(err) } + for _, tc := range []struct { + name string + readback string + want string + }{ + {"missing folder", `{"success":true,"result":{"nodeId":"n1","workspaceId":"space"}}`, "缺少目标文件夹 ID"}, + {"wrong folder", `{"success":true,"result":{"nodeId":"n1","folderId":"other","workspaceId":"space"}}`, "与请求 \"target\" 不一致"}, + {"missing workspace", `{"success":true,"result":{"nodeId":"n1","folderId":"target"}}`, "缺少目标知识库 ID"}, + {"wrong workspace", `{"success":true,"result":{"nodeId":"n1","folderId":"target","workspaceId":"other"}}`, "与请求 \"space\" 不一致"}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &driveCoverageCaller{responses: map[string][]string{ + "move_document": {`{"success":true}`}, + "get_document_info": {tc.readback}, + }} + err := runDriveCoverage(t, Move, caller, "--node", "n1", "--folder", "target", "--workspace", "space", "--yes") + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want %q", err, tc.want) + } + }) + } rename := &driveCoverageCaller{responses: map[string][]string{ "get_file_info": {`{"success":true,"result":{"fileId":"n1","type":"FILE","extension":"md","name":"old.md"}}`, `{"success":true,"result":{"fileId":"n1","name":"new.md"}}`}, From 98d03455b14ad6feeec012cdf81c796c2e928465 Mon Sep 17 00:00:00 2001 From: Dennis Date: Wed, 12 Aug 2026 15:31:27 +0800 Subject: [PATCH 33/86] fix(drive): bind readback to requested objects --- internal/shortcut/drive/catalog_operations.go | 9 +++++- internal/shortcut/drive/common.go | 10 +++++- internal/shortcut/drive/drive.go | 7 ++++ .../shortcut/drive/drive_shortcuts_test.go | 32 +++++++++++++++---- 4 files changed, 50 insertions(+), 8 deletions(-) diff --git a/internal/shortcut/drive/catalog_operations.go b/internal/shortcut/drive/catalog_operations.go index 394a1638..6cfb71dd 100644 --- a/internal/shortcut/drive/catalog_operations.go +++ b/internal/shortcut/drive/catalog_operations.go @@ -486,7 +486,14 @@ var Upload = shortcut.Shortcut{ if err != nil { return err } - if remoteName := firstString(verified, "name", "fileName"); remoteName == "" || !strings.HasPrefix(remoteName, strings.TrimSuffix(name, filepath.Ext(name))) { + remoteID := firstString(verified, "fileId", "dentryUuid", "nodeId", "id") + if remoteID == "" { + return driveResponseError("drive/commit_upload", "readback_missing_id", "上传后读回缺少文件 ID;无法证明读回的是已提交文件") + } + if remoteID != nodeID { + return driveResponseError("drive/commit_upload", "readback_id_mismatch", fmt.Sprintf("上传后读回文件 ID %q 与提交 ID %q 不一致", remoteID, nodeID)) + } + if remoteName := firstString(verified, "name", "fileName"); !driveReadbackNameMatches(verified, name) { return driveResponseError("drive/commit_upload", "readback_mismatch", fmt.Sprintf("上传后读回名称 %q 与请求 %q 不一致", remoteName, name)) } remoteSize, ok := firstInt64(verified, "fileSize", "size", "byteSize", "length") diff --git a/internal/shortcut/drive/common.go b/internal/shortcut/drive/common.go index d528b10f..94b7da41 100644 --- a/internal/shortcut/drive/common.go +++ b/internal/shortcut/drive/common.go @@ -218,6 +218,15 @@ func nestedString(data map[string]any, keys ...string) string { return "" } +func driveReadbackNameMatches(data map[string]any, requested string) bool { + remoteName := firstString(data, "name", "fileName") + if remoteName == requested { + return true + } + extension := strings.TrimLeft(firstString(data, "extension", "fileExtension", "ext"), ".") + return extension != "" && remoteName+"."+extension == requested +} + func firstInt64(data map[string]any, keys ...string) (int64, bool) { for _, key := range keys { value, present := data[key] @@ -246,7 +255,6 @@ func firstInt64(data map[string]any, keys ...string) (int64, bool) { return parsed, true } } - return 0, false } return 0, false } diff --git a/internal/shortcut/drive/drive.go b/internal/shortcut/drive/drive.go index 507f7aad..cbc04f77 100644 --- a/internal/shortcut/drive/drive.go +++ b/internal/shortcut/drive/drive.go @@ -547,6 +547,13 @@ var Move = shortcut.Shortcut{ if err != nil { return err } + remoteID := firstString(verified, "nodeId", "fileId", "dentryUuid", "id") + if remoteID == "" { + return driveResponseError("doc/move_document", "readback_missing_id", "移动后读回缺少节点 ID;无法证明读回的是已移动节点") + } + if remoteID != rt.Str("node") { + return driveResponseError("doc/move_document", "readback_id_mismatch", fmt.Sprintf("移动后读回节点 %q 与请求节点 %q 不一致", remoteID, rt.Str("node"))) + } if rt.Changed("folder") { remoteFolder := firstString(verified, "folderId", "targetFolderId", "parentId") if remoteFolder == "" { diff --git a/internal/shortcut/drive/drive_shortcuts_test.go b/internal/shortcut/drive/drive_shortcuts_test.go index 5ff95517..b383e074 100644 --- a/internal/shortcut/drive/drive_shortcuts_test.go +++ b/internal/shortcut/drive/drive_shortcuts_test.go @@ -178,16 +178,20 @@ func TestCrossPlatformCoverageDriveDownloadAndUploadRequireArtifactsAndReadback( t.Fatal("upload path escape was accepted") } for _, tc := range []struct { - name string - readback string - want string + name string + committedID string + readback string + want string }{ - {"missing remote size", `{"success":true,"result":{"fileId":"uploaded-2","name":"input.bin"}}`, "缺少有效文件大小"}, - {"mismatched remote size", `{"success":true,"result":{"fileId":"uploaded-3","name":"input.bin","fileSize":17}}`, "与本地文件大小 18 不一致"}, + {"missing remote id", "uploaded-2", `{"success":true,"result":{"name":"input.bin","fileSize":18}}`, "缺少文件 ID"}, + {"mismatched remote id", "uploaded-3", `{"success":true,"result":{"fileId":"other","name":"input.bin","fileSize":18}}`, "与提交 ID"}, + {"prefix-only remote name", "uploaded-4", `{"success":true,"result":{"fileId":"uploaded-4","name":"input.bin-old","fileSize":18}}`, "读回名称"}, + {"missing remote size", "uploaded-5", `{"success":true,"result":{"fileId":"uploaded-5","name":"input.bin"}}`, "缺少有效文件大小"}, + {"mismatched remote size", "uploaded-6", `{"success":true,"result":{"fileId":"uploaded-6","name":"input.bin","fileSize":17}}`, "与本地文件大小 18 不一致"}, } { t.Run(tc.name, func(t *testing.T) { testseam.Swap(t, &uploadDriveFile, func(context.Context, helpers.DriveUploadRequest) (map[string]any, error) { - return map[string]any{"success": true, "result": map[string]any{"fileId": strings.TrimSuffix(tc.name, " remote size")}}, nil + return map[string]any{"success": true, "result": map[string]any{"fileId": tc.committedID}}, nil }) caller := &driveCoverageCaller{responses: map[string][]string{"get_file_info": {tc.readback}}} err := runDriveCoverage(t, Upload, caller, "--file", "input.bin", "--yes") @@ -196,6 +200,17 @@ func TestCrossPlatformCoverageDriveDownloadAndUploadRequireArtifactsAndReadback( } }) } + t.Run("split remote extension", func(t *testing.T) { + testseam.Swap(t, &uploadDriveFile, func(context.Context, helpers.DriveUploadRequest) (map[string]any, error) { + return map[string]any{"success": true, "result": map[string]any{"fileId": "uploaded-7"}}, nil + }) + caller := &driveCoverageCaller{responses: map[string][]string{ + "get_file_info": {`{"success":true,"result":{"fileId":"uploaded-7","name":"input","extension":"bin","fileSize":18}}`}, + }} + if err := runDriveCoverage(t, Upload, caller, "--file", "input.bin", "--yes"); err != nil { + t.Fatal(err) + } + }) testseam.Swap(t, &driveDownload, func(_ context.Context, _ string, options localio.DownloadOptions) (localio.DownloadResult, error) { if options.Output != "downloads/file.bin" || options.Headers["x-token"] != "secret" { @@ -269,6 +284,9 @@ func TestCrossPlatformCoverageDriveFirstInt64(t *testing.T) { if got, ok := firstInt64(map[string]any{}, "size"); ok || got != 0 { t.Fatalf("missing firstInt64 = (%d, %t), want (0, false)", got, ok) } + if got, ok := firstInt64(map[string]any{"fileSize": nil, "size": "7"}, "fileSize", "size"); !ok || got != 7 { + t.Fatalf("fallback firstInt64 = (%d, %t), want (7, true)", got, ok) + } } func TestCrossPlatformCoverageDriveVersionAndPublishContracts(t *testing.T) { @@ -512,6 +530,8 @@ func TestCrossPlatformCoverageDriveCreateRestoreCopyMoveRename(t *testing.T) { readback string want string }{ + {"missing node id", `{"success":true,"result":{"folderId":"target","workspaceId":"space"}}`, "缺少节点 ID"}, + {"wrong node id", `{"success":true,"result":{"nodeId":"other","folderId":"target","workspaceId":"space"}}`, "与请求节点 \"n1\" 不一致"}, {"missing folder", `{"success":true,"result":{"nodeId":"n1","workspaceId":"space"}}`, "缺少目标文件夹 ID"}, {"wrong folder", `{"success":true,"result":{"nodeId":"n1","folderId":"other","workspaceId":"space"}}`, "与请求 \"target\" 不一致"}, {"missing workspace", `{"success":true,"result":{"nodeId":"n1","folderId":"target"}}`, "缺少目标知识库 ID"}, From c6e810e4d9a8711dafe998e2e1e407378bb57615 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Thu, 13 Aug 2026 09:34:48 +0800 Subject: [PATCH 34/86] fix: restore interface migration ledger compatibility --- internal/interfacesnapshot/migrations.go | 16 ++++++++-- .../migrations_coverage_test.go | 29 +++++++++++++++++++ internal/interfacesnapshot/migrations_test.go | 25 ++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/internal/interfacesnapshot/migrations.go b/internal/interfacesnapshot/migrations.go index 7bd04bcd..892a7c55 100644 --- a/internal/interfacesnapshot/migrations.go +++ b/internal/interfacesnapshot/migrations.go @@ -326,8 +326,9 @@ func (m FlagMigration) validate() error { } // Requiredness belongs to the one logical parameter. The hidden legacy // spelling must not remain independently required, while the canonical - // spelling inherits the exact before-state contract. If the canonical flag - // already existed, a rename receipt cannot authorize changing it either. + // spelling inherits the exact before-state contract. An already-visible + // canonical flag cannot change requiredness; an existing hidden canonical + // placeholder may inherit it when promoted to the public spelling. if m.Legacy.After.Required { return fmt.Errorf("legacy compatibility alias must not remain independently required after migration") } @@ -336,7 +337,9 @@ func (m FlagMigration) validate() error { "flag requiredness must be preserved from legacy before to canonical after", ) } - if m.Canonical.Before.Present && m.Canonical.Before.Required != m.Canonical.After.Required { + if m.Canonical.Before.Present && + !m.Canonical.Before.Hidden && + m.Canonical.Before.Required != m.Canonical.After.Required { return fmt.Errorf("canonical flag requiredness must remain unchanged when already present") } if m.Legacy.After.AliasOf != m.Canonical.Name { @@ -690,6 +693,13 @@ func flagMigrationAuthorizesChange( change.Kind == "required_flag_added" { return true } + if migration.Canonical.Before.Present && + migration.Canonical.Before.Hidden && + !migration.Canonical.Before.Required && + migration.Canonical.After.Required && + change.Kind == "flag_became_required" { + return true + } } return false } diff --git a/internal/interfacesnapshot/migrations_coverage_test.go b/internal/interfacesnapshot/migrations_coverage_test.go index bdee575f..fa4b239f 100644 --- a/internal/interfacesnapshot/migrations_coverage_test.go +++ b/internal/interfacesnapshot/migrations_coverage_test.go @@ -792,6 +792,35 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsInheritedAndOptionalCa t.Fatalf("existing optional canonical migration = (%#v, %v), want compatible", report, err) } }) + + t.Run("hidden canonical inherits requiredness when promoted", func(t *testing.T) { + pending := coverageManifest(FlagMigrationPending) + pending.Migrations[0].Canonical.Before = FlagMigrationState{ + Present: true, + Type: "string", + Hidden: true, + Scope: "local", + } + consumed := pending + consumed.Migrations = append([]FlagMigration(nil), pending.Migrations...) + consumed.Migrations[0].State = FlagMigrationConsumed + before := coverageMigrationSnapshot(pending.Migrations[0], false, false) + after := coverageMigrationSnapshot(pending.Migrations[0], true, false) + + ordinary := Compare(after, before, "merge-base") + if !hasFlagChange(ordinary.Blocking, "flag_became_required", pending.Migrations[0].Command, pending.Migrations[0].Canonical.Name) { + t.Fatalf("fixture did not change canonical requiredness: %#v", ordinary.Blocking) + } + report, err := CompareAllWithFlagMigrations( + after, + map[string]Snapshot{"merge-base": before, "stable": before}, + pending, + consumed, + ) + if err != nil || !report.Compatible { + t.Fatalf("hidden canonical promotion = (%#v, %v), want compatible", report, err) + } + }) } func TestCrossPlatformCoverageOptionalFlagMigrationLifecycleRemainsHostile(t *testing.T) { diff --git a/internal/interfacesnapshot/migrations_test.go b/internal/interfacesnapshot/migrations_test.go index a7996dba..f9c406f7 100644 --- a/internal/interfacesnapshot/migrations_test.go +++ b/internal/interfacesnapshot/migrations_test.go @@ -5,6 +5,7 @@ package interfacesnapshot import ( "errors" + "os" "reflect" "strings" "testing" @@ -52,6 +53,27 @@ func optionalFlagMigrationManifestJSON() string { ) } +func hiddenCanonicalFlagMigrationManifestJSON() string { + return strings.Replace( + validFlagMigrationManifestJSON, + `"before": {"present": false}`, + `"before": {"present": true, "type": "string", "hidden": true, "scope": "local"}`, + 1, + ) +} + +func TestApprovedFlagMigrationManifestRemainsValid(t *testing.T) { + manifest, err := os.Open("../../scripts/policy/interface-migrations/approved-flag-migrations-v1.json") + if err != nil { + t.Fatalf("open approved flag migration manifest: %v", err) + } + defer manifest.Close() + + if _, err := ReadFlagMigrationManifest(manifest); err != nil { + t.Fatalf("approved flag migration manifest is invalid: %v", err) + } +} + func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsUnknownFields(t *testing.T) { _, err := ReadFlagMigrationManifest(strings.NewReader(`{ "version": 1, @@ -128,6 +150,9 @@ func TestCrossPlatformCoverageReadFlagMigrationManifestValidatesExactEntries(t * if _, err := ReadFlagMigrationManifest(strings.NewReader(optionalFlagMigrationManifestJSON())); err != nil { t.Fatalf("ReadFlagMigrationManifest(optional rename) error = %v", err) } + if _, err := ReadFlagMigrationManifest(strings.NewReader(hiddenCanonicalFlagMigrationManifestJSON())); err != nil { + t.Fatalf("ReadFlagMigrationManifest(hidden canonical promotion) error = %v", err) + } tests := []struct { name string From b469bb127af3b72a3f417776141766d3aea32fcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Thu, 13 Aug 2026 09:37:22 +0800 Subject: [PATCH 35/86] docs: clarify hidden canonical promotion --- docs/cli-interface-flag-migrations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli-interface-flag-migrations.md b/docs/cli-interface-flag-migrations.md index 74f4d577..76d11608 100644 --- a/docs/cli-interface-flag-migrations.md +++ b/docs/cli-interface-flag-migrations.md @@ -122,7 +122,7 @@ scripts/policy/interface-migrations/approved-flag-migrations-v1.json 一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下两类预期 finding: 1. legacy flag 的 `flag_became_hidden`(visible → hidden); -2. required legacy 被新增的 required canonical 替代时产生的 `required_flag_added`。已有 canonical 的 requiredness 不允许借 rename 改变。 +2. required legacy 被新增的 required canonical 替代时产生的 `required_flag_added`;如果 canonical 在 before 阶段只是 hidden 占位符,则允许它在转为公开拼写时继承 legacy 的 requiredness。已有的 visible canonical 不允许借 rename 改变 requiredness。 以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖: From 6fdf6e0678104f5ef72d684a1ef0e21dc67c7997 Mon Sep 17 00:00:00 2001 From: chichuan Date: Thu, 13 Aug 2026 10:01:10 +0800 Subject: [PATCH 36/86] fix(drive): reject sync path type conflicts --- internal/helpers/drive_sync.go | 48 +++++++ .../helpers/drive_sync_type_conflict_test.go | 131 ++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 internal/helpers/drive_sync_type_conflict_test.go diff --git a/internal/helpers/drive_sync.go b/internal/helpers/drive_sync.go index 7400add3..d0baefb0 100644 --- a/internal/helpers/drive_sync.go +++ b/internal/helpers/drive_sync.go @@ -158,6 +158,21 @@ func runDriveSync(cmd *cobra.Command, _ []string) error { localByRel[f.RelPath] = f } + // 在计算 diff 前先识别文件/目录同路径冲突。冲突根及其全部后代都不能再进入 + // new_local / new_remote 或后续执行阶段,否则会在远端制造同名异类对象,或尝试 + // 把远端目录内容下载到本地文件之下。 + typeConflicts := make(map[string]string) + for _, dir := range localDirs { + if msg := pushTypeConflictError(dir, true, remoteFiles, remoteFolders); msg != "" { + typeConflicts[dir] = msg + } + } + for rel := range localByRel { + if msg := pushTypeConflictError(rel, false, remoteFiles, remoteFolders); msg != "" { + typeConflicts[rel] = msg + } + } + // 计算 diff:双端都存在的文件复用 judgeFileMatch,保持与 status 完全一致的判定。 diff := driveSyncDiff{ NewLocal: []driveStatusEntry{}, @@ -168,6 +183,9 @@ func runDriveSync(cmd *cobra.Command, _ []string) error { } var newLocal, newRemote, modified, unknown []string for rel, pf := range localByRel { + if syncPathBlockedByTypeConflict(rel, typeConflicts) { + continue + } rf, ok := remoteFiles[rel] if !ok { newLocal = append(newLocal, rel) @@ -188,6 +206,9 @@ func runDriveSync(cmd *cobra.Command, _ []string) error { } } for rel := range remoteFiles { + if syncPathBlockedByTypeConflict(rel, typeConflicts) { + continue + } if _, ok := localByRel[rel]; !ok { newRemote = append(newRemote, rel) } @@ -211,6 +232,18 @@ func runDriveSync(cmd *cobra.Command, _ []string) error { sortEntries(diff.Unchanged) res := driveSyncResult{Detection: detection, Diff: diff, Items: []driveSyncItem{}} + conflictRels := make([]string, 0, len(typeConflicts)) + for rel := range typeConflicts { + conflictRels = append(conflictRels, rel) + } + sort.Strings(conflictRels) + for _, rel := range conflictRels { + res.Summary.Failed++ + res.Items = append(res.Items, driveSyncItem{ + RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, + Error: typeConflicts[rel], + }) + } // --dry-run:只算差异、不执行任何同步动作。 if deps.Caller.DryRun() { @@ -276,6 +309,9 @@ func runDriveSync(cmd *cobra.Command, _ []string) error { // 阶段 1:镜像本地目录结构到远端(缺失则创建),保证空目录与 push 目标的父目录先存在。 for _, dir := range localDirs { + if syncPathBlockedByTypeConflict(dir, typeConflicts) { + continue + } if _, ok := remoteFolders[dir]; ok { continue // 远端已存在,复用 fileId } @@ -339,6 +375,18 @@ func runDriveSync(cmd *cobra.Command, _ []string) error { return nil } +// syncPathBlockedByTypeConflict 判断 rel 本身或任一祖先是否是文件/目录类型冲突根。 +// 祖先检查保证冲突目录下的本地、远端后代不会绕过根路径的 fail-closed 结论。 +func syncPathBlockedByTypeConflict(rel string, conflicts map[string]string) bool { + for rel != "" { + if _, blocked := conflicts[rel]; blocked { + return true + } + rel, _ = splitRel(rel) + } + return false +} + // syncPullFile 下载单个远端文件到本地 rel 对应路径(总是覆盖,Drive 为该项权威源), // 并把结果计入 res。命中大小写/规范化冲突或逃逸的路径记 failed、不落盘。 func syncPullFile(res *driveSyncResult, ctx context.Context, spaceID string, rf *remoteFile, absDir, rel string, collided map[string]bool, direction string) { diff --git a/internal/helpers/drive_sync_type_conflict_test.go b/internal/helpers/drive_sync_type_conflict_test.go new file mode 100644 index 00000000..dbb66b66 --- /dev/null +++ b/internal/helpers/drive_sync_type_conflict_test.go @@ -0,0 +1,131 @@ +package helpers + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +type driveSyncTypeConflictFixture struct { + name string + setup func(t *testing.T, root string) + listings map[string]string +} + +func driveSyncTypeConflictFixtures() []driveSyncTypeConflictFixture { + return []driveSyncTypeConflictFixture{ + { + name: "local folder conflicts with remote file", + setup: func(t *testing.T, root string) { + t.Helper() + mustWrite(t, filepath.Join(root, "conflict", "local.txt"), "local") + }, + listings: map[string]string{ + "ROOT": `{"result":{"items":[{"name":"conflict","type":"file","fileId":"REMOTE_FILE","modifyTime":1}],"nextToken":""}}`, + }, + }, + { + name: "local file conflicts with remote folder", + setup: func(t *testing.T, root string) { + t.Helper() + mustWrite(t, filepath.Join(root, "conflict"), "local") + }, + listings: map[string]string{ + "ROOT": `{"result":{"items":[{"name":"conflict","type":"folder","fileId":"REMOTE_FOLDER"}],"nextToken":""}}`, + "REMOTE_FOLDER": `{"result":{"items":[{"name":"remote.txt","type":"file","fileId":"REMOTE_CHILD","modifyTime":1}],"nextToken":""}}`, + }, + }, + } +} + +func TestCrossPlatformCoverageDriveSync_typeConflictsFailClosedBeforeWrites(t *testing.T) { + for _, tc := range driveSyncTypeConflictFixtures() { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + tc.setup(t, root) + caller := syncCaller(tc.listings) + + transferCalls := captureDriveSyncTransfers(t) + + err := runDriveCmd(t, caller, "sync", "--local-folder", root, + "--remote-folder", "ROOT", "--quick") + var syncFailure *driveSyncFailure + if !errors.As(err, &syncFailure) || syncFailure.failed != 1 { + t.Fatalf("type conflict error = %v, want one failed item", err) + } + assertDriveSyncNoWriteCalls(t, caller, transferCalls) + }) + } +} + +func TestCrossPlatformCoverageDriveSync_dryRunReportsTypeConflictsWithoutWrites(t *testing.T) { + for _, tc := range driveSyncTypeConflictFixtures() { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + tc.setup(t, root) + caller := syncCaller(tc.listings) + caller.dryRun = true + + transferCalls := captureDriveSyncTransfers(t) + + var out bytes.Buffer + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive", "sync"}) + + cmd := findDriveSubcommand(t, "sync") + mustSetFlags(t, cmd, map[string]string{ + "local-folder": root, + "remote-folder": "ROOT", + "quick": "true", + }) + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("dry-run type conflict: %v", err) + } + + text := out.String() + if !strings.Contains(text, `"failed": 1`) || + !strings.Contains(text, `"action": "failed"`) || + !strings.Contains(text, `"rel_path": "conflict"`) { + t.Fatalf("dry-run must report the type conflict as failed: %s", text) + } + assertDriveSyncNoWriteCalls(t, caller, transferCalls) + }) + } +} + +type driveSyncTransferCalls struct { + get int + put int +} + +func captureDriveSyncTransfers(t *testing.T) *driveSyncTransferCalls { + t.Helper() + calls := &driveSyncTransferCalls{} + testseam.Swap(t, &httpGetFile, func(context.Context, string, map[string]string, string) error { + calls.get++ + return nil + }) + testseam.Swap(t, &httpPutFile, func(context.Context, string, map[string]string, string, int64) error { + calls.put++ + return nil + }) + return calls +} + +func assertDriveSyncNoWriteCalls(t *testing.T, caller *driveScriptCaller, transferCalls *driveSyncTransferCalls) { + t.Helper() + for _, tool := range []string{"create_folder", "download_file", "get_upload_info", "commit_upload"} { + if calls := caller.callsFor(tool); len(calls) != 0 { + t.Errorf("type conflict must not call %s, got %v", tool, calls) + } + } + if transferCalls.get != 0 || transferCalls.put != 0 { + t.Errorf("type conflict must not transfer HTTP content, GET=%d PUT=%d", transferCalls.get, transferCalls.put) + } +} From 54dc8fadb71662118b47aea640114c88f658d878 Mon Sep 17 00:00:00 2001 From: wxianfeng Date: Wed, 12 Aug 2026 19:09:37 +0800 Subject: [PATCH 37/86] feat: forward agent version and extension context --- .changes/85384225-agent-version-ext.md | 8 + .env.example | 9 + docs/reference.md | 43 ++ internal/app/agent_host_test.go | 2 +- internal/app/agent_metadata.go | 248 +++++++ internal/app/agent_metadata_test.go | 743 +++++++++++++++++++ internal/app/agent_product_test.go | 2 +- internal/app/auth_registry.go | 25 +- internal/app/coverage_more_test.go | 15 +- internal/app/identity_export.go | 6 +- internal/app/root.go | 152 +++- internal/app/runner.go | 39 +- internal/auth/agent_metadata_headers_test.go | 80 ++ internal/logging/redact.go | 2 + internal/logging/redact_test.go | 29 +- internal/transport/agent_metadata_test.go | 93 +++ internal/transport/client.go | 41 +- internal/transport/redirect_test.go | 140 ++++ pkg/cli/identity.go | 5 +- pkg/cli/identity_test.go | 35 + 20 files changed, 1638 insertions(+), 79 deletions(-) create mode 100644 .changes/85384225-agent-version-ext.md create mode 100644 internal/app/agent_metadata.go create mode 100644 internal/app/agent_metadata_test.go create mode 100644 internal/auth/agent_metadata_headers_test.go create mode 100644 internal/transport/agent_metadata_test.go create mode 100644 internal/transport/redirect_test.go create mode 100644 pkg/cli/identity_test.go diff --git a/.changes/85384225-agent-version-ext.md b/.changes/85384225-agent-version-ext.md new file mode 100644 index 00000000..98649dfd --- /dev/null +++ b/.changes/85384225-agent-version-ext.md @@ -0,0 +1,8 @@ +--- +category: Added +--- + +- **Agent version and extended context passthrough** (Aone 85384225) — adds + validated `DWS_AGENT_VER` and sensitive JSON `DWS_AGENT_EXT` metadata to + ordinary non-plugin MCP requests without forwarding it to A2A, OAuth, + Discovery, or third-party plugins. diff --git a/.env.example b/.env.example index 0707e058..e5451497 100644 --- a/.env.example +++ b/.env.example @@ -19,3 +19,12 @@ # Cache directory (optional, defaults to ~/.dws/cache) # DWS_CACHE_DIR= + +# Agent integration metadata (optional; ordinary non-plugin MCP requests only) +# DWS_AGENT_PRODUCT=example-agent +# DWS_AGENT_HOST=cloud +# DWS_AGENT_VER=0.1.5 +# DWS_AGENT_EXT='{"umt":"example-redacted","miniwua":"example-redacted","ua":"ExampleAgent/0.1.5"}' +# The outer single quotes above are shell syntax and are not part of the value. +# DWS_AGENT_EXT is sensitive caller-declared JSON (max 8 KiB); never put real +# tokens in committed files or use this metadata alone for authentication. diff --git a/docs/reference.md b/docs/reference.md index 3290bb67..b8c7f015 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -7,6 +7,8 @@ | `DWS_CONFIG_DIR` | Override default config directory / 覆盖默认配置目录 | | `DWS_AGENT_PRODUCT` | Optional, caller-declared Agent product sent as `x-dws-agent-product` (for example `qwenwork`) for downstream logs/BI and used as the IM `clawType` display label when `--ai-tag` is enabled. `--ai-tag` defaults to `true`, so a configured Product changes the displayed label by default. With `--ai-tag=false`, native `chat message send` / `reply` calls send an empty `clawType`, while shortcut calls omit the argument. Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[A-Za-z0-9][A-Za-z0-9_-]*$`. Unset or empty values omit the Header and use the edition's IM display default. This client never uses Product to change the separate HTTP `claw-type` PAT/routing label. / 可选、由调用方声明的 Agent 产品标识,经校验后作为 `x-dws-agent-product` 发送,并用于 IM 小尾巴;`--ai-tag` 默认为 `true`,因此配置 Product 后默认会改变展示标签。使用 `--ai-tag=false` 时,原生 `chat message send` / `reply` 发送空的 `clawType`,shortcut 调用则省略该参数。未设置时省略请求头且 IM 使用发行版默认值;本客户端不会用 Product 修改独立的 HTTP `claw-type` | | `DWS_AGENT_HOST` | Optional, caller-declared Agent runtime form sent as `x-dws-agent-host` (for example `cloud` or `desktop`) for downstream logs/BI. Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[a-z0-9][a-z0-9_-]*$`; unset values are omitted. This client does not use Host for PAT, authentication, Discovery, or MCP endpoint selection. / 可选、由调用方声明的 Agent 运行形态,经校验后作为 `x-dws-agent-host` 发送给下游日志/BI;本客户端不使用该值进行 PAT、鉴权、Discovery 或 MCP 端点选择,未设置时省略 | +| `DWS_AGENT_VER` | Optional caller-declared Agent version / 可选、由调用方声明的 Agent 版本。After trimming surrounding ASCII spaces/tabs, the value must be at most 64 bytes and match `^[A-Za-z0-9][A-Za-z0-9._+-]*$`; a non-empty valid value is sent as `x-dws-agent-ver`, while unset or empty values omit the Header. / 去除首尾 ASCII 空格和 Tab 后,值不得超过 64 字节且必须匹配上述格式;合法非空值通过 `x-dws-agent-ver` 发送,未设置或空值则省略请求头 | +| `DWS_AGENT_EXT` | Optional caller-declared Agent extended context / 可选、由调用方声明的 Agent 扩展上下文。The value must be a UTF-8 JSON object no larger than 8 KiB, is compacted before being sent as the sensitive `x-dws-agent-ext` Header, and may use the recommended keys `umt`, `miniwua`, and `ua`; unknown keys remain supported. Unset or empty values omit the Header. / 值必须是 UTF-8 JSON 对象且不得超过 8 KiB,压缩后通过敏感请求头 `x-dws-agent-ext` 发送;推荐使用 `umt`、`miniwua`、`ua`,同时允许未知扩展键。未设置或空值则省略请求头 | | `DWS__MCP_URL` | Override a product MCP endpoint for local development / 本地开发时覆盖指定产品 MCP endpoint | | `DWS_CLIENT_ID` | OAuth client ID (DingTalk AppKey) | | `DWS_CLIENT_SECRET` | OAuth client secret (DingTalk AppSecret) | @@ -14,6 +16,47 @@ | `DWS_ALLOW_HTTP_ENDPOINTS` | Set `1` to allow HTTP for loopback during dev / 设为 `1` 允许回环地址 HTTP,仅用于开发调试 | | `DWS_DISABLE_KEYCHAIN` | macOS only. Set `1` to skip system Keychain for the encryption key and use file-based storage (same scheme as Linux). For sandboxed runtimes (e.g. Codex App) that block Keychain APIs. Weakens at-rest protection — DEK and ciphertext live in the same directory. / 仅 macOS。设为 `1` 时跳过系统 Keychain,密钥以文件形式存储(与 Linux 一致)。用于 Keychain API 被拦截的沙盒环境(如 Codex App)。代价是 DEK 与密文同目录,保护强度低于默认方案 | +### Agent Version and Extended Context / Agent 版本与扩展上下文 + +`DWS_AGENT_VER` and `DWS_AGENT_EXT` are sent only on the CLI's ordinary, +non-plugin MCP requests. They do not change the standard HTTP `User-Agent` or +the separate `X-Cli-Version` that identifies the DWS CLI version, and they are +not forwarded to A2A, OAuth, Discovery, or third-party plugin requests. + +`DWS_AGENT_EXT` is one JSON-object Header rather than a set of Headers. The +recommended keys are `umt`, `miniwua`, and `ua`, but the open-source CLI keeps +the object extensible and does not enforce a key allowlist. For example, using +fictional, redacted values: + +```bash +DWS_AGENT_VER=0.1.5 +DWS_AGENT_EXT='{"umt":"example-redacted","miniwua":"example-redacted","ua":"ExampleAgent/0.1.5"}' +``` + +The shell's outer single quotes group the JSON and are not part of the +environment-variable value. The CLI trims surrounding ASCII spaces/tabs, +omits either Header when its value is empty, and compacts EXT to a single-line +JSON object. A representative current payload is about 657 bytes, well below +the 8 KiB limit; integrations must still enforce the limit because values can +grow. EXT may contain sensitive device or runtime signals: the CLI masks it in +configuration and logs, and removes it on a cross-host redirect. + +Both values are declared by the caller and are therefore forgeable. They can +support compatibility checks, diagnostics, and observability, but they are not +credentials or attestations and must never be sufficient on their own to +authenticate a caller or authorize access. + +`DWS_AGENT_VER` 与 `DWS_AGENT_EXT` 仅随 CLI 发起的普通非插件 MCP 请求发送,不会 +改变标准 HTTP `User-Agent`,也不会覆盖标识 DWS CLI 自身版本的 `X-Cli-Version`; +二者不会进入 A2A、OAuth、Discovery 或第三方插件请求。EXT 使用单个 JSON 对象 +请求头,不拆成多个子请求头;推荐键为 `umt`、`miniwua`、`ua`,但开源 CLI 不限制 +扩展键。Shell 示例中的外层单引号只用于保护 JSON,不属于环境变量值。当前典型负载 +约为 657 字节,远低于 8 KiB 上限,但集成方仍须遵守大小限制。EXT 可能包含敏感的 +设备或运行时信号,配置展示和日志会对其脱敏,跨主机重定向时也会移除该请求头。 + +这两个值都由调用方自行声明,可以被伪造;它们可用于兼容性判断、诊断和可观测性, +但不是凭据或可信证明,不能单独用于身份认证或访问授权。 + ### Agent Product, Host, and `claw-type` / Agent 产品、运行形态与 `claw-type` `DWS_AGENT_PRODUCT` and `DWS_AGENT_HOST` are caller-declared observation diff --git a/internal/app/agent_host_test.go b/internal/app/agent_host_test.go index 38c5e6a0..82c22abd 100644 --- a/internal/app/agent_host_test.go +++ b/internal/app/agent_host_test.go @@ -165,7 +165,7 @@ func TestResolveIdentityHeadersOmitsAbsentOrInvalidAgentHost(t *testing.T) { } } -func TestRootRejectsInvalidAgentHostBeforeEditionHook(t *testing.T) { +func TestCrossPlatformCoverageRootRejectsInvalidAgentHostBeforeEditionHook(t *testing.T) { t.Setenv("DWS_CONFIG_DIR", t.TempDir()) const invalidValue = "DO_NOT_ECHO" t.Setenv(envDWSAgentHost, invalidValue) diff --git a/internal/app/agent_metadata.go b/internal/app/agent_metadata.go new file mode 100644 index 00000000..c8b7d74d --- /dev/null +++ b/internal/app/agent_metadata.go @@ -0,0 +1,248 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "bytes" + "context" + "encoding/json" + "os" + "regexp" + "strings" + "unicode" + "unicode/utf8" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta" +) + +const ( + envDWSAgentVersion = "DWS_AGENT_VER" + envDWSAgentExt = "DWS_AGENT_EXT" + maxAgentVersionBytes = 64 + maxAgentExtensionBytes = 8 * 1024 +) + +var agentVersionPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]*$`) + +type agentMetadataSnapshot struct { + version string + ext string + versionErr error + extErr error +} + +type agentMetadataSnapshotContextKey struct{} + +func (snapshot agentMetadataSnapshot) validationError() error { + if snapshot.versionErr != nil { + return snapshot.versionErr + } + return snapshot.extErr +} + +func contextWithAgentMetadataSnapshot(ctx context.Context, snapshot agentMetadataSnapshot) context.Context { + return context.WithValue(ctx, agentMetadataSnapshotContextKey{}, snapshot) +} + +func agentMetadataSnapshotFromContext(ctx context.Context) (agentMetadataSnapshot, bool) { + if ctx == nil { + return agentMetadataSnapshot{}, false + } + snapshot, ok := ctx.Value(agentMetadataSnapshotContextKey{}).(agentMetadataSnapshot) + return snapshot, ok +} + +func init() { + configmeta.Register(configmeta.ConfigItem{ + Name: envDWSAgentVersion, + Category: configmeta.CategoryExternal, + Description: "调用 DWS 的 Agent 版本;仅作为 x-dws-agent-ver 透传到非插件 MCP 请求", + Example: "1.2.3-beta.1+build.7", + }) + configmeta.Register(configmeta.ConfigItem{ + Name: envDWSAgentExt, + Category: configmeta.CategoryExternal, + Description: "调用 DWS 的 Agent 扩展上下文 JSON;仅作为 x-dws-agent-ext 透传到非插件 MCP 请求", + Example: `{"umt":"","miniwua":"","ua":"agent/1.0"}`, + Sensitive: true, + }) +} + +// parseAgentVersion normalizes and validates the caller-declared Agent +// version. Only surrounding ASCII spaces and tabs are trimmed. An unset or +// ASCII-whitespace-only value means "do not emit". +func parseAgentVersion(raw string) (string, error) { + value := strings.Trim(raw, " \t") + if value == "" { + return "", nil + } + if len(value) > maxAgentVersionBytes || !agentVersionPattern.MatchString(value) { + return "", invalidAgentVersionError() + } + return value, nil +} + +// parseAgentExt validates one generic JSON object and returns its compact +// one-line representation. Raw control characters other than horizontal tab +// are rejected before JSON parsing; escaped JSON control characters remain +// valid because they are safe on the HTTP header wire. +func parseAgentExt(raw string) (string, error) { + if len(raw) > maxAgentExtensionBytes || !utf8.ValidString(raw) { + return "", invalidAgentExtError() + } + for _, r := range raw { + if unicode.IsControl(r) && r != '\t' { + return "", invalidAgentExtError() + } + } + + value := strings.Trim(raw, " \t") + if value == "" { + return "", nil + } + + var compact bytes.Buffer + if err := json.Compact(&compact, []byte(value)); err != nil { + return "", invalidAgentExtError() + } + compactBytes := compact.Bytes() + if len(compactBytes) > maxAgentExtensionBytes || len(compactBytes) < 2 || compactBytes[0] != '{' { + return "", invalidAgentExtError() + } + return compact.String(), nil +} + +func invalidAgentVersionError() error { + return apperrors.NewValidation( + "DWS_AGENT_VER must be at most 64 bytes and match ^[A-Za-z0-9][A-Za-z0-9._+-]*$", + apperrors.WithReason("invalid_agent_version"), + ) +} + +func invalidAgentExtError() error { + return apperrors.NewValidation( + "DWS_AGENT_EXT must be a UTF-8 JSON object of at most 8192 bytes without raw control characters", + apperrors.WithReason("invalid_agent_ext"), + ) +} + +// readAgentMetadataSnapshot reads both environment variables from one +// os.Environ snapshot, then parses them once. Normal CLI execution retains the +// validated result through the invocation so hooks and transport observe the +// same pair even in an embedding process that mutates its environment. +func readAgentMetadataSnapshot() agentMetadataSnapshot { + var rawVersion, rawExt string + for _, entry := range os.Environ() { + key, value, _ := strings.Cut(entry, "=") + switch key { + case envDWSAgentVersion: + rawVersion = value + case envDWSAgentExt: + rawExt = value + } + } + version, versionErr := parseAgentVersion(rawVersion) + ext, extErr := parseAgentExt(rawExt) + return agentMetadataSnapshot{ + version: version, + ext: ext, + versionErr: versionErr, + extErr: extErr, + } +} + +// removeAgentMetadataHeaders removes every case variant so edition or +// credential hooks cannot smuggle MCP-only metadata into shared transports. +func removeAgentMetadataHeaders(headers map[string]string) { + for key := range headers { + if strings.EqualFold(key, transport.HeaderAgentVersion) || + strings.EqualFold(key, transport.HeaderAgentExt) { + delete(headers, key) + } + } +} + +// applyAgentMetadataHeaders applies validated environment values as the final +// authority for non-plugin MCP requests. Invalid values are omitted on +// library paths that bypass root validation; normal CLI execution rejects +// them before hooks or network access. +func applyAgentMetadataHeaders(headers map[string]string) map[string]string { + return applyAgentMetadataSnapshot(headers, readAgentMetadataSnapshot()) +} + +func applyAgentMetadataSnapshot(headers map[string]string, snapshot agentMetadataSnapshot) map[string]string { + removeAgentMetadataHeaders(headers) + + if (snapshot.versionErr != nil || snapshot.version == "") && (snapshot.extErr != nil || snapshot.ext == "") { + return headers + } + if headers == nil { + headers = make(map[string]string) + } + if snapshot.versionErr == nil && snapshot.version != "" { + headers[transport.HeaderAgentVersion] = snapshot.version + } + if snapshot.extErr == nil && snapshot.ext != "" { + headers[transport.HeaderAgentExt] = snapshot.ext + } + return headers +} + +// resolveMCPRequestHeaders adds Agent version and extension metadata only to +// the built-in DingTalk MCP request path. Shared identity consumers (notably +// A2A) continue to use resolveIdentityHeaders and never receive these fields. +func resolveMCPRequestHeaders() map[string]string { + return resolveMCPRequestHeadersWithSnapshot(readAgentMetadataSnapshot()) +} + +func resolveMCPRequestHeadersWithSnapshot(snapshot agentMetadataSnapshot) map[string]string { + return applyAgentMetadataSnapshot(resolveIdentityHeaders(), snapshot) +} + +// resolveMCPRequestHeadersForInvocation resolves one immutable Header snapshot +// for an invocation. The helper-only mcp-meta server performs endpoint +// discovery rather than an ordinary MCP product call, so caller-declared +// Agent metadata must not cross that boundary. +func resolveMCPRequestHeadersForInvocation(invocation executor.Invocation, snapshots ...agentMetadataSnapshot) map[string]string { + headers := resolveIdentityHeaders() + if strings.EqualFold(strings.TrimSpace(invocation.CanonicalProduct), mcpMetaServerID) { + return headers + } + snapshot := readAgentMetadataSnapshot() + if len(snapshots) > 0 { + snapshot = snapshots[0] + } + return applyAgentMetadataSnapshot(headers, snapshot) +} + +// pluginRequestHeaders returns a private, sanitized copy of plugin-owned +// Headers. Third-party plugins never receive DWS-owned Agent metadata, even if +// their manifest tries to declare the reserved Header names itself. +func pluginRequestHeaders(pluginAuth *PluginAuth) map[string]string { + if pluginAuth == nil || len(pluginAuth.ExtraHeaders) == 0 { + return nil + } + headers := make(map[string]string, len(pluginAuth.ExtraHeaders)) + for key, value := range pluginAuth.ExtraHeaders { + headers[key] = value + } + removeAgentMetadataHeaders(headers) + if len(headers) == 0 { + return nil + } + return headers +} diff --git a/internal/app/agent_metadata_test.go b/internal/app/agent_metadata_test.go new file mode 100644 index 00000000..2bae8254 --- /dev/null +++ b/internal/app/agent_metadata_test.go @@ -0,0 +1,743 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "context" + "encoding/json" + "errors" + "io" + "maps" + "os" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + outputpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes" + "github.com/spf13/cobra" +) + +func TestCrossPlatformCoverageParseAgentVersion(t *testing.T) { + var nilContext context.Context + if _, ok := agentMetadataSnapshotFromContext(nilContext); ok { + t.Fatal("nil context unexpectedly contained Agent metadata") + } + wantSnapshot := agentMetadataSnapshot{version: "context-version", ext: "{}"} + if got, ok := agentMetadataSnapshotFromContext(contextWithAgentMetadataSnapshot(context.Background(), wantSnapshot)); !ok || got != wantSnapshot { + t.Fatalf("context Agent metadata = %#v, %v; want %#v", got, ok, wantSnapshot) + } + + valid := []struct { + name string + raw string + want string + }{ + {name: "unset", raw: "", want: ""}, + {name: "ASCII whitespace only", raw: " \t ", want: ""}, + {name: "semantic version", raw: "1.2.3", want: "1.2.3"}, + {name: "pre-release and build", raw: " v1.2.3-rc.1+build_7 ", want: "v1.2.3-rc.1+build_7"}, + {name: "maximum length", raw: strings.Repeat("a", maxAgentVersionBytes), want: strings.Repeat("a", maxAgentVersionBytes)}, + } + for _, tc := range valid { + t.Run(tc.name, func(t *testing.T) { + got, err := parseAgentVersion(tc.raw) + if err != nil { + t.Fatalf("parseAgentVersion() error = %v", err) + } + if got != tc.want { + t.Fatalf("parseAgentVersion() = %q, want %q", got, tc.want) + } + }) + } + + invalid := []struct { + name string + raw string + }{ + {name: "leading punctuation", raw: "-1.2.3"}, + {name: "internal space", raw: "1.2 3"}, + {name: "slash", raw: "1.2/3"}, + {name: "line feed", raw: "1.2.3\n"}, + {name: "carriage return", raw: "1.2.3\r"}, + {name: "NUL", raw: "1.2\x003"}, + {name: "Unicode", raw: "版本1"}, + {name: "too long", raw: strings.Repeat("a", maxAgentVersionBytes+1)}, + } + for _, tc := range invalid { + t.Run(tc.name, func(t *testing.T) { + got, err := parseAgentVersion(tc.raw) + if err == nil || got != "" { + t.Fatalf("parseAgentVersion(%q) = %q, %v; want validation error", tc.raw, got, err) + } + assertAgentMetadataValidationError(t, err, "invalid_agent_version", tc.raw) + }) + } +} + +func TestCrossPlatformCoverageParseAgentExt(t *testing.T) { + boundary := `{"x":"` + strings.Repeat("a", maxAgentExtensionBytes-8) + `"}` + if len(boundary) != maxAgentExtensionBytes { + t.Fatalf("invalid boundary fixture size: %d", len(boundary)) + } + + valid := []struct { + name string + raw string + want string + }{ + {name: "unset", raw: "", want: ""}, + {name: "ASCII whitespace only", raw: " \t ", want: ""}, + {name: "empty object", raw: "{}", want: "{}"}, + {name: "compact generic object", raw: " \t{ \"umt\": \"masked\",\t \"nested\": { \"ok\": true }, \"unknown\": [1, 2] }\t ", want: `{"umt":"masked","nested":{"ok":true},"unknown":[1,2]}`}, + {name: "Unicode value", raw: `{"ua":"千问办公/1.0"}`, want: `{"ua":"千问办公/1.0"}`}, + {name: "escaped control remains safe", raw: `{"ua":"line\nnext"}`, want: `{"ua":"line\nnext"}`}, + {name: "maximum length", raw: boundary, want: boundary}, + } + for _, tc := range valid { + t.Run(tc.name, func(t *testing.T) { + got, err := parseAgentExt(tc.raw) + if err != nil { + t.Fatalf("parseAgentExt() error = %v", err) + } + if got != tc.want { + t.Fatalf("parseAgentExt() = %q, want %q", got, tc.want) + } + }) + } + + invalidUTF8 := string([]byte{'{', '"', 'x', '"', ':', '"', 0xff, '"', '}'}) + invalid := []struct { + name string + raw string + }{ + {name: "too long raw input", raw: strings.Repeat(" ", maxAgentExtensionBytes+1)}, + {name: "invalid UTF-8", raw: invalidUTF8}, + {name: "array", raw: `[]`}, + {name: "string", raw: `"value"`}, + {name: "number", raw: `1`}, + {name: "boolean", raw: `true`}, + {name: "null", raw: `null`}, + {name: "malformed object", raw: `{"secret":"DO_NOT_ECHO"`}, + {name: "trailing value", raw: `{} {}`}, + {name: "line feed", raw: "{\n}"}, + {name: "carriage return", raw: "{\r}"}, + {name: "NUL", raw: "{\x00}"}, + {name: "vertical tab", raw: "{\v}"}, + {name: "form feed", raw: "{\f}"}, + {name: "DEL", raw: "{\x7f}"}, + {name: "C1 control", raw: "{\u0085}"}, + } + for _, tc := range invalid { + t.Run(tc.name, func(t *testing.T) { + got, err := parseAgentExt(tc.raw) + if err == nil || got != "" { + t.Fatalf("parseAgentExt() = %q, %v; want validation error", got, err) + } + assertAgentMetadataValidationError(t, err, "invalid_agent_ext", tc.raw) + }) + } +} + +func assertAgentMetadataValidationError(t *testing.T, err error, reason, raw string) { + t.Helper() + var appErr *apperrors.Error + if !errors.As(err, &appErr) { + t.Fatalf("error type = %T, want *errors.Error", err) + } + if appErr.Category != apperrors.CategoryValidation || appErr.Reason != reason { + t.Fatalf("error = category %q reason %q, want validation/%s", appErr.Category, appErr.Reason, reason) + } + if strings.Contains(raw, "DO_NOT_ECHO") && strings.Contains(err.Error(), "DO_NOT_ECHO") { + t.Fatalf("error must not echo invalid value: %v", err) + } +} + +func TestCrossPlatformCoverageAgentMetadataConfigRegistrationAndMasking(t *testing.T) { + items := configmeta.All() + var versionItem, extItem *configmeta.ConfigItem + for i := range items { + switch items[i].Name { + case envDWSAgentVersion: + versionItem = &items[i] + case envDWSAgentExt: + extItem = &items[i] + } + } + if versionItem == nil || extItem == nil { + t.Fatalf("Agent metadata config registration missing: version=%v ext=%v", versionItem != nil, extItem != nil) + } + if versionItem.Category != configmeta.CategoryExternal || versionItem.Sensitive { + t.Fatalf("version config metadata = %#v", *versionItem) + } + if extItem.Category != configmeta.CategoryExternal || !extItem.Sensitive { + t.Fatalf("extension config metadata = %#v", *extItem) + } + + const canary = `{"umt":"SENSITIVE_CANARY"}` + t.Setenv(envDWSAgentExt, canary) + got, ok := configmeta.Resolve(envDWSAgentExt) + if !ok || got == "" || strings.Contains(got, "SENSITIVE_CANARY") || got == canary { + t.Fatalf("sensitive extension was not masked: value=%q ok=%v", got, ok) + } + + t.Setenv(envDWSAgentVersion, "9.8.7") + command := newConfigListCommand() + var output strings.Builder + command.SetOut(&output) + command.SetArgs([]string{"--category", string(configmeta.CategoryExternal), "--show-values", "--json"}) + if err := command.Execute(); err != nil { + t.Fatalf("config list failed: %v", err) + } + rawOutput := output.String() + if !json.Valid([]byte(rawOutput)) { + t.Fatalf("config list emitted invalid JSON: %q", rawOutput) + } + if !strings.Contains(rawOutput, envDWSAgentVersion) || !strings.Contains(rawOutput, envDWSAgentExt) { + t.Fatalf("config list omitted Agent metadata variables: %s", rawOutput) + } + if strings.Contains(rawOutput, "SENSITIVE_CANARY") || strings.Contains(rawOutput, canary) { + t.Fatalf("config list leaked Agent extension: %s", rawOutput) + } +} + +func TestCrossPlatformCoverageResolveMCPRequestHeadersScopesAndFinalizesAgentMetadata(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + t.Setenv(envDWSAgentHost, "") + t.Setenv(agentproduct.EnvName, "") + t.Setenv(envDWSAgentVersion, " 1.2.3-rc.1 ") + t.Setenv(envDWSAgentExt, " { \"umt\": \"masked\", \"unknown\": true } ") + + oldEdition := edition.Get() + t.Cleanup(func() { edition.Override(oldEdition) }) + edition.Override(&edition.Hooks{ + MergeHeaders: func(headers map[string]string) map[string]string { + headers["X-Dws-Agent-Ver"] = "merge-must-not-win" + headers["X-Dws-Agent-Ext"] = `{"source":"merge"}` + return headers + }, + EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string { + headers[transport.HeaderAgentVersion] = "credential-must-not-win" + headers[transport.HeaderAgentExt] = `{"source":"credential"}` + return headers + }, + }) + + for name, headers := range map[string]map[string]string{ + "shared identity": resolveIdentityHeaders(), + "A2A export": MCPIdentityHeaders(), + } { + if hasHeaderFold(headers, transport.HeaderAgentVersion) || hasHeaderFold(headers, transport.HeaderAgentExt) { + t.Fatalf("%s leaked MCP-only metadata: %#v", name, headers) + } + } + + headers := resolveMCPRequestHeaders() + if got := headers[transport.HeaderAgentVersion]; got != "1.2.3-rc.1" { + t.Fatalf("%s = %q, want 1.2.3-rc.1", transport.HeaderAgentVersion, got) + } + if got := headers[transport.HeaderAgentExt]; got != `{"umt":"masked","unknown":true}` { + t.Fatalf("%s = %q", transport.HeaderAgentExt, got) + } + if got := headers[transport.HeaderVersion]; got != version { + t.Fatalf("%s = %q, want CLI version %q", transport.HeaderVersion, got, version) + } + if _, ok := headers["User-Agent"]; ok { + t.Fatal("Agent extension must not create or replace the standard User-Agent header") + } + for _, key := range []string{"umt", "miniwua", "ua", "x-dws-agent-umt", "x-dws-agent-miniwua", "x-dws-agent-ua"} { + if hasHeaderFold(headers, key) { + t.Fatalf("Agent extension was split into an extra header %q: %#v", key, headers) + } + } + + // Library paths are best-effort: one invalid value is omitted without + // suppressing the other valid field or preserving hook-injected values. + t.Setenv(envDWSAgentExt, `{"secret":"DO_NOT_ECHO"`) + headers = resolveMCPRequestHeaders() + if got := headers[transport.HeaderAgentVersion]; got != "1.2.3-rc.1" { + t.Fatalf("valid version was suppressed: %q", got) + } + if hasHeaderFold(headers, transport.HeaderAgentExt) { + t.Fatalf("invalid extension or hook value leaked: %#v", headers) + } + + // Exercise the nil-map and empty-input library paths. An absent environment + // must not allocate a map, while an EXT-only value must allocate one and + // remain a single compact Header. + t.Setenv(envDWSAgentVersion, "") + t.Setenv(envDWSAgentExt, "") + if got := applyAgentMetadataHeaders(nil); got != nil { + t.Fatalf("empty metadata allocated headers: %#v", got) + } + t.Setenv(envDWSAgentExt, " { } ") + headers = applyAgentMetadataHeaders(nil) + if got := headers[transport.HeaderAgentExt]; got != "{}" { + t.Fatalf("EXT-only metadata = %q, want {}", got) + } +} + +func TestCrossPlatformCoverageRootRejectsInvalidAgentMetadataBeforeEditionHook(t *testing.T) { + oldEdition := edition.Get() + t.Cleanup(func() { edition.Override(oldEdition) }) + + tests := []struct { + name string + env string + value string + reason string + }{ + {name: "version", env: envDWSAgentVersion, value: "DO_NOT ECHO", reason: "invalid_agent_version"}, + {name: "extension", env: envDWSAgentExt, value: `{"secret":"DO_NOT_ECHO"`, reason: "invalid_agent_ext"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + t.Setenv(envDWSAgentHost, "") + t.Setenv(agentproduct.EnvName, "") + t.Setenv(envDWSAgentVersion, "") + t.Setenv(envDWSAgentExt, "") + t.Setenv(tc.env, tc.value) + + headerHookCalled := false + afterHookCalled := false + edition.Override(&edition.Hooks{ + MergeHeaders: func(headers map[string]string) map[string]string { + headerHookCalled = true + return headers + }, + EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string { + headerHookCalled = true + return headers + }, + AfterPersistentPreRun: func(_ *cobra.Command, _ []string) error { + afterHookCalled = true + return nil + }, + }) + + root := NewRootCommand() + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetArgs([]string{"version"}) + err := root.Execute() + if err == nil { + t.Fatalf("root command accepted invalid %s", tc.env) + } + if headerHookCalled || afterHookCalled { + t.Fatalf("edition hook ran before %s validation", tc.env) + } + assertAgentMetadataValidationError(t, err, tc.reason, tc.value) + }) + } +} + +func TestCrossPlatformCoverageAgentMetadataProcessEntryValidationPrecedesRootConstruction(t *testing.T) { + for _, tc := range []struct { + name string + args []string + want bool + }{ + {name: "default JSON", args: []string{"version"}, want: true}, + {name: "long JSON", args: []string{"version", "--format", "JSON"}, want: true}, + {name: "long table", args: []string{"--format=table", "version"}, want: false}, + {name: "short attached JSON", args: []string{"version", "-fjson"}, want: true}, + {name: "short table", args: []string{"version", "-f", "table"}, want: false}, + {name: "last wins", args: []string{"--format", "table", "version", "-f=json"}, want: true}, + {name: "terminator", args: []string{"version", "--format", "table", "--", "--format", "json"}, want: false}, + {name: "missing value", args: []string{"version", "--format"}, want: false}, + } { + t.Run("presentation/"+tc.name, func(t *testing.T) { + if got := processArgsRequestJSON(tc.args); got != tc.want { + t.Fatalf("processArgsRequestJSON(%q) = %v, want %v", tc.args, got, tc.want) + } + }) + } + + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + t.Setenv(envDWSAgentHost, "") + t.Setenv(agentproduct.EnvName, "") + t.Setenv(envDWSAgentVersion, "") + sensitiveRaw := "{\"umt\":\"must-not-leak\"}\n" + t.Setenv(envDWSAgentExt, sensitiveRaw) + oldEdition := edition.Get() + t.Cleanup(func() { edition.Override(oldEdition) }) + extensionHookCalls := 0 + edition.Override(&edition.Hooks{ + Name: "presentation-test", + RegisterExtraCommands: func(*cobra.Command, edition.ToolCaller) { + extensionHookCalls++ + }, + VisibleProducts: func() []string { + extensionHookCalls++ + return nil + }, + StaticServers: func() []edition.ServerInfo { + extensionHookCalls++ + return nil + }, + }) + + oldArgs := os.Args + os.Args = []string{"dws", "version"} + t.Cleanup(func() { os.Args = oldArgs }) + + rootConstructed := false + preParseCalled := false + testseam.Swap(t, &rootNewRootCommandWithEngine, func(context.Context, *pipeline.Engine) *cobra.Command { + rootConstructed = true + return &cobra.Command{Use: "dws"} + }) + testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { + preParseCalled = true + return nil + }) + + stderrFile, err := os.CreateTemp(t.TempDir(), "agent-metadata-stderr-*") + if err != nil { + t.Fatalf("create stderr capture: %v", err) + } + oldStderr := os.Stderr + os.Stderr = stderrFile + t.Cleanup(func() { + os.Stderr = oldStderr + _ = stderrFile.Close() + }) + + if code := Execute(); code == 0 { + t.Fatal("process entry accepted invalid Agent metadata") + } + if rootConstructed || preParseCalled { + t.Fatalf("invalid Agent metadata reached root hooks: constructed=%v preParse=%v", rootConstructed, preParseCalled) + } + if extensionHookCalls != 0 { + t.Fatalf("invalid Agent metadata executed %d extension hooks", extensionHookCalls) + } + if err := stderrFile.Sync(); err != nil { + t.Fatalf("sync stderr capture: %v", err) + } + stderrOutput, err := os.ReadFile(stderrFile.Name()) + if err != nil { + t.Fatalf("read stderr capture: %v", err) + } + if strings.Contains(string(stderrOutput), "must-not-leak") || strings.Contains(string(stderrOutput), sensitiveRaw) { + t.Fatalf("process validation error leaked raw EXT: %q", stderrOutput) + } + if !json.Valid(stderrOutput) || !strings.Contains(string(stderrOutput), `"reason": "invalid_agent_ext"`) { + t.Fatalf("default JSON error presentation = %q", stderrOutput) + } + + stdoutFile, err := os.CreateTemp(t.TempDir(), "agent-metadata-stdout-*") + if err != nil { + t.Fatalf("create stdout capture: %v", err) + } + oldStdout := os.Stdout + os.Stdout = stdoutFile + t.Cleanup(func() { + os.Stdout = oldStdout + _ = stdoutFile.Close() + }) + emitEarlyAgentMetadataValidationError(invalidAgentExtError(), []string{"drive", "+list", "--format", "json"}) + if err := stdoutFile.Sync(); err != nil { + t.Fatalf("sync stdout capture: %v", err) + } + unifiedOutput, err := os.ReadFile(stdoutFile.Name()) + if err != nil { + t.Fatalf("read stdout capture: %v", err) + } + if !json.Valid(unifiedOutput) || !strings.Contains(string(unifiedOutput), `"outcome": "failure"`) || + !strings.Contains(string(unifiedOutput), `"subtype": "invalid_agent_ext"`) { + t.Fatalf("unified JSON error presentation = %q", unifiedOutput) + } + if extensionHookCalls != 0 { + t.Fatalf("presentation-only root executed %d extension hooks", extensionHookCalls) + } + + if err := stderrFile.Truncate(0); err != nil { + t.Fatalf("truncate fallback stderr capture: %v", err) + } + if _, err := stderrFile.Seek(0, io.SeekStart); err != nil { + t.Fatalf("rewind fallback stderr capture: %v", err) + } + testseam.Swap(t, &rootEmitResult, func(*cobra.Command, outputpkg.CommandResult) (int, error) { + return 0, errors.New("injected result emission failure") + }) + emitEarlyAgentMetadataValidationError(invalidAgentExtError(), []string{"drive", "+list", "--format", "json"}) + if err := stderrFile.Sync(); err != nil { + t.Fatalf("sync fallback stderr capture: %v", err) + } + fallbackOutput, err := os.ReadFile(stderrFile.Name()) + if err != nil { + t.Fatalf("read fallback stderr capture: %v", err) + } + if !json.Valid(fallbackOutput) || !strings.Contains(string(fallbackOutput), `"reason": "invalid_agent_ext"`) || + strings.Contains(string(fallbackOutput), "must-not-leak") { + t.Fatalf("fallback validation error presentation = %q", fallbackOutput) + } + + if err := stderrFile.Truncate(0); err != nil { + t.Fatalf("truncate stderr capture: %v", err) + } + if _, err := stderrFile.Seek(0, io.SeekStart); err != nil { + t.Fatalf("rewind stderr capture: %v", err) + } + emitEarlyAgentMetadataValidationError(invalidAgentExtError(), []string{"version", "--format", "table"}) + if err := stderrFile.Sync(); err != nil { + t.Fatalf("sync human stderr capture: %v", err) + } + humanOutput, err := os.ReadFile(stderrFile.Name()) + if err != nil { + t.Fatalf("read human stderr capture: %v", err) + } + if json.Valid(humanOutput) || !strings.Contains(string(humanOutput), "DWS_AGENT_EXT") || + strings.Contains(string(humanOutput), "must-not-leak") { + t.Fatalf("human validation error presentation = %q", humanOutput) + } + + var capturedRunner *runtimeRunner + testseam.Swap(t, &rootNewCommandRunnerWithFlags, func(flags *GlobalFlags) executor.Runner { + capturedRunner = newCommandRunnerWithFlags(flags).(*runtimeRunner) + return capturedRunner + }) + cachedSnapshot := agentMetadataSnapshot{version: "9.8.7", ext: `{"ua":"cached"}`} + _ = newRootCommandWithMode( + contextWithAgentMetadataSnapshot(context.Background(), cachedSnapshot), + nil, + false, + true, + true, + ) + if capturedRunner == nil || capturedRunner.agentMetadata == nil || *capturedRunner.agentMetadata != cachedSnapshot { + t.Fatalf("root runner Agent metadata = %#v, want %#v", capturedRunner, cachedSnapshot) + } +} + +func TestCrossPlatformCoverageAgentMetadataExcludedFromServiceDiscovery(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + t.Setenv(envDWSAgentHost, "") + t.Setenv(agentproduct.EnvName, "") + t.Setenv(envDWSAgentVersion, "3.0.0") + t.Setenv(envDWSAgentExt, `{"umt":"test-value"}`) + + headers := resolveMCPRequestHeadersForInvocation(executor.Invocation{ + CanonicalProduct: mcpMetaServerID, + Tool: mcpMetaURLTool, + }) + if hasHeaderFold(headers, transport.HeaderAgentVersion) || hasHeaderFold(headers, transport.HeaderAgentExt) { + t.Fatalf("service-discovery request leaked Agent metadata: %#v", headers) + } + + headers = resolveMCPRequestHeadersForInvocation(executor.Invocation{CanonicalProduct: "doc", Tool: "read"}) + if headers[transport.HeaderAgentVersion] != "3.0.0" || headers[transport.HeaderAgentExt] == "" { + t.Fatalf("ordinary MCP request omitted Agent metadata: %#v", headers) + } + cached := agentMetadataSnapshot{version: "3.1.0", ext: "{}"} + headers = resolveMCPRequestHeadersForInvocation(executor.Invocation{CanonicalProduct: "doc", Tool: "read"}, cached) + if headers[transport.HeaderAgentVersion] != "3.1.0" || headers[transport.HeaderAgentExt] != "{}" { + t.Fatalf("ordinary MCP request ignored its validated snapshot: %#v", headers) + } +} + +func TestCrossPlatformCoverageAgentMetadataMCPAndPluginScoping(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + t.Setenv(envDWSAgentHost, "") + t.Setenv(agentproduct.EnvName, "") + t.Setenv(envDWSAgentVersion, "2.0.0") + t.Setenv(envDWSAgentExt, `{"ua":"test-agent/2.0"}`) + + oldEdition := edition.Get() + t.Cleanup(func() { edition.Override(oldEdition) }) + edition.Override(&edition.Hooks{}) + + pluginAuthMu.Lock() + oldPluginRegistry := pluginAuthRegistry + pluginAuthRegistry = make(map[string]*PluginAuth) + pluginAuthMu.Unlock() + t.Cleanup(func() { + pluginAuthMu.Lock() + pluginAuthRegistry = oldPluginRegistry + pluginAuthMu.Unlock() + }) + dynamicMu.Lock() + oldDynamicEndpoints := dynamicEndpoints + oldDynamicProducts := dynamicProducts + oldDynamicAliases := dynamicAliases + oldDynamicToolEndpoints := dynamicToolEndpoints + dynamicEndpoints = nil + dynamicProducts = nil + dynamicAliases = nil + dynamicToolEndpoints = nil + dynamicMu.Unlock() + t.Cleanup(func() { + dynamicMu.Lock() + dynamicEndpoints = oldDynamicEndpoints + dynamicProducts = oldDynamicProducts + dynamicAliases = oldDynamicAliases + dynamicToolEndpoints = oldDynamicToolEndpoints + dynamicMu.Unlock() + }) + + testseam.Swap(t, &runnerPreflightDocDownload, func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error { + return nil + }) + type capturedRequest struct { + headers map[string]string + token string + } + var captured []capturedRequest + testseam.Swap(t, &runnerCallTool, func(client *transport.Client, _ context.Context, _, _ string, _ map[string]any) (transport.ToolCallResult, error) { + copyHeaders := make(map[string]string, len(client.ExtraHeaders)) + for key, value := range client.ExtraHeaders { + copyHeaders[key] = value + } + captured = append(captured, capturedRequest{headers: copyHeaders, token: client.AuthToken}) + return transport.ToolCallResult{Content: map[string]any{"value": "ok"}}, nil + }) + + created := newCommandRunnerWithFlags(&GlobalFlags{}).(*runtimeRunner) + if hasHeaderFold(created.transport.ExtraHeaders, transport.HeaderAgentVersion) || + hasHeaderFold(created.transport.ExtraHeaders, transport.HeaderAgentExt) { + t.Fatalf("new runner resolved Agent metadata before invocation validation: %#v", created.transport.ExtraHeaders) + } + + // runSingle must not cache ambient MCP metadata on the shared base transport. + // Use mock mode to exercise the path without authentication or network I/O. + t.Setenv(envDWSAgentVersion, "2.0.1") + refreshRunner := &runtimeRunner{ + transport: transport.NewClient(nil), + globalFlags: &GlobalFlags{Mock: true}, + auditSink: audit.NopSink{}, + } + refreshInvocation := executor.Invocation{CanonicalProduct: "refresh", Tool: "tool", Params: map[string]any{}} + if _, err := refreshRunner.runSingle(context.Background(), refreshInvocation, false); err != nil { + t.Fatalf("mock runSingle failed: %v", err) + } + if hasHeaderFold(refreshRunner.transport.ExtraHeaders, transport.HeaderAgentVersion) { + t.Fatalf("runSingle mutated the shared transport Header map: %#v", refreshRunner.transport.ExtraHeaders) + } + t.Setenv(envDWSAgentVersion, "2.0.0") + + r := &runtimeRunner{ + transport: transport.NewClient(nil), + globalFlags: &GlobalFlags{Token: "test-token"}, + auditSink: audit.NopSink{}, + agentMetadata: &agentMetadataSnapshot{ + version: "2.0.0", + ext: `{"ua":"test-agent/2.0"}`, + }, + } + builtIn := executor.Invocation{CanonicalProduct: "built-in", Tool: "tool", Params: map[string]any{}} + if _, err := r.executeInvocation(context.Background(), "https://example.test", builtIn); err != nil { + t.Fatalf("built-in invocation failed: %v", err) + } + + pluginDescriptor := mcptypes.ServerDescriptor{ + Key: "third-party", + Endpoint: "https://plugin.example.test", + CLI: mcptypes.CLIOverlay{ID: "third-party"}, + AuthHeaders: map[string]string{ + "X-Plugin": "yes", + "X-Dws-Agent-Ver": "plugin-must-not-forge-version", + "X-Dws-Agent-Ext": `{"source":"plugin"}`, + }, + } + registerPluginHTTPServer(pluginDescriptor) + registeredPlugin, pluginOwned := LookupPluginAuth("third-party") + if !pluginOwned || registeredPlugin == nil || registeredPlugin.Token != "" { + t.Fatalf("anonymous HTTP plugin ownership = %#v, %v", registeredPlugin, pluginOwned) + } + registerPluginHTTPServer(mcptypes.ServerDescriptor{ + Key: "anonymous-empty", + Endpoint: "https://anonymous.example.test", + CLI: mcptypes.CLIOverlay{ID: "anonymous-empty"}, + }) + if emptyPlugin, owned := LookupPluginAuth("anonymous-empty"); !owned || emptyPlugin == nil || emptyPlugin.Token != "" || len(emptyPlugin.ExtraHeaders) != 0 { + t.Fatalf("headerless HTTP plugin ownership = %#v, %v", emptyPlugin, owned) + } + originalPluginHeaders := maps.Clone(registeredPlugin.ExtraHeaders) + pluginInvocation := executor.Invocation{CanonicalProduct: "third-party", Tool: "tool", Params: map[string]any{}} + if _, err := r.executeInvocation(context.Background(), "https://plugin.example.test", pluginInvocation); err != nil { + t.Fatalf("plugin invocation failed: %v", err) + } + + if len(captured) != 2 { + t.Fatalf("captured %d calls, want 2", len(captured)) + } + if captured[0].headers[transport.HeaderAgentVersion] != "2.0.0" || captured[0].headers[transport.HeaderAgentExt] != `{"ua":"test-agent/2.0"}` { + t.Fatalf("built-in MCP metadata = %#v", captured[0].headers) + } + if hasHeaderFold(captured[1].headers, transport.HeaderAgentVersion) || hasHeaderFold(captured[1].headers, transport.HeaderAgentExt) { + t.Fatalf("plugin request leaked Agent metadata: %#v", captured[1].headers) + } + if got := captured[1].headers["X-Plugin"]; got != "yes" { + t.Fatalf("plugin-owned header = %q, want yes", got) + } + if captured[1].token != "" { + t.Fatalf("anonymous plugin unexpectedly received default OAuth token") + } + if !maps.Equal(registeredPlugin.ExtraHeaders, originalPluginHeaders) { + t.Fatalf("plugin Header sanitization mutated registry state: got %#v want %#v", registeredPlugin.ExtraHeaders, originalPluginHeaders) + } + if got := pluginRequestHeaders(nil); got != nil { + t.Fatalf("nil plugin auth produced Headers: %#v", got) + } + if got := pluginRequestHeaders(&PluginAuth{ExtraHeaders: map[string]string{ + "X-DWS-AGENT-VER": "forged", + "X-DWS-AGENT-EXT": `{"forged":true}`, + }}); got != nil { + t.Fatalf("reserved-only plugin Headers survived sanitization: %#v", got) + } + + // Keep the execution-boundary auth guard independently testable: even if a + // future token provider returns an empty token without an error, built-in MCP + // calls must fail before preflight or transport while anonymous plugins remain + // valid above. + resolveCalled := false + testseam.Swap(t, &runnerResolveAuthToken, func(*runtimeRunner, context.Context) (string, error) { + resolveCalled = true + return "", nil + }) + callsBefore := len(captured) + unauthenticated := &runtimeRunner{ + transport: transport.NewClient(nil), + globalFlags: &GlobalFlags{}, + auditSink: audit.NopSink{}, + } + if _, err := unauthenticated.executeInvocation(context.Background(), "https://example.test", executor.Invocation{CanonicalProduct: "built-in-unauthenticated", Tool: "tool"}); err == nil || !isAuthError(err) { + t.Fatalf("unauthenticated built-in request = %v, want auth error", err) + } + if !resolveCalled { + t.Fatal("unauthenticated request did not exercise the token resolver") + } + if len(captured) != callsBefore { + t.Fatalf("unauthenticated built-in request reached transport: calls %d -> %d", callsBefore, len(captured)) + } +} + +func hasHeaderFold(headers map[string]string, want string) bool { + for key := range headers { + if strings.EqualFold(key, want) { + return true + } + } + return false +} diff --git a/internal/app/agent_product_test.go b/internal/app/agent_product_test.go index 2cd8219e..8cc0c92b 100644 --- a/internal/app/agent_product_test.go +++ b/internal/app/agent_product_test.go @@ -158,7 +158,7 @@ func TestApplyAgentProductHeader(t *testing.T) { } } -func TestRootRejectsInvalidAgentProductBeforeEditionHook(t *testing.T) { +func TestCrossPlatformCoverageRootRejectsInvalidAgentProductBeforeEditionHook(t *testing.T) { t.Setenv("DWS_CONFIG_DIR", t.TempDir()) const invalidValue = "DO_NOT ECHO" t.Setenv(agentproduct.EnvName, invalidValue) diff --git a/internal/app/auth_registry.go b/internal/app/auth_registry.go index 1083bd5b..60c55be6 100644 --- a/internal/app/auth_registry.go +++ b/internal/app/auth_registry.go @@ -15,11 +15,10 @@ package app import "sync" -// PluginAuth holds authentication credentials for a plugin-owned -// streamable-http MCP server. Each server is keyed by its canonical -// product ID (CLI.ID) so that different servers can use independent -// tokens without interfering with each other or with the default -// DingTalk OAuth token. +// PluginAuth marks ownership of a plugin-owned streamable-http MCP server and +// holds its optional authentication credentials. Every accepted HTTP plugin, +// including an anonymous one, has a non-nil record keyed by canonical product +// ID (CLI.ID) so execution never falls back to built-in DingTalk OAuth. type PluginAuth struct { // Token is the Bearer token extracted from the plugin's // "Authorization" header (e.g. a third-party API key). @@ -39,27 +38,25 @@ var ( pluginAuthRegistry = make(map[string]*PluginAuth) ) -// RegisterPluginAuth stores authentication credentials for a plugin -// server keyed by its canonical product ID. The runner looks up these -// credentials at execution time to inject the correct Bearer token -// instead of the default DingTalk OAuth token. +// RegisterPluginAuth stores ownership and optional authentication credentials +// for a plugin server keyed by its canonical product ID. func RegisterPluginAuth(productID string, auth *PluginAuth) { pluginAuthMu.Lock() defer pluginAuthMu.Unlock() pluginAuthRegistry[productID] = auth } -// ClearPluginAuth removes credentials for a plugin product. Registration uses -// this before applying an accepted descriptor so a descriptor without custom -// auth cannot inherit stale credentials from an earlier root construction. +// ClearPluginAuth removes the ownership and credential record for a plugin +// product. func ClearPluginAuth(productID string) { pluginAuthMu.Lock() defer pluginAuthMu.Unlock() delete(pluginAuthRegistry, productID) } -// LookupPluginAuth returns the authentication credentials registered -// for the given product ID, or nil if none exists. +// LookupPluginAuth returns plugin ownership and optional authentication +// credentials for the product ID. The bool denotes ownership, not whether a +// Bearer token is present. func LookupPluginAuth(productID string) (*PluginAuth, bool) { pluginAuthMu.RLock() defer pluginAuthMu.RUnlock() diff --git a/internal/app/coverage_more_test.go b/internal/app/coverage_more_test.go index dae675b6..c56c1247 100644 --- a/internal/app/coverage_more_test.go +++ b/internal/app/coverage_more_test.go @@ -61,11 +61,16 @@ func appRPCServer(t *testing.T, initOK, listOK bool) *httptest.Server { } func TestCrossPlatformCoveragePluginAuthCoverage(t *testing.T) { - registerPluginAuthFromHeaders(mcptypes.ServerDescriptor{Key: "fallback", Endpoint: "%", AuthHeaders: map[string]string{"Authorization": "token"}}) - registerPluginAuthFromHeaders(mcptypes.ServerDescriptor{Key: "server", Endpoint: "https://x.test", CLI: mcptypes.CLIOverlay{ID: "cli"}, AuthHeaders: map[string]string{"Authorization": "Bearer token", "X": "Y"}}) - registerPluginAuthFromHeaders(mcptypes.ServerDescriptor{Key: "none"}) - if got, ok := LookupPluginAuth("cli"); !ok || got == nil || got.Token != "token" { - t.Fatalf("registered plugin auth = %#v, %v", got, ok) + fallback := pluginAuthFromServerDescriptor(mcptypes.ServerDescriptor{Key: "fallback", Endpoint: "%", AuthHeaders: map[string]string{"Authorization": "token"}}) + if fallback == nil || fallback.Token != "token" || len(fallback.TrustedDomains) != 0 { + t.Fatalf("fallback plugin auth = %#v", fallback) + } + got := pluginAuthFromServerDescriptor(mcptypes.ServerDescriptor{Key: "server", Endpoint: "https://x.test", CLI: mcptypes.CLIOverlay{ID: "cli"}, AuthHeaders: map[string]string{"Authorization": "Bearer token", "X": "Y"}}) + if got == nil || got.Token != "token" || got.ExtraHeaders["X"] != "Y" || len(got.TrustedDomains) != 2 { + t.Fatalf("plugin auth = %#v", got) + } + if anonymous := pluginAuthFromServerDescriptor(mcptypes.ServerDescriptor{Key: "none"}); anonymous == nil || anonymous.Token != "" { + t.Fatalf("anonymous plugin ownership = %#v", anonymous) } } diff --git a/internal/app/identity_export.go b/internal/app/identity_export.go index 9b2c36f0..dcc5b9a9 100644 --- a/internal/app/identity_export.go +++ b/internal/app/identity_export.go @@ -13,9 +13,9 @@ package app -// MCPIdentityHeaders returns the same header map used for MCP HTTP requests -// (agent identity, env trace headers, edition MergeHeaders). Intended for -// non-MCP transports such as the A2A gateway client. +// MCPIdentityHeaders returns the shared identity header map used by non-MCP +// transports such as the A2A gateway client. MCP-only Agent version and +// extension metadata are intentionally excluded. func MCPIdentityHeaders() map[string]string { return resolveIdentityHeaders() } diff --git a/internal/app/root.go b/internal/app/root.go index f9ad0a9f..cf42e616 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -79,6 +79,7 @@ var ( rootPluginSyncSkills = plugin.SyncSkills rootAuthLoadTokenData = authpkg.LoadTokenData rootNewCommandRunnerWithFlags = newCommandRunnerWithFlags + rootEmitResult = output.EmitResult ) // Execute runs the root command and returns the process exit code. @@ -136,6 +137,16 @@ func Execute() (exitCode int) { restoreArgs := rootNormalizeProcessProfileArgs() defer restoreArgs() + // Validate MCP Agent metadata before constructing the command tree. + // Construction may invoke edition registration/static-server hooks and load + // plugin PreParse handlers, so PersistentPreRunE alone is too late for the + // process entry point. Retain this exact pair for the eventual invocation. + agentMetadata := readAgentMetadataSnapshot() + if err := agentMetadata.validationError(); err != nil { + emitEarlyAgentMetadataValidationError(err, os.Args[1:]) + return apperrors.ExitCode(err) + } + timing := NewTimingCollector() defer func() { rootStopAllStdioClients() // Ensure child processes are terminated on exit @@ -147,6 +158,7 @@ func Execute() (exitCode int) { // Attach timing collector to context for use by child components ctx := WithTimingCollector(context.Background(), timing) + ctx = contextWithAgentMetadataSnapshot(ctx, agentMetadata) ctx, resultStore = output.WithResultStore(ctx) var signalState *processSignalState var stopSignals func() @@ -260,6 +272,70 @@ func Execute() (exitCode int) { return 0 } +// emitEarlyAgentMetadataValidationError preserves each built-in command's +// legacy-vs-unified output contract without running extension hooks. The +// presentation-only tree contains reviewed open-source commands and flags but +// deliberately omits edition registration, plugin loading, and visibility +// hooks; callers therefore still fail before any external hook executes. +func emitEarlyAgentMetadataValidationError(err error, args []string) { + format := processArgsFormat(args) + presentationRoot := newRootPresentationCommand() + _ = presentationRoot.PersistentFlags().Set("format", format) + if target, _, findErr := presentationRoot.Find(args); findErr == nil && target != nil && output.UsesUnifiedResult(target) { + target.SetOut(os.Stdout) + target.SetErr(os.Stderr) + result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err)) + if _, emitErr := rootEmitResult(target, result); emitErr == nil { + return + } + } + if strings.EqualFold(strings.TrimSpace(format), "json") { + _ = apperrors.PrintJSON(os.Stderr, err) + return + } + _ = apperrors.PrintHumanAt(os.Stderr, err, apperrors.VerbosityNormal) +} + +// processArgsRequestJSON preserves the CLI's machine-readable error contract +// for validation that must occur before Cobra and its presentation flags exist. +// The global format defaults to JSON; an explicit non-JSON format switches to +// the human diagnostic path. Last occurrence wins, matching pflag semantics. +func processArgsRequestJSON(args []string) bool { + return strings.EqualFold(strings.TrimSpace(processArgsFormat(args)), "json") +} + +func processArgsFormat(args []string) string { + format := "json" + for index := 0; index < len(args); index++ { + arg := args[index] + if arg == "--" { + break + } + if value, ok := strings.CutPrefix(arg, "--format="); ok { + format = value + continue + } + if value, ok := strings.CutPrefix(arg, "-f="); ok { + format = value + continue + } + if strings.HasPrefix(arg, "-f") && len(arg) > len("-f") { + format = strings.TrimPrefix(arg, "-f") + continue + } + if arg != "--format" && arg != "-f" { + continue + } + if index+1 >= len(args) { + format = "" + break + } + index++ + format = args[index] + } + return format +} + // errorInfoFromExecutionError projects the repository error model into the unified // failure body. Exit code and category are derived from the same error value, // preventing the wire and process status from drifting apart. @@ -633,12 +709,25 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) } func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine, loadRuntimeExtensions bool, declarationOnly bool) *cobra.Command { + return newRootCommandWithMode(rootCtx, engine, loadRuntimeExtensions, declarationOnly, false) +} + +func newRootPresentationCommand() *cobra.Command { + return newRootCommandWithMode(context.Background(), nil, false, true, true) +} + +func newRootCommandWithMode(rootCtx context.Context, engine *pipeline.Engine, loadRuntimeExtensions bool, declarationOnly bool, presentationOnly bool) *cobra.Command { if rootCtx == nil { rootCtx = context.Background() } flags := &GlobalFlags{} authpkg.SetRuntimeProfile(preparseProfileFlag(os.Args[1:])) runner := rootNewCommandRunnerWithFlags(flags) + if snapshot, ok := agentMetadataSnapshotFromContext(rootCtx); ok { + if runtime, ok := runner.(*runtimeRunner); ok { + runtime.agentMetadata = &snapshot + } + } root := &cobra.Command{ Use: "dws", @@ -672,15 +761,29 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine, // --output sink instead opens at Run entry (after Cobra's own // validation), so validation failures still cannot strand a // temporary file. - // Validate caller-provided identity labels before any edition hook - // or command network activity can run. Header-only library callers - // use the best-effort path in resolveIdentityHeaders instead. + // Validate caller-provided identity and MCP metadata before command + // execution hooks or network activity. The process entry point additionally + // validates Agent metadata before command-tree construction; direct Cobra + // embedding retains this execution-boundary guard. if _, err := parseAgentHost(os.Getenv(envDWSAgentHost)); err != nil { return err } if _, err := parseAgentProduct(os.Getenv(agentproduct.EnvName)); err != nil { return err } + agentMetadata, cached := agentMetadataSnapshotFromContext(cmd.Context()) + if !cached { + agentMetadata = readAgentMetadataSnapshot() + } + if err := agentMetadata.validationError(); err != nil { + return err + } + if runtime, ok := runner.(*runtimeRunner); ok { + // Retain the exact validated pair for this command execution so a + // concurrently mutating embedding environment cannot change what is + // later applied after edition and credential hooks. + runtime.agentMetadata = &agentMetadata + } if shouldDetectNestedSkillLayout(cmd) { if found, err := detectNestedMultiSkillLayout(); err == nil && found { fmt.Fprintln(cmd.ErrOrStderr(), "⚠️ 检测到旧升级器留下的嵌套 Skill;请运行 dws skill setup --mode multi 查看迁移计划并确认") @@ -777,10 +880,12 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine, // PAT authorization commands (open-source core) pat.RegisterCommands(root, patCaller) - if fn := edition.Get().RegisterExtraCommands; fn != nil { - caller := newToolCallerAdapter(runner, flags) - fn(root, caller) - deduplicateCommands(root) + if !presentationOnly { + if fn := edition.Get().RegisterExtraCommands; fn != nil { + caller := newToolCallerAdapter(runner, flags) + fn(root, caller) + deduplicateCommands(root) + } } if loadRuntimeExtensions { // Resolve plugins only after the complete distribution command tree is @@ -791,7 +896,9 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine, addPluginCommandsSafe(root, pluginCmds) } } - hideNonDirectRuntimeCommands(root) + if !presentationOnly { + hideNonDirectRuntimeCommands(root) + } configureRootHelp(root) // Set custom flag error handler for better UX root.SetFlagErrorFunc(flagErrorWithSuggestions) @@ -1755,17 +1862,16 @@ func distributionRootOwns(root *cobra.Command, name string) bool { func registerPluginHTTPServer(srv mcptypes.ServerDescriptor) { AppendDynamicServer(srv) productID := firstNonEmptyPluginString(srv.CLI.ID, srv.Key) - ClearPluginAuth(productID) - if len(srv.AuthHeaders) > 0 { - registerPluginAuthFromHeaders(srv) - } + // Register ownership for every accepted HTTP plugin, including anonymous + // plugins. Execution must never fall back to the built-in DingTalk OAuth or + // Agent-metadata path merely because a plugin has no Authorization Header. + RegisterPluginAuth(productID, pluginAuthFromServerDescriptor(srv)) } -// registerPluginAuthFromHeaders extracts authentication credentials from -// a server descriptor's AuthHeaders and registers them in the global -// PluginAuth registry. The runner uses this registry at execution time -// to inject the correct Bearer token for third-party MCP servers. -func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) { +// pluginAuthFromServerDescriptor extracts plugin-owned credentials and custom +// Headers. A non-nil result also acts as the HTTP plugin ownership marker for +// anonymous plugins. +func pluginAuthFromServerDescriptor(srv mcptypes.ServerDescriptor) *PluginAuth { authToken := "" extraHeaders := make(map[string]string) for key, value := range srv.AuthHeaders { @@ -1776,20 +1882,18 @@ func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) { extraHeaders[key] = value } } - if authToken == "" { - return - } var trustedDomains []string if parsed, err := url.Parse(srv.Endpoint); err == nil { host := parsed.Hostname() - trustedDomains = []string{host, "*." + host} + if host != "" { + trustedDomains = []string{host, "*." + host} + } } - productID := firstNonEmptyPluginString(srv.CLI.ID, srv.Key) - RegisterPluginAuth(productID, &PluginAuth{ + return &PluginAuth{ Token: authToken, ExtraHeaders: extraHeaders, TrustedDomains: trustedDomains, - }) + } } // newPipelineEngine creates and configures the pipeline engine with diff --git a/internal/app/runner.go b/internal/app/runner.go index 3e9290c4..175f70b9 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -137,7 +137,6 @@ func newCommandRunnerWithFlags(flags *GlobalFlags) executor.Runner { httpClient = &http.Client{Timeout: time.Duration(flags.Timeout) * time.Second} } transportClient := transport.NewClient(httpClient) - transportClient.ExtraHeaders = resolveIdentityHeaders() transportClient.FileLogger = FileLoggerInstance() return &runtimeRunner{ transport: transportClient, @@ -156,12 +155,14 @@ type runtimeRunner struct { enforceContentScan bool includeScanReport bool auditSink audit.Sink + agentMetadata *agentMetadataSnapshot } var ( runnerResolveMultiProfileSelections = resolveMultiProfileSelections runnerResolveProfile = authpkg.ResolveProfile runnerGetCachedRuntimeToken = getCachedRuntimeToken + runnerResolveAuthToken = (*runtimeRunner).resolveAuthToken runnerPreflightDocDownload = (*runtimeRunner).preflightDocDownload runnerCallTool = (*transport.Client).CallTool runnerStdioEnsureInitialized = (*transport.StdioClient).EnsureInitialized @@ -237,7 +238,6 @@ func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invoc if r.transport == nil { return r.fallback.Run(ctx, invocation) } - r.transport.ExtraHeaders = resolveIdentityHeaders() // Mock mode: skip endpoint resolution, use a placeholder endpoint. if r.globalFlags != nil && r.globalFlags.Mock { @@ -543,9 +543,9 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, emitAudit(auditSink, execID, invokeStart, invocation, endpoint, retErr, version) }() - // Check if this product has plugin-level auth credentials registered. - // If so, use the plugin's token instead of the default DingTalk OAuth token. - // This allows third-party MCP servers (e.g. Bailian) to use their own API keys. + // Check whether this product belongs to an HTTP plugin. Every accepted + // plugin has an ownership record; credentials within that record are + // optional. Plugin requests never fall back to the default DingTalk OAuth. pluginAuth, hasPluginAuth := LookupPluginAuth(invocation.CanonicalProduct) authToken := "" @@ -553,7 +553,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, authToken = pluginAuth.Token } else if !invocation.DryRun && (r.globalFlags == nil || !r.globalFlags.Mock) { var tokenErr error - authToken, tokenErr = r.resolveAuthToken(ctx) + authToken, tokenErr = runnerResolveAuthToken(r, ctx) if tokenErr != nil { return executor.Result{}, tokenResolutionError(tokenErr) } @@ -603,9 +603,10 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, }, nil } - // Fail-fast: reject unauthenticated requests before making network calls. - // This provides a clear error message instead of cryptic HTTP 400 from MCP. - if strings.TrimSpace(authToken) == "" { + // Preserve a final execution-boundary guard even though the built-in token + // resolver normally returns either a non-empty token or an error. HTTP + // plugins are ownership-scoped separately and may intentionally be anonymous. + if !hasPluginAuth && strings.TrimSpace(authToken) == "" { return executor.Result{}, apperrors.NewAuth( "未登录,请先执行 dws auth login", apperrors.WithReason("not_authenticated"), @@ -616,12 +617,20 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, var tc *transport.Client if hasPluginAuth { - // Use plugin-level auth: inject the plugin's token and trust its domains. - tc = r.transport.WithAuth(authToken, pluginAuth.ExtraHeaders) + // Plugin ownership is authoritative even when the plugin is anonymous. + // Copy and sanitize manifest headers so plugins cannot opt themselves into + // DWS-owned Agent metadata by declaring the reserved names directly. + tc = r.transport.WithAuth(authToken, pluginRequestHeaders(pluginAuth)) tc.TrustedDomains = pluginAuth.TrustedDomains } else { - // Default path: use DingTalk OAuth token with identity headers. - tc = r.transport.WithAuth(authToken, resolveIdentityHeaders()) + // Default path: use DingTalk OAuth token with identity headers. Agent + // metadata is resolved exactly once per invocation and is excluded from + // helper-only service-discovery requests. + if r.agentMetadata != nil { + tc = r.transport.WithAuth(authToken, resolveMCPRequestHeadersForInvocation(invocation, *r.agentMetadata)) + } else { + tc = r.transport.WithAuth(authToken, resolveMCPRequestHeadersForInvocation(invocation)) + } } callCtx := ctx @@ -1069,6 +1078,10 @@ func resolveIdentityHeaders() map[string]string { } else { delete(headers, agentproduct.HeaderName) } + // Agent version and extension are intentionally MCP-request-only. Remove + // every case variant potentially supplied by an edition or credential hook + // so shared consumers such as A2A cannot inherit them. + removeAgentMetadataHeaders(headers) return headers } diff --git a/internal/auth/agent_metadata_headers_test.go b/internal/auth/agent_metadata_headers_test.go new file mode 100644 index 00000000..adab2952 --- /dev/null +++ b/internal/auth/agent_metadata_headers_test.go @@ -0,0 +1,80 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import ( + "context" + "io" + "net/http" + "strings" + "testing" +) + +type agentMetadataHeaderRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f agentMetadataHeaderRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestCrossPlatformCoverageOAuthRequestsExcludeAgentMetadataHeaders(t *testing.T) { + t.Setenv("DWS_AGENT_VER", "1.2.3-test") + t.Setenv("DWS_AGENT_EXT", `{"umt":"test-umt","miniwua":"test-wua","ua":"test-agent"}`) + + assertExcluded := func(t *testing.T, header http.Header) { + t.Helper() + for _, name := range []string{"x-dws-agent-ver", "x-dws-agent-ext"} { + if values := header.Values(name); len(values) != 0 { + t.Fatalf("OAuth request header %q = %q, want absent", name, values) + } + } + } + + newCaptureClient := func(responseBody string) (*http.Client, <-chan http.Header) { + seen := make(chan http.Header, 1) + client := &http.Client{Transport: agentMetadataHeaderRoundTripFunc(func(req *http.Request) (*http.Response, error) { + seen <- req.Header.Clone() + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": {"application/json"}}, + Body: io.NopCloser(strings.NewReader(responseBody)), + Request: req, + }, nil + })} + return client, seen + } + + t.Run("JSON token request", func(t *testing.T) { + client, seen := newCaptureClient(`{}`) + provider := &OAuthProvider{httpClient: client} + if _, err := provider.postJSON(context.Background(), "https://oauth.test/token", map[string]string{ + "code": "test-code", + "grantType": "authorization_code", + }); err != nil { + t.Fatalf("postJSON() error = %v", err) + } + assertExcluded(t, <-seen) + }) + + t.Run("device code request", func(t *testing.T) { + client, seen := newCaptureClient(`{"success":true,"result":{"deviceCode":"test-device-code","userCode":"TEST-CODE","verificationUri":"https://example.test/device","expiresIn":900,"interval":1}}`) + provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger()) + provider.clientID = "test-client-id" + provider.httpClient = client + provider.SetBaseURL("https://oauth.test") + if _, err := provider.requestDeviceCode(context.Background()); err != nil { + t.Fatalf("requestDeviceCode() error = %v", err) + } + assertExcluded(t, <-seen) + }) +} diff --git a/internal/logging/redact.go b/internal/logging/redact.go index d84003f7..adb6b40c 100644 --- a/internal/logging/redact.go +++ b/internal/logging/redact.go @@ -26,6 +26,8 @@ import ( var sensitiveKeys = map[string]bool{ "authorization": true, "x-user-access-token": true, + "dws_agent_ext": true, + "x-dws-agent-ext": true, "client_secret": true, "client-secret": true, "token": true, diff --git a/internal/logging/redact_test.go b/internal/logging/redact_test.go index e3830df8..eb4578fc 100644 --- a/internal/logging/redact_test.go +++ b/internal/logging/redact_test.go @@ -30,6 +30,12 @@ func TestIsSensitiveKey(t *testing.T) { {"authorization", true}, {"x-user-access-token", true}, {"X-User-Access-Token", true}, + {"DWS_AGENT_EXT", true}, + {"dws_agent_ext", true}, + {"x-dws-agent-ext", true}, + {"X-Dws-Agent-Ext", true}, + {"DWS_AGENT_VER", false}, + {"x-dws-agent-ver", false}, {"client_secret", true}, {"client-secret", true}, {"token", true}, @@ -128,17 +134,24 @@ func TestSanitizeArguments_Empty(t *testing.T) { func TestRedactHeaders(t *testing.T) { t.Parallel() - headers := http.Header{ - "Authorization": {"Bearer token123456"}, - "Content-Type": {"application/json"}, - } + headers := make(http.Header) + headers.Set("Authorization", "Bearer test-credential-value") + headers.Set("Content-Type", "application/json") + headers.Set("DWS_AGENT_EXT", `{"umt":"test-umt-value"}`) + headers.Set("x-dws-agent-ext", `{"ua":"test-agent-value"}`) attrs := RedactHeaders(headers) - if len(attrs) != 2 { - t.Fatalf("expected 2 attrs, got %d", len(attrs)) + if len(attrs) != 4 { + t.Fatalf("expected 4 attrs, got %d", len(attrs)) } for _, attr := range attrs { - if attr.Key == "header.authorization" && !strings.Contains(attr.Value.String(), "***") { - t.Fatalf("authorization should be redacted: %s", attr.Value.String()) + switch attr.Key { + case "header.authorization", "header.dws_agent_ext", "header.x-dws-agent-ext": + if !strings.Contains(attr.Value.String(), "***") { + t.Fatalf("%s should be redacted: %s", attr.Key, attr.Value.String()) + } + if strings.Contains(attr.Value.String(), "test-") { + t.Fatalf("%s leaked its original value: %s", attr.Key, attr.Value.String()) + } } if attr.Key == "header.content-type" && attr.Value.String() != "application/json" { t.Fatalf("content-type should not be redacted: %s", attr.Value.String()) diff --git a/internal/transport/agent_metadata_test.go b/internal/transport/agent_metadata_test.go new file mode 100644 index 00000000..3767ce87 --- /dev/null +++ b/internal/transport/agent_metadata_test.go @@ -0,0 +1,93 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package transport + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestCrossPlatformCoverageAgentMetadataReachesEveryMCPMethod(t *testing.T) { + t.Parallel() + + const ( + agentVersion = "1.2.3-test+7" + agentExt = `{"umt":"test-umt","nested":{"enabled":true}}` + cliVersion = "9.8.7-cli" + userAgent = "dws-user-agent-test/1.0" + ) + + seen := make(map[string]int) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request requestEnvelope + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + seen[request.Method]++ + if got := r.Header.Get(HeaderAgentVersion); got != agentVersion { + t.Errorf("%s %s = %q, want %q", request.Method, HeaderAgentVersion, got, agentVersion) + } + if got := r.Header.Get(HeaderAgentExt); got != agentExt { + t.Errorf("%s %s = %q, want %q", request.Method, HeaderAgentExt, got, agentExt) + } + if got := r.Header.Get(HeaderVersion); got != cliVersion { + t.Errorf("%s %s = %q, want %q", request.Method, HeaderVersion, got, cliVersion) + } + if got := r.Header.Get("User-Agent"); got != userAgent { + t.Errorf("%s User-Agent = %q, want preset value %q", request.Method, got, userAgent) + } + + w.Header().Set("Content-Type", "application/json") + switch request.Method { + case "initialize": + _, _ = fmt.Fprintf(w, `{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"%s","capabilities":{}}}`, supportedProtocolVersions[0]) + case "tools/list": + _, _ = fmt.Fprint(w, `{"jsonrpc":"2.0","id":2,"result":{"tools":[]}}`) + case "tools/call": + _, _ = fmt.Fprint(w, `{"jsonrpc":"2.0","id":3,"result":{"content":{"success":true}}}`) + default: + http.Error(w, "unexpected method", http.StatusBadRequest) + } + })) + defer server.Close() + + client := NewClient(server.Client()) + client.ExtraHeaders = map[string]string{ + HeaderAgentVersion: agentVersion, + HeaderAgentExt: agentExt, + HeaderVersion: cliVersion, + "User-Agent": userAgent, + } + ctx := context.Background() + if _, err := client.Initialize(ctx, server.URL); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + if _, err := client.ListTools(ctx, server.URL); err != nil { + t.Fatalf("ListTools() error = %v", err) + } + if _, err := client.CallTool(ctx, server.URL, "test_tool", map[string]any{"value": "safe"}); err != nil { + t.Fatalf("CallTool() error = %v", err) + } + + for _, method := range []string{"initialize", "tools/list", "tools/call"} { + if got := seen[method]; got != 1 { + t.Errorf("%s request count = %d, want 1", method, got) + } + } +} diff --git a/internal/transport/client.go b/internal/transport/client.go index c9184a41..ddbae10d 100644 --- a/internal/transport/client.go +++ b/internal/transport/client.go @@ -68,10 +68,12 @@ const ( defaultRetryMaxDelay = 5 * time.Second // Security headers - HeaderSource = "X-Cli-Source" - HeaderVersion = "X-Cli-Version" - HeaderExecutionId = "X-Cli-Execution-Id" - SourceValue = "dws-cli" + HeaderSource = "X-Cli-Source" + HeaderVersion = "X-Cli-Version" + HeaderExecutionId = "X-Cli-Execution-Id" + HeaderAgentVersion = "x-dws-agent-ver" + HeaderAgentExt = "x-dws-agent-ext" + SourceValue = "dws-cli" ) // Supported MCP protocol versions, ordered from newest to oldest. @@ -256,20 +258,43 @@ func NewClient(httpClient *http.Client) *Client { // safeRedirectPolicy prevents credential headers from being forwarded // when a response redirects to a different host (e.g. API 302 → CDN). -// Strips Authorization, x-user-access-token on cross-host redirects; -// other headers like X-Cli-* pass through. +// Credentials and Agent extension context are bound to the initial origin: +// once a redirect chain leaves that origin they remain stripped for every +// subsequent hop, including a redirect back to the initial origin. +// Non-sensitive headers like X-Cli-* and x-dws-agent-ver pass through. func safeRedirectPolicy(req *http.Request, via []*http.Request) error { if len(via) >= 10 { return fmt.Errorf("too many redirects") } - if len(via) > 0 && req.URL.Host != via[0].URL.Host { - // Cross-host redirect: strip sensitive headers to prevent credential leakage + if redirectChainLeftInitialOrigin(req, via) { + req.Header.Del(HeaderAgentExt) req.Header.Del("Authorization") req.Header.Del("x-user-access-token") } return nil } +func redirectChainLeftInitialOrigin(req *http.Request, via []*http.Request) bool { + if len(via) == 0 { + return false + } + initialURL := via[0].URL + if !sameOrigin(req.URL, initialURL) { + return true + } + for _, previous := range via[1:] { + if !sameOrigin(previous.URL, initialURL) { + return true + } + } + return false +} + +func sameOrigin(left, right *url.URL) bool { + return strings.EqualFold(left.Scheme, right.Scheme) && + strings.EqualFold(left.Host, right.Host) +} + // WithAuth returns a shallow copy of c with the given auth token and extra // headers. The returned client shares the underlying HTTP client but is safe // to use concurrently with the original. diff --git a/internal/transport/redirect_test.go b/internal/transport/redirect_test.go new file mode 100644 index 00000000..0614174f --- /dev/null +++ b/internal/transport/redirect_test.go @@ -0,0 +1,140 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package transport + +import ( + "net/http" + "strings" + "testing" +) + +func TestCrossPlatformCoverageSafeRedirectPolicyAgentMetadataHeaders(t *testing.T) { + t.Parallel() + + newRequest := func(t *testing.T, rawURL string) *http.Request { + t.Helper() + req, err := http.NewRequest(http.MethodGet, rawURL, nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set(HeaderAgentVersion, "1.2.3-test") + req.Header.Set(HeaderAgentExt, `{"ua":"test-agent-value"}`) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("x-user-access-token", "test-token") + return req + } + + assertHeaders := func(t *testing.T, request *http.Request, wantExt bool) { + t.Helper() + if got := request.Header.Get(HeaderAgentVersion); got != "1.2.3-test" { + t.Fatalf("agent version = %q, want retained", got) + } + gotExt := request.Header.Get(HeaderAgentExt) + if wantExt && gotExt == "" { + t.Fatal("agent extension was removed") + } + if !wantExt && gotExt != "" { + t.Fatalf("agent extension leaked across origins: %q", gotExt) + } + for _, key := range []string{"Authorization", "x-user-access-token"} { + got := request.Header.Get(key) + if wantExt && got == "" { + t.Fatalf("same-origin redirect removed %s", key) + } + if !wantExt && got != "" { + t.Fatalf("credential %s leaked across origins", key) + } + } + } + + t.Run("same origin retains version and extension", func(t *testing.T) { + t.Parallel() + previous := newRequest(t, "https://api.example.test/start") + redirected := newRequest(t, "https://api.example.test/next") + + if err := safeRedirectPolicy(redirected, []*http.Request{previous}); err != nil { + t.Fatal(err) + } + assertHeaders(t, redirected, true) + }) + + t.Run("cross host strips extension and retains version", func(t *testing.T) { + t.Parallel() + previous := newRequest(t, "https://api.example.test/start") + redirected := newRequest(t, "https://cdn.example.test/asset") + + if err := safeRedirectPolicy(redirected, []*http.Request{previous}); err != nil { + t.Fatal(err) + } + assertHeaders(t, redirected, false) + }) + + t.Run("scheme downgrade on same host strips extension", func(t *testing.T) { + t.Parallel() + previous := newRequest(t, "https://api.example.test/start") + redirected := newRequest(t, "http://api.example.test/next") + + if err := safeRedirectPolicy(redirected, []*http.Request{previous}); err != nil { + t.Fatal(err) + } + assertHeaders(t, redirected, false) + }) + + t.Run("extension remains stripped after returning to initial origin", func(t *testing.T) { + t.Parallel() + initial := newRequest(t, "https://api.example.test/start") + crossOrigin := newRequest(t, "https://cdn.example.test/asset") + redirectedBack := newRequest(t, "https://api.example.test/final") + + if err := safeRedirectPolicy(redirectedBack, []*http.Request{initial, crossOrigin}); err != nil { + t.Fatal(err) + } + assertHeaders(t, redirectedBack, false) + }) + + t.Run("extension remains stripped on later cross-origin hop", func(t *testing.T) { + t.Parallel() + initial := newRequest(t, "https://api.example.test/start") + crossOrigin := newRequest(t, "https://cdn.example.test/asset") + redirected := newRequest(t, "https://cdn.example.test/final") + + if err := safeRedirectPolicy(redirected, []*http.Request{initial, crossOrigin}); err != nil { + t.Fatal(err) + } + assertHeaders(t, redirected, false) + }) + + t.Run("initial request is unchanged", func(t *testing.T) { + t.Parallel() + request := newRequest(t, "https://api.example.test/start") + + if err := safeRedirectPolicy(request, nil); err != nil { + t.Fatal(err) + } + assertHeaders(t, request, true) + }) + + t.Run("redirect limit is enforced", func(t *testing.T) { + t.Parallel() + request := newRequest(t, "https://api.example.test/final") + via := make([]*http.Request, 10) + for index := range via { + via[index] = newRequest(t, "https://api.example.test/"+strings.Repeat("x", index+1)) + } + + if err := safeRedirectPolicy(request, via); err == nil { + t.Fatal("safeRedirectPolicy() error = nil, want redirect limit error") + } + }) +} diff --git a/pkg/cli/identity.go b/pkg/cli/identity.go index e42afa4e..98adcc77 100644 --- a/pkg/cli/identity.go +++ b/pkg/cli/identity.go @@ -15,8 +15,9 @@ package cli import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app" -// MCPIdentityHeaders returns HTTP headers aligned with MCP tool calls -// (identity + edition merge). Overlays may pass this to auxiliary clients. +// MCPIdentityHeaders returns the shared identity and edition headers that +// overlays may pass to auxiliary clients. MCP-request-only metadata such as +// DWS_AGENT_VER and DWS_AGENT_EXT is intentionally excluded. func MCPIdentityHeaders() map[string]string { return app.MCPIdentityHeaders() } diff --git a/pkg/cli/identity_test.go b/pkg/cli/identity_test.go new file mode 100644 index 00000000..4ca6c44e --- /dev/null +++ b/pkg/cli/identity_test.go @@ -0,0 +1,35 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cli + +import ( + "strings" + "testing" +) + +func TestCrossPlatformCoverageMCPIdentityHeadersExcludeMCPOnlyMetadata(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + t.Setenv("DWS_AGENT_VER", "1.2.3") + t.Setenv("DWS_AGENT_EXT", `{"ua":"test-agent/1.2.3"}`) + + headers := MCPIdentityHeaders() + if headers == nil { + t.Fatal("MCPIdentityHeaders() returned nil") + } + for key := range headers { + if strings.EqualFold(key, "x-dws-agent-ver") || strings.EqualFold(key, "x-dws-agent-ext") { + t.Fatalf("shared identity export leaked MCP-only Header %q", key) + } + } +} From 19a21b8f7e788034667cbac61c90d5b05aff516d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Thu, 13 Aug 2026 10:54:51 +0800 Subject: [PATCH 38/86] fix(chat): avoid explicit zone in list-all formatting --- internal/helpers/chat.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 532a1584..34a45d0f 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -622,7 +622,7 @@ func defaultChatMessageTimeRange(cmd *cobra.Command, lookback time.Duration) (st } func formatChatMessageListAllTime(ms int64) string { - return time.UnixMilli(ms).In(shanghaiLocation()).Format("2006-01-02 15:04:05") + return time.UnixMilli(ms).Format("2006-01-02 15:04:05") } func chatMessageSearchAdvancedArgs(cmd *cobra.Command) (map[string]any, error) { From a55880ce82442f6bea08b2de0cb209654aa3e1b1 Mon Sep 17 00:00:00 2001 From: chichuan Date: Thu, 13 Aug 2026 11:10:53 +0800 Subject: [PATCH 39/86] fix(drive): reject unsafe remote names --- internal/helpers/drive_push.go | 15 +-- internal/helpers/drive_push_coverage_test.go | 11 +- .../helpers/drive_remote_unsafe_name_test.go | 125 ++++++++++++++++++ internal/helpers/drive_status.go | 38 ++++-- .../helpers/drive_status_coverage_test.go | 6 +- 5 files changed, 162 insertions(+), 33 deletions(-) create mode 100644 internal/helpers/drive_remote_unsafe_name_test.go diff --git a/internal/helpers/drive_push.go b/internal/helpers/drive_push.go index 5d790824..ff617007 100644 --- a/internal/helpers/drive_push.go +++ b/internal/helpers/drive_push.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "io/fs" - "log/slog" "path/filepath" "sort" "strings" @@ -257,13 +256,11 @@ func walkRemoteForPush(ctx context.Context, spaceID, parentID, relBase string, f } for _, it := range items { - name := it.name() - if name == "" { - continue + name, included, err := remoteMirrorEntryName(it, relBase) + if err != nil { + return err } - // 与 walkRemoteDir 一致:非规范名称跳过,避免逃逸性 rel_path 进入索引。 - if !isSafeRemoteSegment(name) { - slog.Warn("overlay: 跳过含非法路径成分的远端条目", "name", name, "relBase", relBase) + if !included { continue } childRel := name @@ -284,9 +281,7 @@ func walkRemoteForPush(ctx context.Context, spaceID, parentID, relBase string, f } continue } - if !it.isFile() { - continue - } + // 非文件类型已由 remoteMirrorEntryName 过滤;目录在上方完成递归后返回。 if err := claimRemotePath(occupied, childRel, "文件"); err != nil { return err } diff --git a/internal/helpers/drive_push_coverage_test.go b/internal/helpers/drive_push_coverage_test.go index c279ae3f..494ef24c 100644 --- a/internal/helpers/drive_push_coverage_test.go +++ b/internal/helpers/drive_push_coverage_test.go @@ -283,7 +283,7 @@ func TestCrossPlatformCoverageDrivePush_missingLocalRootFails(t *testing.T) { } // ────────────────────────────────────────────────────────── -// walkRemoteForPush — 分页、递归、深度上限、非法/空名跳过 +// walkRemoteForPush — 分页、递归、深度上限、非镜像类型跳过 // ────────────────────────────────────────────────────────── func TestCrossPlatformCoverageWalkRemoteForPush_paginationRecursionAndSkips(t *testing.T) { @@ -299,8 +299,7 @@ func TestCrossPlatformCoverageWalkRemoteForPush_paginationRecursionAndSkips(t *t } if parent == "ROOT" && token == "P2" { return `{"result":{"items":[` + - `{"name":"","type":"file","fileId":"EMPTY"},` + - `{"name":"../bad.txt","type":"file","fileId":"BAD"},` + + `{"name":"online.adoc","type":"document","fileId":"DOC"},` + `{"name":"ok.txt","type":"file","fileId":"OK"}` + `],"nextToken":""}}`, nil } @@ -325,10 +324,8 @@ func TestCrossPlatformCoverageWalkRemoteForPush_paginationRecursionAndSkips(t *t if _, ok := files["ok.txt"]; !ok { t.Error("second page file must be indexed") } - for _, bad := range []string{"", "../bad.txt"} { - if _, ok := files[bad]; ok { - t.Errorf("unsafe/empty name %q must be skipped", bad) - } + if _, ok := files["online.adoc"]; ok { + t.Error("non-file document must remain outside the folder mirror index") } } diff --git a/internal/helpers/drive_remote_unsafe_name_test.go b/internal/helpers/drive_remote_unsafe_name_test.go new file mode 100644 index 00000000..393f2fb7 --- /dev/null +++ b/internal/helpers/drive_remote_unsafe_name_test.go @@ -0,0 +1,125 @@ +package helpers + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestCrossPlatformCoverageDriveRemoteTree_unsafeNamesFailClosed(t *testing.T) { + tests := []struct { + name string + listing string + wantKind string + }{ + { + name: "file name containing a backslash", + listing: `{"result":{"items":[{"name":"a\\b.txt","type":"file","fileId":"UNSAFE_FILE"}],"nextToken":""}}`, + wantKind: "文件", + }, + { + name: "folder name escaping its parent", + listing: `{"result":{"items":[{"name":"..","type":"folder","fileId":"UNSAFE_FOLDER"}],"nextToken":""}}`, + wantKind: "目录", + }, + } + + commands := []struct { + name string + args func(localRoot string) []string + prepare func(t *testing.T, localRoot string) + }{ + { + name: "status", + args: func(localRoot string) []string { + return []string{"status", "--local-folder", localRoot, "--remote-folder", "ROOT", "--quick"} + }, + prepare: func(t *testing.T, localRoot string) { + t.Helper() + if err := os.MkdirAll(localRoot, 0o755); err != nil { + t.Fatal(err) + } + }, + }, + { + name: "pull", + args: func(localRoot string) []string { + return []string{"pull", "--local-folder", localRoot, "--remote-folder", "ROOT"} + }, + }, + { + name: "push", + args: func(localRoot string) []string { + return []string{"push", "--local-folder", localRoot, "--remote-folder", "ROOT"} + }, + prepare: prepareUnsafeNameWriteSource, + }, + { + name: "sync", + args: func(localRoot string) []string { + return []string{"sync", "--local-folder", localRoot, "--remote-folder", "ROOT", "--quick"} + }, + prepare: prepareUnsafeNameWriteSource, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, command := range commands { + t.Run(command.name, func(t *testing.T) { + localRoot := filepath.Join(t.TempDir(), "mirror") + if command.prepare != nil { + command.prepare(t, localRoot) + } + + caller := &driveScriptCaller{reply: func(tool string, args map[string]any, nth int) (string, error) { + if tool != "list_files" || nth != 0 || args["parentId"] != "ROOT" { + t.Fatalf("unsafe remote entry must stop before recursion: tool=%s nth=%d args=%v", tool, nth, args) + } + return tt.listing, nil + }} + getCalls, putCalls := 0, 0 + testseam.Swap(t, &httpGetFile, func(context.Context, string, map[string]string, string) error { + getCalls++ + return nil + }) + testseam.Swap(t, &httpPutFile, func(context.Context, string, map[string]string, string, int64) error { + putCalls++ + return nil + }) + + err := runDriveCmd(t, caller, command.args(localRoot)...) + if err == nil || !strings.Contains(err.Error(), "无法安全映射到本地路径") || + !strings.Contains(err.Error(), tt.wantKind) { + t.Fatalf("error = %v, want unsafe remote %s name failure", err, tt.wantKind) + } + if got := len(caller.callsFor("list_files")); got != 1 { + t.Fatalf("list_files calls = %d, want only the root query", got) + } + for _, tool := range []string{"create_folder", "download_file", "get_upload_info", "commit_upload"} { + if calls := caller.callsFor(tool); len(calls) != 0 { + t.Fatalf("unsafe remote name must not call %s: %v", tool, calls) + } + } + if getCalls != 0 || putCalls != 0 { + t.Fatalf("unsafe remote name must not transfer content: GET=%d PUT=%d", getCalls, putCalls) + } + if command.name == "pull" { + if _, statErr := os.Stat(localRoot); !os.IsNotExist(statErr) { + t.Fatalf("pull must fail before creating its local root: %v", statErr) + } + } + }) + } + }) + } +} + +func prepareUnsafeNameWriteSource(t *testing.T, localRoot string) { + t.Helper() + mustWrite(t, filepath.Join(localRoot, "local.txt"), "local") +} diff --git a/internal/helpers/drive_status.go b/internal/helpers/drive_status.go index c1075e73..09e9319b 100644 --- a/internal/helpers/drive_status.go +++ b/internal/helpers/drive_status.go @@ -8,7 +8,6 @@ import ( "fmt" "io" "io/fs" - "log/slog" "os" "path/filepath" "sort" @@ -133,14 +132,11 @@ func walkRemoteDir(ctx context.Context, spaceID, parentID, relBase string, out m } for _, it := range items { - name := it.name() - if name == "" { - continue + name, included, err := remoteMirrorEntryName(it, relBase) + if err != nil { + return err } - // 远端名称可能含 ..、分隔符或卷标,拼进 rel_path 会逃逸本地根目录: - // 非规范名称直接跳过(同名文件夹也不递归),不纳入索引。 - if !isSafeRemoteSegment(name) { - slog.Warn("overlay: 跳过含非法路径成分的远端条目", "name", name, "relBase", relBase) + if !included { continue } // rel_path 由文件夹名逐层拼接,统一用 / 分隔,与本地相对路径对齐。 @@ -163,10 +159,7 @@ func walkRemoteDir(ctx context.Context, spaceID, parentID, relBase string, out m } continue } - // 只纳入明确 type=FILE 的条目;在线文档、快捷方式等其它类型一律跳过。 - if !it.isFile() { - continue - } + // 非文件类型已由 remoteMirrorEntryName 过滤;目录在上方完成递归后返回。 if err := claimRemotePath(occupied, childRel, "文件"); err != nil { return err } @@ -201,6 +194,27 @@ func claimRemotePath(occupied map[string]string, rel, kind string) error { return nil } +// remoteMirrorEntryName 只选择文件夹镜像支持的 file / folder 条目,并验证其名称 +// 能安全、无歧义地表示为本地单层路径。无法表示的镜像条目必须中止整棵远端树构建; +// 静默跳过会让 status/pull/push/sync 在遗漏文件或目录子树时误报成功。 +func remoteMirrorEntryName(it driveItem, relBase string) (string, bool, error) { + kind := "" + switch { + case it.isFolder(): + kind = "目录" + case it.isFile(): + kind = "文件" + default: + return "", false, nil + } + + name := it.name() + if !isSafeRemoteSegment(name) { + return "", false, fmt.Errorf("远端%s名称 %q 无法安全映射到本地路径(父路径 %q),已中止", kind, name, relBase) + } + return name, true, nil +} + // callDriveListFiles 让文件夹同步族在 --dry-run 下仍可通过受控只读通道获取 // 远端现状;普通 CallTool 在 dry-run 下只会返回执行计划,不能被当作业务数据解析。 func callDriveListFiles(ctx context.Context, args map[string]any) (string, error) { diff --git a/internal/helpers/drive_status_coverage_test.go b/internal/helpers/drive_status_coverage_test.go index 79222ce0..fdfed660 100644 --- a/internal/helpers/drive_status_coverage_test.go +++ b/internal/helpers/drive_status_coverage_test.go @@ -120,9 +120,7 @@ func TestCrossPlatformCoverageDriveStatus_endToEndNestedTree(t *testing.T) { return `{"result":{"items":[` + `{"name":"root.txt","type":"file","fileId":"R1","modifyTime":1000},` + `{"name":"sub","type":"folder","fileId":"SUB"},` + - `{"name":"online.adoc","type":"document","fileId":"D1"},` + - `{"name":"","type":"file","fileId":"EMPTY"},` + - `{"name":"../escape.txt","type":"file","fileId":"BAD"}` + + `{"name":"online.adoc","type":"document","fileId":"D1"}` + `],"nextToken":""}}`, nil case "SUB": return `{"result":{"items":[{"name":"nested.txt","type":"file","fileId":"N1","modifyTime":2000}],"nextToken":""}}`, nil @@ -134,7 +132,7 @@ func TestCrossPlatformCoverageDriveStatus_endToEndNestedTree(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - // 递归进入 sub 一次;空名/非法名/在线文档都不产生额外调用。 + // 递归进入 sub 一次;不属于文件夹镜像范围的在线文档不产生额外调用。 if got := len(caller.callsFor("list_files")); got != 2 { t.Errorf("expected 2 list_files calls (ROOT + SUB), got %d", got) } From a203572ee3ed991c016c95109af232e62be39b57 Mon Sep 17 00:00:00 2001 From: Dennis Date: Wed, 12 Aug 2026 14:46:20 +0800 Subject: [PATCH 40/86] feat(chat): unify shortcut auto-pagination controls --- internal/shortcut/chat/chat_conversation.go | 68 ++++++++++++----- .../chat/chat_conversation_projection_test.go | 22 ++++++ internal/shortcut/chat/chat_group.go | 47 +++++++++--- internal/shortcut/chat/chat_message.go | 1 + .../chat/flag_list_pagination_test.go | 28 ++++++- internal/shortcut/chat/lark_alignment.go | 69 ++++++++++++----- .../chat/remaining_read_pagination_test.go | 24 +++++- internal/shortcut/chatmsg/chatmsg.go | 13 ++++ internal/shortcut/pagination.go | 75 +++++++++++++++++++ internal/shortcut/smart/at_me.go | 45 +++++++++-- internal/shortcut/smart/chat_messages.go | 44 ++++++++--- internal/shortcut/smart/chat_messages_test.go | 24 +++--- internal/shortcut/smart/my_groups.go | 58 ++++++++++++-- .../smart/remaining_read_pagination_test.go | 48 ++++++++++++ internal/shortcut/smart/thread_replies.go | 1 + .../dingtalk-chat/references/contracts.md | 4 +- 16 files changed, 482 insertions(+), 89 deletions(-) create mode 100644 internal/shortcut/pagination.go diff --git a/internal/shortcut/chat/chat_conversation.go b/internal/shortcut/chat/chat_conversation.go index a45094b7..3c77f8f9 100644 --- a/internal/shortcut/chat/chat_conversation.go +++ b/internal/shortcut/chat/chat_conversation.go @@ -313,17 +313,17 @@ var ConversationList = shortcut.Shortcut{ Examples: []string{"dws chat +conversation-list --limit 50"}, }, }, - Flags: []shortcut.Flag{ + Flags: append([]shortcut.Flag{ {Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页数量;--limit 必须在 1-100"}, {Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标(首次不传或 0)"}, {Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已免打扰会话"}, - {Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部分页;--page-limit 仅与 --page-all 一起使用且范围 1-500"}, + {Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部分页;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0"}, {Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"}, - }, - Constraints: []shortcut.Constraint{ + }, shortcut.AutoPageControlFlags()...), + Constraints: append([]shortcut.Constraint{ {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100"}, {Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"}, - }, + }, shortcut.AutoPageControlConstraints()...), Tips: []string{ `dws chat +conversation-list --limit 50`, `dws chat +conversation-list --page-all --limit 100`, @@ -338,6 +338,9 @@ var ConversationList = shortcut.Shortcut{ if pageLimit := rt.Int("page-limit"); pageLimit < 1 || pageLimit > 500 { return apperrors.NewValidation("--page-limit 必须在 1-500 之间") } + if err := shortcut.ValidateAutoPageControls(rt); err != nil { + return apperrors.NewValidation(err.Error()) + } return nil }, Execute: func(rt *shortcut.RuntimeContext) error { @@ -353,8 +356,18 @@ var ConversationList = shortcut.Shortcut{ complete := false hasMore := false nextCursor := int64(0) + stopReason := "source_complete" + truncatedByPageLimit := false + truncatedByResultLimit := false failures := make([]map[string]any, 0) for pagesFetched < pageLimit { + if pagesFetched > 0 { + if err := shortcut.WaitAutoPageDelay(rt); err != nil { + failures = append(failures, map[string]any{"stage": "conversation-page-delay", "cursor": cursor, "error": err.Error()}) + stopReason = "delay_interrupted" + break + } + } params := map[string]any{"limit": rt.Int("limit")} if cursor > 0 { params["cursor"] = cursor @@ -379,6 +392,10 @@ var ConversationList = shortcut.Shortcut{ } seenConversations[id] = true } + if maxItems := rt.Int("max-items"); maxItems > 0 && len(convs) >= maxItems { + truncatedByResultLimit = true + continue + } convs = append(convs, conversation) } page := chatmsg.Pagination(data) @@ -389,7 +406,11 @@ var ConversationList = shortcut.Shortcut{ break } if !hasMore { - complete = true + complete = !truncatedByResultLimit + if truncatedByResultLimit { + hasMore = true + stopReason = "result_limit" + } break } nextCursor, err = conversationPaginationCursor(page["nextCursor"]) @@ -398,26 +419,37 @@ var ConversationList = shortcut.Shortcut{ break } if !rt.Bool("page-all") { + stopReason = "single_page" + break + } + if maxItems := rt.Int("max-items"); maxItems > 0 && len(convs) >= maxItems { + truncatedByResultLimit = true + stopReason = "result_limit" break } seenCursors[nextCursor] = true cursor = nextCursor } - if rt.Bool("page-all") && hasMore && pagesFetched == pageLimit { - failures = append(failures, map[string]any{"stage": "conversation-page-limit", "error": fmt.Sprintf("达到 --page-limit=%d,仍有更多会话", pageLimit)}) + if rt.Bool("page-all") && hasMore && pagesFetched == pageLimit && !truncatedByResultLimit { + truncatedByPageLimit = true + stopReason = "page_limit" } payload := map[string]any{ - "count": len(convs), - "conversations": convs, - "pagesFetched": pagesFetched, - "complete": complete, - "hasMore": hasMore, - "nextCursor": nextCursor, - "paginationKnown": len(failures) == 0 || hasMore, - "failedCount": len(failures), - "failures": failures, - "partial": len(failures) > 0, + "count": len(convs), + "conversations": convs, + "pagesFetched": pagesFetched, + "complete": complete, + "hasMore": hasMore, + "nextCursor": nextCursor, + "paginationKnown": len(failures) == 0 || hasMore, + "stopReason": stopReason, + "truncatedByPageLimit": truncatedByPageLimit, + "truncatedByResultLimit": truncatedByResultLimit, + "failedCount": len(failures), + "failures": failures, + "partial": len(failures) > 0, } + chatmsg.ApplyTruncation(payload) return rt.Output(payload) }, } diff --git a/internal/shortcut/chat/chat_conversation_projection_test.go b/internal/shortcut/chat/chat_conversation_projection_test.go index dee998aa..cec59a68 100644 --- a/internal/shortcut/chat/chat_conversation_projection_test.go +++ b/internal/shortcut/chat/chat_conversation_projection_test.go @@ -148,6 +148,28 @@ func TestCrossPlatformCoverageConversationListSinglePagePreservesTypedCursor(t * } } +func TestCrossPlatformCoverageConversationListMaxItemsPublishesStableTruncation(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1"},{"openConversationId":"cid-2"}],"hasMore":true,"nextCursor":2}}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{"chat", "+conversation-list", "--page-all", "--max-items", "1", "--page-delay", "0"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["count"] != float64(1) || payload["truncated"] != true || + payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" { + t.Fatalf("payload = %#v", payload) + } +} + func TestCrossPlatformCoverageConversationListDeduplicatesStableIDs(t *testing.T) { fake := &larkAlignmentCaller{responses: map[string]string{ "im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1","title":"一"},{"openConversationId":"cid-1","title":"重复"}],"hasMore":false}}`, diff --git a/internal/shortcut/chat/chat_group.go b/internal/shortcut/chat/chat_group.go index 215b3f87..6bba8430 100644 --- a/internal/shortcut/chat/chat_group.go +++ b/internal/shortcut/chat/chat_group.go @@ -878,16 +878,16 @@ var ChatListAll = shortcut.Shortcut{ Examples: []string{"dws chat +chat-list-all --limit 50"}, }, }, - Flags: []shortcut.Flag{ + Flags: append([]shortcut.Flag{ {Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页返回数量;--limit 必须在 1-200 之间"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传 nextCursor"}, - {Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 nextCursor 自动读取全部已加入群;--page-limit 仅与 --page-all 一起使用且范围 1-500"}, + {Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 nextCursor 自动读取全部已加入群;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0"}, {Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"}, - }, - Constraints: []shortcut.Constraint{ + }, shortcut.AutoPageControlFlags()...), + Constraints: append([]shortcut.Constraint{ {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-200 之间"}, {Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"}, - }, + }, shortcut.AutoPageControlConstraints()...), Tips: []string{ `dws chat +chat-list-all --limit 50`, `dws chat +chat-list-all --limit 200 --page-all --page-limit 50`, @@ -908,6 +908,9 @@ func validateChatListAll(rt *shortcut.RuntimeContext) error { return apperrors.NewValidation("--page-limit 必须在 1-500 之间") } } + if err := shortcut.ValidateAutoPageControls(rt); err != nil { + return apperrors.NewValidation(err.Error()) + } return nil } @@ -958,9 +961,19 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any) hasMore := false stopReason := "source_complete" truncatedByPageLimit := false + truncatedByResultLimit := false var nextCursor any for pagesFetched < pageLimit { + if pagesFetched > 0 { + if err := shortcut.WaitAutoPageDelay(rt); err != nil { + failures = append(failures, map[string]any{ + "page": pagesFetched + 1, "stage": "delay", "cursor": cursorKey, "error": err.Error(), + }) + stopReason = "delay_interrupted" + break + } + } params := map[string]any{"limit": baseParams["limit"]} if cursorKey != "0" { params["cursor"] = cursorValue @@ -989,6 +1002,10 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any) if id != "" { seenGroups[id] = true } + if maxItems := rt.Int("max-items"); maxItems > 0 && len(allGroups) >= maxItems { + truncatedByResultLimit = true + continue + } allGroups = append(allGroups, group) } @@ -1004,9 +1021,14 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any) } hasMore = pageHasMore if !hasMore { - complete = true + complete = !truncatedByResultLimit nextCursor = nil - stopReason = "source_complete" + if truncatedByResultLimit { + hasMore = true + stopReason = "result_limit" + } else { + stopReason = "source_complete" + } break } nextCursor = page["nextCursor"] @@ -1022,8 +1044,13 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any) seenCursors[nextKey] = true cursorKey = nextKey cursorValue = nextCursor + if maxItems := rt.Int("max-items"); maxItems > 0 && len(allGroups) >= maxItems { + truncatedByResultLimit = true + stopReason = "result_limit" + break + } } - if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit { + if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit && !truncatedByResultLimit { truncatedByPageLimit = true stopReason = "page_limit" } @@ -1033,9 +1060,11 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any) "pagesFetched": pagesFetched, "paginationKnown": true, "complete": complete && len(failures) == 0, "hasMore": hasMore, "stopReason": stopReason, "truncatedByPageLimit": truncatedByPageLimit, - "failedCount": len(failures), "failures": failures, + "truncatedByResultLimit": truncatedByResultLimit, + "failedCount": len(failures), "failures": failures, "partial": len(failures) > 0 && len(allGroups) > 0, } + chatmsg.ApplyTruncation(payload) if hasMore && nextCursor != nil { payload["nextCursor"] = nextCursor } diff --git a/internal/shortcut/chat/chat_message.go b/internal/shortcut/chat/chat_message.go index b74761cd..536686e6 100644 --- a/internal/shortcut/chat/chat_message.go +++ b/internal/shortcut/chat/chat_message.go @@ -634,6 +634,7 @@ func readAllDirectMessages(rt *shortcut.RuntimeContext, params map[string]any) ( payload["hasMore"] = hasMore payload["stopReason"] = stopReason payload["truncatedByPageLimit"] = truncatedByPageLimit + chatmsg.ApplyTruncation(payload) payload["failedCount"] = len(failures) payload["failures"] = failures payload["partial"] = len(failures) > 0 && len(messages) > 0 diff --git a/internal/shortcut/chat/flag_list_pagination_test.go b/internal/shortcut/chat/flag_list_pagination_test.go index b9b99611..9f58795b 100644 --- a/internal/shortcut/chat/flag_list_pagination_test.go +++ b/internal/shortcut/chat/flag_list_pagination_test.go @@ -73,7 +73,29 @@ func TestCrossPlatformCoverageFlagListPageTokenAndPageLimit(t *testing.T) { if err := json.Unmarshal(output.Bytes(), &payload); err != nil { t.Fatal(err) } - if payload["complete"] != false || payload["nextCursor"] != float64(9) || payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" { + if payload["complete"] != false || payload["nextCursor"] != float64(9) || payload["truncated"] != true || payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" { + t.Fatalf("payload = %#v", payload) + } +} + +func TestCrossPlatformCoverageFlagListMaxItemsPublishesStableTruncation(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "im/list_message_favorites": `{"result":{"items":[{"openMessageId":"msg-1"},{"openMessageId":"msg-2"}],"hasMore":true,"nextCursor":9}}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{"chat", "+flag-list", "--page-all", "--max-items", "1", "--page-delay", "0"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["count"] != float64(1) || payload["truncated"] != true || + payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" { t.Fatalf("payload = %#v", payload) } } @@ -137,7 +159,11 @@ func TestCrossPlatformCoverageFlagListPaginationValidation(t *testing.T) { {"--size", "31"}, {"--page-token", "not-a-number"}, {"--page-limit", "2"}, + {"--max-items", "1"}, + {"--page-delay", "1"}, {"--page-all", "--page-limit", "0"}, + {"--page-all", "--max-items", "-1"}, + {"--page-all", "--page-delay", "-1"}, {"--page-all", "--page-limit", "501"}, {"--cursor", "1", "--page-token", "2"}, } { diff --git a/internal/shortcut/chat/lark_alignment.go b/internal/shortcut/chat/lark_alignment.go index d741f039..04d7706b 100644 --- a/internal/shortcut/chat/lark_alignment.go +++ b/internal/shortcut/chat/lark_alignment.go @@ -510,21 +510,21 @@ var FlagList = shortcut.Shortcut{ Description: "分页查询当前用户收藏的消息,支持有界自动翻页", Intent: "当你要查看当前用户的 DingTalk message favorite 列表时使用;默认读取一页,明确要求全部收藏时加 --page-all,并用 --page-limit 保持有界。底层实际使用数字 cursor,结果按 openMessageId 去重并公开 complete、hasMore、nextCursor、stopReason 和 failures;它不把 message favorite 与 Pin、会话置顶或 Lark feed-layer thread flag 混为一谈。", Risk: shortcut.RiskRead, - Flags: []shortcut.Flag{ + Flags: append([]shortcut.Flag{ {Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量;下游真实上限为 30,显式页大小必须在 1-30 之间"}, {Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "--page-size 的兼容别名;下游真实上限为 30,显式页大小必须在 1-30 之间"}, {Name: "page-token", Type: shortcut.FlagString, Desc: "Lark 对齐的起始分页参数;起始 cursor 必须是非负整数"}, {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "钉钉数字分页游标;起始 cursor 必须是非负整数"}, - {Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部收藏分页;--page-limit 仅与 --page-all 一起使用且范围 1-500"}, + {Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部收藏分页;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0"}, {Name: "page-limit", Type: shortcut.FlagInt, Default: "20", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"}, - }, - Constraints: []shortcut.Constraint{ + }, shortcut.AutoPageControlFlags()...), + Constraints: append([]shortcut.Constraint{ {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"page-size", "size"}}, {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"page-token", "cursor"}}, {Kind: shortcut.ConstraintCustom, Flags: []string{"page-size", "size"}, Description: "显式页大小必须在 1-30 之间"}, {Kind: shortcut.ConstraintCustom, Flags: []string{"page-token", "cursor"}, Description: "起始 cursor 必须是非负整数"}, {Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"}, - }, + }, shortcut.AutoPageControlConstraints()...), Tips: []string{ `dws chat +flag-list --cursor 0 --page-size 20`, `dws chat +flag-list --page-size 30 --page-all --page-limit 20`, @@ -548,6 +548,9 @@ func validateFlagList(rt *shortcut.RuntimeContext) error { return apperrors.NewValidation("--page-limit 必须在 1-500 之间") } } + if err := shortcut.ValidateAutoPageControls(rt); err != nil { + return apperrors.NewValidation(err.Error()) + } return nil } @@ -598,8 +601,18 @@ func executeFlagList(rt *shortcut.RuntimeContext) error { var cursorErr error stopReason := "source_complete" truncatedByPageLimit := false + truncatedByResultLimit := false for pagesFetched < pageLimit { + if pagesFetched > 0 { + if err := shortcut.WaitAutoPageDelay(rt); err != nil { + failures = append(failures, map[string]any{ + "page": pagesFetched + 1, "stage": "delay", "cursor": cursor, "error": err.Error(), + }) + stopReason = "delay_interrupted" + break + } + } data, callErr := rt.CallMCPData("im", "list_message_favorites", flagListRequestParams(cursor, pageSize)) if callErr != nil { if pagesFetched == 0 { @@ -621,6 +634,10 @@ func executeFlagList(rt *shortcut.RuntimeContext) error { if messageID != "" { seenMessages[messageID] = true } + if maxItems := rt.Int("max-items"); maxItems > 0 && len(items) >= maxItems { + truncatedByResultLimit = true + continue + } items = append(items, item) } @@ -650,9 +667,14 @@ func executeFlagList(rt *shortcut.RuntimeContext) error { } hasMore = pageHasMore if !hasMore { - complete = true + complete = !truncatedByResultLimit nextCursor = 0 - stopReason = "source_complete" + if truncatedByResultLimit { + hasMore = true + stopReason = "result_limit" + } else { + stopReason = "source_complete" + } break } if cursorErr != nil || nextCursor <= 0 || seenCursors[nextCursor] { @@ -669,28 +691,35 @@ func executeFlagList(rt *shortcut.RuntimeContext) error { } seenCursors[nextCursor] = true cursor = nextCursor + if maxItems := rt.Int("max-items"); maxItems > 0 && len(items) >= maxItems { + truncatedByResultLimit = true + stopReason = "result_limit" + break + } } - if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit { + if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit && !truncatedByResultLimit { truncatedByPageLimit = rt.Bool("page-all") if truncatedByPageLimit { stopReason = "page_limit" } } payload := map[string]any{ - "count": len(items), - "items": items, - "pagesFetched": pagesFetched, - "paginationKnown": paginationKnown, - "complete": complete && len(failures) == 0, - "hasMore": hasMore, - "nextCursor": nextCursor, - "stopReason": stopReason, - "truncatedByPageLimit": truncatedByPageLimit, - "failedCount": len(failures), - "failures": failures, - "partial": len(failures) > 0 && len(items) > 0, + "count": len(items), + "items": items, + "pagesFetched": pagesFetched, + "paginationKnown": paginationKnown, + "complete": complete && len(failures) == 0, + "hasMore": hasMore, + "nextCursor": nextCursor, + "stopReason": stopReason, + "truncatedByPageLimit": truncatedByPageLimit, + "truncatedByResultLimit": truncatedByResultLimit, + "failedCount": len(failures), + "failures": failures, + "partial": len(failures) > 0 && len(items) > 0, } + chatmsg.ApplyTruncation(payload) if outputErr := rt.Output(payload); outputErr != nil { return outputErr } diff --git a/internal/shortcut/chat/remaining_read_pagination_test.go b/internal/shortcut/chat/remaining_read_pagination_test.go index 821ba023..66b8d235 100644 --- a/internal/shortcut/chat/remaining_read_pagination_test.go +++ b/internal/shortcut/chat/remaining_read_pagination_test.go @@ -95,7 +95,29 @@ func TestCrossPlatformCoverageDirectMessagesPageLimitPublishesExecutableContinua t.Fatal(err) } if payload["complete"] != false || payload["hasMore"] != true || - payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" || payload["nextPage"] == nil { + payload["truncated"] != true || payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" || payload["nextPage"] == nil { + t.Fatalf("payload = %#v", payload) + } +} + +func TestCrossPlatformCoverageChatListAllMaxItemsPublishesStableTruncation(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "im/list_my_groups_pagination": `{"result":{"groups":[{"openConversationId":"g1"},{"openConversationId":"g2"}],"hasMore":true,"nextCursor":88}}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{"chat", "+chat-list-all", "--page-all", "--max-items", "1", "--page-delay", "0"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["count"] != float64(1) || payload["truncated"] != true || + payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" { t.Fatalf("payload = %#v", payload) } } diff --git a/internal/shortcut/chatmsg/chatmsg.go b/internal/shortcut/chatmsg/chatmsg.go index eab58e2f..a94ed2c5 100644 --- a/internal/shortcut/chatmsg/chatmsg.go +++ b/internal/shortcut/chatmsg/chatmsg.go @@ -86,6 +86,7 @@ var messageResultContractV1 = MessageResultContract{ "hasMore", "nextPage", "stopReason", + "truncated", "truncatedByPageLimit", "truncatedByResultLimit", "failedCount", @@ -122,9 +123,21 @@ func NewMessageListPayload(messages []map[string]any) map[string]any { "failedCount": 0, "failures": []map[string]any{}, "partial": false, + "truncated": false, } } +// ApplyTruncation publishes the stable aggregate bit while preserving the +// established reason-specific fields for compatibility and diagnosis. +func ApplyTruncation(payload map[string]any) { + if payload == nil { + return + } + byPage, _ := payload["truncatedByPageLimit"].(bool) + byItems, _ := payload["truncatedByResultLimit"].(bool) + payload["truncated"] = byPage || byItems +} + // ListMessageItems returns message rows from the common list response envelopes. func ListMessageItems(data map[string]any) []map[string]any { if data == nil { diff --git a/internal/shortcut/pagination.go b/internal/shortcut/pagination.go new file mode 100644 index 00000000..451d8ec1 --- /dev/null +++ b/internal/shortcut/pagination.go @@ -0,0 +1,75 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package shortcut + +import ( + "fmt" + "time" +) + +// AutoPageControlFlags returns the shared item and pacing controls used by +// cursor-based shortcuts. Callers retain ownership of --page-all and their +// product-specific --page-limit defaults. +func AutoPageControlFlags() []Flag { + const evidence = "--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0" + return []Flag{ + {Name: "max-items", Type: FlagInt, Desc: "自动翻页最多返回条数(默认 0 表示不限制)。" + evidence}, + {Name: "page-delay", Type: FlagInt, Desc: "自动翻页每页之间等待毫秒数(默认 0 表示不等待)。" + evidence}, + } +} + +// AutoPageControlConstraints publishes the runtime-only relationship between +// the pagination switch and its shared controls into Help and Schema. +func AutoPageControlConstraints() []Constraint { + return []Constraint{{ + Kind: ConstraintCustom, + Flags: []string{"page-all", "max-items", "page-delay"}, + Description: "--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0", + }} +} + +// ValidateAutoPageControls enforces the shared contract while preserving the +// historical behavior that defaulted controls do not activate pagination. +func ValidateAutoPageControls(rt *RuntimeContext) error { + if !rt.Bool("page-all") { + if rt.Changed("max-items") || rt.Changed("page-delay") { + return fmt.Errorf("--max-items/--page-delay 仅与 --page-all 一起使用") + } + return nil + } + if rt.Int("max-items") < 0 { + return fmt.Errorf("--max-items 必须大于等于 0") + } + if rt.Int("page-delay") < 0 { + return fmt.Errorf("--page-delay 必须大于等于 0") + } + return nil +} + +// WaitAutoPageDelay waits between successful pages and remains cancellable so +// a throttled pagination run cannot ignore command cancellation. +func WaitAutoPageDelay(rt *RuntimeContext) error { + delayMS := rt.Int("page-delay") + if delayMS <= 0 { + return nil + } + timer := time.NewTimer(time.Duration(delayMS) * time.Millisecond) + defer timer.Stop() + select { + case <-rt.Command().Context().Done(): + return rt.Command().Context().Err() + case <-timer.C: + return nil + } +} diff --git a/internal/shortcut/smart/at_me.go b/internal/shortcut/smart/at_me.go index 881c0206..155100f5 100644 --- a/internal/shortcut/smart/at_me.go +++ b/internal/shortcut/smart/at_me.go @@ -93,23 +93,23 @@ var AtMe = shortcut.Shortcut{ }, }, }, - Flags: append([]shortcut.Flag{ + Flags: append(append([]shortcut.Flag{ {Name: "group", Type: shortcut.FlagString, Desc: "仅查看指定群;可传 openConversationId 或群名"}, {Name: "chat-query", Type: shortcut.FlagString, Desc: "--group 的旧版自然名称入口", Hidden: true}, {Name: "group-query", Type: shortcut.FlagString, Desc: "--chat-query 的兼容别名", Hidden: true}, {Name: "days", Type: shortcut.FlagInt, Desc: "回溯天数(默认 7);--days 必须在 1-3650 之间", Default: "7", Required: false}, {Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(默认 50);--limit 必须大于 0", Default: "50"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传上次的 nextCursor", Default: "0"}, - {Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 nextCursor 自动读取全部 @我 消息;--page-limit 仅与 --page-all 一起使用且范围 1-500"}, + {Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 nextCursor 自动读取全部 @我 消息;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0"}, {Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"}, {Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"}, - }, chatshortcut.MessageResourceDownloadFlags()...), - Constraints: append([]shortcut.Constraint{ + }, shortcut.AutoPageControlFlags()...), chatshortcut.MessageResourceDownloadFlags()...), + Constraints: append(append([]shortcut.Constraint{ {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"group", "chat-query", "group-query"}}, {Kind: shortcut.ConstraintCustom, Flags: []string{"days"}, Description: "--days 必须在 1-3650 之间"}, {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0"}, {Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"}, - }, chatshortcut.MessageResourceDownloadConstraints()...), + }, shortcut.AutoPageControlConstraints()...), chatshortcut.MessageResourceDownloadConstraints()...), Tips: []string{ `dws chat +at-me`, `dws chat +at-me --days 3`, @@ -211,6 +211,9 @@ func validateAtMe(rt *shortcut.RuntimeContext) error { return apperrors.NewValidation("--page-limit 必须在 1-500 之间") } } + if err := shortcut.ValidateAutoPageControls(rt); err != nil { + return apperrors.NewValidation(err.Error()) + } return nil } @@ -240,8 +243,18 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m nextCursor := "" stopReason := "source_complete" truncatedByPageLimit := false + truncatedByResultLimit := false for pagesFetched < pageLimit { + if pagesFetched > 0 { + if err := shortcut.WaitAutoPageDelay(rt); err != nil { + failures = append(failures, map[string]any{ + "page": pagesFetched + 1, "stage": "delay", "cursor": cursor, "error": err.Error(), + }) + stopReason = "delay_interrupted" + break + } + } params := make(map[string]any, len(baseParams)) for key, value := range baseParams { params[key] = value @@ -268,6 +281,10 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m if id != "" { seenMessages[id] = true } + if maxItems := rt.Int("max-items"); maxItems > 0 && len(allItems) >= maxItems { + truncatedByResultLimit = true + continue + } allItems = append(allItems, item) } @@ -283,9 +300,14 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m } hasMore = pageHasMore if !hasMore { - complete = true + complete = !truncatedByResultLimit nextCursor = "" - stopReason = "source_complete" + if truncatedByResultLimit { + hasMore = true + stopReason = "result_limit" + } else { + stopReason = "source_complete" + } break } nextCursor = atMeCursorString(page["nextCursor"]) @@ -299,8 +321,13 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m } seenCursors[nextCursor] = true cursor = nextCursor + if maxItems := rt.Int("max-items"); maxItems > 0 && len(allItems) >= maxItems { + truncatedByResultLimit = true + stopReason = "result_limit" + break + } } - if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit { + if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit && !truncatedByResultLimit { truncatedByPageLimit = true stopReason = "page_limit" } @@ -312,9 +339,11 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m payload["hasMore"] = hasMore payload["stopReason"] = stopReason payload["truncatedByPageLimit"] = truncatedByPageLimit + payload["truncatedByResultLimit"] = truncatedByResultLimit payload["failedCount"] = len(failures) payload["failures"] = failures payload["partial"] = len(failures) > 0 && len(allItems) > 0 + chatmsg.ApplyTruncation(payload) if hasMore && nextCursor != "" { payload["nextCursor"] = nextCursor } diff --git a/internal/shortcut/smart/chat_messages.go b/internal/shortcut/smart/chat_messages.go index 7a1e9fe1..461f0f2e 100644 --- a/internal/shortcut/smart/chat_messages.go +++ b/internal/shortcut/smart/chat_messages.go @@ -60,7 +60,7 @@ var ChatMessages = shortcut.Shortcut{ Intent: "当你要读取或导出一个指定群聊或单聊的消息记录时使用;可附带发送者姓名解析,无稳定身份时保留全部消息,唯一解析出稳定身份后按 senderId 筛选同一次读取结果;" + "群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" + "省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --time/--direction,范围模式可用公开可选的 --start/--end/--order(兼容 --start-time/--end-time/--sort),范围语义为 [start,end)。" + - "全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" + + "全量读取用 --page-all,并由 --page-limit/--max-items 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" + "默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。", Risk: shortcut.RiskRead, Safety: contract.SafetySpec{ @@ -86,7 +86,7 @@ var ChatMessages = shortcut.Shortcut{ UseWhen: []string{"当你要读取或导出一个指定群聊或单聊的消息记录时使用;可附带发送者姓名解析,无稳定身份时保留全部消息,唯一解析出稳定身份后按 senderId 筛选同一次读取结果;" + "群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" + "省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --time/--direction,范围模式可用公开可选的 --start/--end/--order(兼容 --start-time/--end-time/--sort),范围语义为 [start,end)。" + - "全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" + + "全量读取用 --page-all,并由 --page-limit/--max-items 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" + "默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。"}, AvoidWhen: []string{"以发送者、关键词、@对象或消息类型为主的直接条件检索优先使用 +search-msg;已有一批精确消息 ID 时使用 +messages-mget。已选择会话读取时可在同一次调用附带发送者姓名,不需要再搜索消息"}, Examples: []string{ @@ -117,9 +117,11 @@ var ChatMessages = shortcut.Shortcut{ {Name: "page-size", Type: shortcut.FlagInt, Desc: "--limit 的兼容别名", Hidden: true}, {Name: "direction", Type: shortcut.FlagString, Enum: []string{"newer", "older"}, Desc: "时间方向 newer/older;省略时为 older,从时间边界向前读取"}, {Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"}, - {Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 typed nextPage.time 自动读取后续页;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-results 仅与 --page-all 一起使用且不能为负数"}, + {Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 typed nextPage.time 自动读取后续页;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-items 仅与 --page-all 一起使用且不能为负数;--page-delay 仅与 --page-all 一起使用且不能为负数"}, {Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"}, - {Name: "max-results", Type: shortcut.FlagInt, Desc: "--max-results 仅与 --page-all 一起使用且不能为负数;0 表示仅受页数上限约束"}, + {Name: "max-items", Type: shortcut.FlagInt, Desc: "自动翻页最多返回条数(默认 0 表示不限制);--max-items 仅与 --page-all 一起使用且不能为负数"}, + {Name: "max-results", Type: shortcut.FlagInt, Desc: "--max-items 的兼容别名", Hidden: true}, + {Name: "page-delay", Type: shortcut.FlagInt, Desc: "自动翻页每页之间等待毫秒数(默认 0 表示不等待);--page-delay 仅与 --page-all 一起使用且不能为负数"}, {Name: "output", Shorthand: "o", Type: shortcut.FlagString, Desc: "把完整结构化 ledger 原子写入工作目录内的相对 JSON 文件"}, }, chatshortcut.MessageResourceDownloadFlags()...), Constraints: append([]shortcut.Constraint{ @@ -138,7 +140,8 @@ var ChatMessages = shortcut.Shortcut{ {Kind: shortcut.ConstraintCustom, Flags: []string{"order", "sort"}, Description: "asc 必须指定 --start/--start-time"}, {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式页大小必须大于 0"}, {Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"}, - {Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "max-results"}, Description: "--max-results 仅与 --page-all 一起使用且不能为负数"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "max-items"}, Description: "--max-items 仅与 --page-all 一起使用且不能为负数"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-delay"}, Description: "--page-delay 仅与 --page-all 一起使用且不能为负数"}, { Kind: shortcut.ConstraintCustom, Flags: []string{"output", "overwrite"}, @@ -179,16 +182,25 @@ func validateChatMessages(rt *shortcut.RuntimeContext) error { return localChatOptionError("invalid_page_size", "+chat-messages 的 --"+name+" 必须大于 0", "--"+name) } } - if !rt.Bool("page-all") && (rt.Changed("page-limit") || rt.Changed("max-results")) { - return apperrors.NewValidation("--page-limit/--max-results 仅与 --page-all 一起使用") + if !rt.Bool("page-all") && rt.Changed("page-limit") { + return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用") + } + if !rt.Bool("page-all") && rt.Changed("max-results") { + return apperrors.NewValidation("--max-results 仅与 --page-all 一起使用") } if rt.Bool("page-all") { if pageLimit := rt.Int("page-limit"); pageLimit < 1 || pageLimit > chatMessagesHardPageLimit { return apperrors.NewValidation("--page-limit 必须在 1-500 之间") } - if rt.Int("max-results") < 0 { - return apperrors.NewValidation("--max-results 不能小于 0") - } + } + if err := shortcut.ValidateAutoPageControls(rt); err != nil { + return apperrors.NewValidation(err.Error()) + } + if rt.Int("max-results") < 0 { + return apperrors.NewValidation("--max-results 不能小于 0") + } + if rt.Changed("max-items") && rt.Changed("max-results") { + return apperrors.NewValidation("--max-items 与 --max-results 不能同时使用") } if rt.Changed("output") { if err := chatshortcut.ValidateMessageExportOutput(rt.Str("output")); err != nil { @@ -477,7 +489,7 @@ func collectOneChatMessagesPage(rt *shortcut.RuntimeContext, request chatMessage func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesRequest) (map[string]any, []map[string]any, error) { pageLimit := defaultChatPageLimit(rt.Int("page-limit"), chatMessagesDefaultPageLimit) - maxResults := rt.Int("max-results") + maxResults := rt.IntFirst("max-items", "max-results") basePageSize, _ := request.params["limit"].(int) if basePageSize <= 0 { basePageSize = chatMessagesAllPageSize @@ -496,6 +508,15 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq var nextPage map[string]any for pagesFetched < pageLimit { + if pagesFetched > 0 { + if delayErr := shortcut.WaitAutoPageDelay(rt); delayErr != nil { + failures = append(failures, map[string]any{ + "page": pagesFetched + 1, "stage": "delay", "error": delayErr.Error(), + }) + stopReason = "delay_interrupted" + break + } + } request.params["limit"] = basePageSize if maxResults > 0 { remaining := maxResults - len(allItems) @@ -656,6 +677,7 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq payload["stopReason"] = stopReason payload["truncatedByPageLimit"] = truncatedByPageLimit payload["truncatedByResultLimit"] = truncatedByResultLimit + chatmsg.ApplyTruncation(payload) payload["failedCount"] = len(failures) payload["failures"] = failures payload["partial"] = len(failures) > 0 && len(results) > 0 diff --git a/internal/shortcut/smart/chat_messages_test.go b/internal/shortcut/smart/chat_messages_test.go index f94438fb..9576755a 100644 --- a/internal/shortcut/smart/chat_messages_test.go +++ b/internal/shortcut/smart/chat_messages_test.go @@ -613,21 +613,23 @@ func TestCrossPlatformCoverageChatMessagesAdditionalCollectionEdges(t *testing.T }) t.Run("terminal result limit and unsafe continuation", func(t *testing.T) { - caller := &chatMessagesPagingCaller{responses: []string{ - `{"result":{"hasMore":true,"messages":[{"openMessageId":"m2","createTime":"2026-01-03 00:00:00"},{"openMessageId":"m1","createTime":"2026-01-02 00:00:00"},{"openMessageId":"old","createTime":"2026-01-01 00:00:00"}]}}`, - }} - payload, _, err := collectAllChatMessages( - runtimeWith(t, caller, map[string]string{"max-results": "1"}), - chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older", timeRange: configuredRange}, - ) - if err != nil || payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" { - t.Fatalf("payload=%#v err=%v", payload, err) + for _, flag := range []string{"max-items", "max-results"} { + caller := &chatMessagesPagingCaller{responses: []string{ + `{"result":{"hasMore":true,"messages":[{"openMessageId":"m2","createTime":"2026-01-03 00:00:00"},{"openMessageId":"m1","createTime":"2026-01-02 00:00:00"},{"openMessageId":"old","createTime":"2026-01-01 00:00:00"}]}}`, + }} + payload, _, err := collectAllChatMessages( + runtimeWith(t, caller, map[string]string{flag: "1"}), + chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older", timeRange: configuredRange}, + ) + if err != nil || payload["truncated"] != true || payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" { + t.Fatalf("%s payload=%#v err=%v", flag, payload, err) + } } - caller = &chatMessagesPagingCaller{responses: []string{ + caller := &chatMessagesPagingCaller{responses: []string{ `{"result":{"hasMore":true,"messages":[{"openMessageId":"m1","createTime":"2026-01-03 00:00:00"}]}}`, }} - payload, _, err = collectAllChatMessages( + payload, _, err := collectAllChatMessages( runtimeWith(t, caller, map[string]string{"max-results": "1"}), chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older"}, ) diff --git a/internal/shortcut/smart/my_groups.go b/internal/shortcut/smart/my_groups.go index 1c2d3ae9..072fdcbb 100644 --- a/internal/shortcut/smart/my_groups.go +++ b/internal/shortcut/smart/my_groups.go @@ -84,17 +84,17 @@ var MyGroups = shortcut.Shortcut{ }, }, }, - Flags: []shortcut.Flag{ + Flags: append([]shortcut.Flag{ {Name: "type", Type: shortcut.FlagString, Desc: "按群类型过滤(可选,如返回中的 groupType/conversationType,大小写不敏感)", Required: false}, {Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(默认 200);--limit 必须在 1-200 之间", Default: "200"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传上次的 nextCursor"}, - {Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 nextCursor 自动读取全部已加入群;--page-limit 仅与 --page-all 一起使用且范围 1-500"}, + {Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 nextCursor 自动读取全部已加入群;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0"}, {Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"}, - }, - Constraints: []shortcut.Constraint{ + }, shortcut.AutoPageControlFlags()...), + Constraints: append([]shortcut.Constraint{ {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-200 之间"}, {Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"}, - }, + }, shortcut.AutoPageControlConstraints()...), Tips: []string{ `dws chat +my-groups`, `dws chat +my-groups --type group`, @@ -116,6 +116,9 @@ func validateMyGroups(rt *shortcut.RuntimeContext) error { return apperrors.NewValidation("--page-limit 必须在 1-500 之间") } } + if err := shortcut.ValidateAutoPageControls(rt); err != nil { + return apperrors.NewValidation(err.Error()) + } return nil } @@ -180,9 +183,20 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma hasMore := false stopReason := "source_complete" truncatedByPageLimit := false + truncatedByResultLimit := false + eligibleCount := 0 var nextCursor any for pagesFetched < pageLimit { + if pagesFetched > 0 { + if err := shortcut.WaitAutoPageDelay(rt); err != nil { + failures = append(failures, map[string]any{ + "page": pagesFetched + 1, "stage": "delay", "cursor": cursorKey, "error": err.Error(), + }) + stopReason = "delay_interrupted" + break + } + } params := map[string]any{"limit": baseParams["limit"]} if cursorKey != "0" { params["cursor"] = cursorValue @@ -208,7 +222,14 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma if id != "" { seenGroups[id] = true } + if maxItems := rt.Int("max-items"); maxItems > 0 && myGroupsMatchesFilter(rt, group) && eligibleCount >= maxItems { + truncatedByResultLimit = true + continue + } allGroups = append(allGroups, group) + if myGroupsMatchesFilter(rt, group) { + eligibleCount++ + } } page := chatmsg.Pagination(data) @@ -223,9 +244,14 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma } hasMore = pageHasMore if !hasMore { - complete = true + complete = !truncatedByResultLimit nextCursor = nil - stopReason = "source_complete" + if truncatedByResultLimit { + hasMore = true + stopReason = "result_limit" + } else { + stopReason = "source_complete" + } break } nextCursor = page["nextCursor"] @@ -241,8 +267,13 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma seenCursors[nextKey] = true cursorKey = nextKey cursorValue = nextCursor + if maxItems := rt.Int("max-items"); maxItems > 0 && eligibleCount >= maxItems { + truncatedByResultLimit = true + stopReason = "result_limit" + break + } } - if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit { + if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit && !truncatedByResultLimit { truncatedByPageLimit = true stopReason = "page_limit" } @@ -254,9 +285,11 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma payload["hasMore"] = hasMore payload["stopReason"] = stopReason payload["truncatedByPageLimit"] = truncatedByPageLimit + payload["truncatedByResultLimit"] = truncatedByResultLimit payload["failedCount"] = len(failures) payload["failures"] = failures payload["partial"] = len(failures) > 0 && len(allGroups) > 0 + chatmsg.ApplyTruncation(payload) if hasMore && nextCursor != nil { payload["nextCursor"] = nextCursor } @@ -275,6 +308,15 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma ) } +func myGroupsMatchesFilter(rt *shortcut.RuntimeContext, group map[string]any) bool { + typeFilter := strings.TrimSpace(rt.Str("type")) + if typeFilter == "" { + return true + } + groupType, _ := myGroupsProject(group)["type"].(string) + return strings.EqualFold(strings.TrimSpace(groupType), typeFilter) +} + func myGroupsCursorString(value any) string { if value == nil { return "" diff --git a/internal/shortcut/smart/remaining_read_pagination_test.go b/internal/shortcut/smart/remaining_read_pagination_test.go index 2400d989..65f29f6f 100644 --- a/internal/shortcut/smart/remaining_read_pagination_test.go +++ b/internal/shortcut/smart/remaining_read_pagination_test.go @@ -83,6 +83,28 @@ func TestCrossPlatformCoverageAtMePageAllContinuesAcrossEmptyIntermediatePage(t } } +func TestCrossPlatformCoverageAtMeMaxItemsPublishesStableTruncation(t *testing.T) { + caller := &chatMessagesPagingCaller{responses: []string{ + `{"result":{"conversationMessagesList":[{"messages":[{"openMessageId":"m1"},{"openMessageId":"m2"}]}],"hasMore":true,"nextCursor":"cursor-2"}}`, + }} + helpers.InitDeps(caller) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{"chat", "+at-me", "--page-all", "--max-items", "1", "--page-delay", "0"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["count"] != float64(1) || payload["truncated"] != true || + payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" { + t.Fatalf("payload = %#v", payload) + } +} + func TestCrossPlatformCoverageMyGroupsPageAllUsesNumericCursorAndFiltersAfterMerge(t *testing.T) { caller := &chatMessagesPagingCaller{responses: []string{ `{"result":{"groups":[{"openConversationId":"g1","title":"群一","groupType":"group"}],"hasMore":true,"nextCursor":88}}`, @@ -109,12 +131,38 @@ func TestCrossPlatformCoverageMyGroupsPageAllUsesNumericCursorAndFiltersAfterMer } } +func TestCrossPlatformCoverageMyGroupsMaxItemsAppliesAfterTypeFilter(t *testing.T) { + caller := &chatMessagesPagingCaller{responses: []string{ + `{"result":{"groups":[{"openConversationId":"p1","groupType":"p2p"},{"openConversationId":"g1","groupType":"group"},{"openConversationId":"g2","groupType":"group"}],"hasMore":false}}`, + }} + helpers.InitDeps(caller) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{"chat", "+my-groups", "--type", "group", "--page-all", "--max-items", "1", "--page-delay", "0"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["count"] != float64(1) || payload["truncated"] != true || + payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" { + t.Fatalf("payload = %#v", payload) + } +} + func TestCrossPlatformCoverageRemainingReadPaginationValidation(t *testing.T) { for _, args := range [][]string{ {"chat", "+at-me", "--page-limit", "2"}, + {"chat", "+at-me", "--max-items", "1"}, + {"chat", "+at-me", "--page-delay", "1"}, {"chat", "+at-me", "--page-all", "--page-limit", "501"}, + {"chat", "+at-me", "--page-all", "--max-items", "-1"}, {"chat", "+my-groups", "--limit", "201"}, {"chat", "+my-groups", "--page-limit", "2"}, + {"chat", "+my-groups", "--max-items", "1"}, {"chat", "+my-groups", "--page-all", "--page-limit", "501"}, } { helpers.InitDeps(&chatMessagesPagingCaller{}) diff --git a/internal/shortcut/smart/thread_replies.go b/internal/shortcut/smart/thread_replies.go index 53697622..e98aae20 100644 --- a/internal/shortcut/smart/thread_replies.go +++ b/internal/shortcut/smart/thread_replies.go @@ -463,6 +463,7 @@ func collectAllThreadReplies(rt *shortcut.RuntimeContext, params map[string]any) payload["hasMore"] = hasMore payload["stopReason"] = stopReason payload["truncatedByPageLimit"] = truncatedByPageLimit + chatmsg.ApplyTruncation(payload) payload["failedCount"] = len(failures) payload["failures"] = failures payload["partial"] = len(failures) > 0 && len(allItems) > 0 diff --git a/skills/multi/dingtalk-chat/references/contracts.md b/skills/multi/dingtalk-chat/references/contracts.md index 9c1fb723..9fce4598 100644 --- a/skills/multi/dingtalk-chat/references/contracts.md +++ b/skills/multi/dingtalk-chat/references/contracts.md @@ -12,11 +12,11 @@ - `version`: `im.message-list.v1` - `message_fields`: `messageId`, `conversationId`, `threadId`, `sender`, `senderId`, `senderType`, `messageType`, `text`, `createTime`, `updateTime`, `reactions`, `quotedMessage`, `forwarded`, `resourceRefs` -- `envelope_fields`: `contractVersion`, `messages`, `count`, `resolvedFilters`, `queryRange`, `pagesFetched`, `paginationKnown`, `complete`, `hasMore`, `nextPage`, `stopReason`, `truncatedByPageLimit`, `truncatedByResultLimit`, `failedCount`, `failures`, `partial`, `scope`, `resourceDownloads` +- `envelope_fields`: `contractVersion`, `messages`, `count`, `resolvedFilters`, `queryRange`, `pagesFetched`, `paginationKnown`, `complete`, `hasMore`, `nextPage`, `stopReason`, `truncated`, `truncatedByPageLimit`, `truncatedByResultLimit`, `failedCount`, `failures`, `partial`, `scope`, `resourceDownloads` 当 `complete=false` 时不能称为全量成功。`nextPage` 只能来自真实 lower boundary; -`failedCount/failures`、`partial` 和两个 truncated 字段必须原样保留。 +`failedCount/failures`、`partial`、总 `truncated` 和两个原因字段必须原样保留。 当 Runtime 解析并应用自然发送者条件时,`resolvedFilters.senders[]` 保留原查询及选中的 `userId/openDingTalkId`。消息展示名可以与通讯录姓名不同;只能用稳定 `senderId` 与解析结果关联, 不得重新做姓名字符串比较。 From 896801634f2de6fe41de2348e61bbb2dda5ccc86 Mon Sep 17 00:00:00 2001 From: Dennis Date: Wed, 12 Aug 2026 14:59:18 +0800 Subject: [PATCH 41/86] fix(chat): preserve max-results visibility --- internal/shortcut/smart/chat_messages.go | 4 +++- internal/shortcut/smart/chat_messages_test.go | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/internal/shortcut/smart/chat_messages.go b/internal/shortcut/smart/chat_messages.go index 461f0f2e..1f7bf413 100644 --- a/internal/shortcut/smart/chat_messages.go +++ b/internal/shortcut/smart/chat_messages.go @@ -120,7 +120,7 @@ var ChatMessages = shortcut.Shortcut{ {Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 typed nextPage.time 自动读取后续页;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-items 仅与 --page-all 一起使用且不能为负数;--page-delay 仅与 --page-all 一起使用且不能为负数"}, {Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"}, {Name: "max-items", Type: shortcut.FlagInt, Desc: "自动翻页最多返回条数(默认 0 表示不限制);--max-items 仅与 --page-all 一起使用且不能为负数"}, - {Name: "max-results", Type: shortcut.FlagInt, Desc: "--max-items 的兼容别名", Hidden: true}, + {Name: "max-results", Type: shortcut.FlagInt, Desc: "--max-items 的公开兼容别名;仅与 --page-all 一起使用且不能为负数"}, {Name: "page-delay", Type: shortcut.FlagInt, Desc: "自动翻页每页之间等待毫秒数(默认 0 表示不等待);--page-delay 仅与 --page-all 一起使用且不能为负数"}, {Name: "output", Shorthand: "o", Type: shortcut.FlagString, Desc: "把完整结构化 ledger 原子写入工作目录内的相对 JSON 文件"}, }, chatshortcut.MessageResourceDownloadFlags()...), @@ -141,6 +141,8 @@ var ChatMessages = shortcut.Shortcut{ {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式页大小必须大于 0"}, {Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"}, {Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "max-items"}, Description: "--max-items 仅与 --page-all 一起使用且不能为负数"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "max-results"}, Description: "--max-results 仅与 --page-all 一起使用且不能为负数"}, + {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"max-items", "max-results"}}, {Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-delay"}, Description: "--page-delay 仅与 --page-all 一起使用且不能为负数"}, { Kind: shortcut.ConstraintCustom, diff --git a/internal/shortcut/smart/chat_messages_test.go b/internal/shortcut/smart/chat_messages_test.go index 9576755a..c79abd76 100644 --- a/internal/shortcut/smart/chat_messages_test.go +++ b/internal/shortcut/smart/chat_messages_test.go @@ -503,6 +503,18 @@ func chatMessagesRuntimeForTest(t *testing.T, values map[string]string) *shortcu return shortcut.RuntimeContextForTest(cmd, ChatMessages) } +func TestCrossPlatformCoverageChatMessagesKeepsMaxResultsPublic(t *testing.T) { + root := newPlatformCoverageRoot() + cmd, _, err := root.Find([]string{"chat", "+chat-messages"}) + if err != nil { + t.Fatal(err) + } + flag := cmd.Flags().Lookup("max-results") + if flag == nil || flag.Hidden { + t.Fatalf("--max-results must remain a visible compatibility flag: %#v", flag) + } +} + func TestCrossPlatformCoverageChatMessagesAdditionalValidationAndHelpers(t *testing.T) { for _, values := range []map[string]string{ {"time": "2026-01-01", "start": "2026-01-01"}, From eee41a9b459cd96bdf141101a13e0094d4c1a2cc Mon Sep 17 00:00:00 2001 From: Dennis Date: Wed, 12 Aug 2026 15:39:25 +0800 Subject: [PATCH 42/86] fix(chat): preserve safe pagination continuations --- internal/shortcut/chat/chat_conversation.go | 33 +++++++++++- .../chat/chat_conversation_projection_test.go | 51 ++++++++++++++++++- internal/shortcut/chat/chat_group.go | 15 +++++- .../chat/flag_list_pagination_test.go | 5 +- internal/shortcut/chat/lark_alignment.go | 15 +++++- .../chat/remaining_read_pagination_test.go | 5 +- internal/shortcut/pagination.go | 15 ++++++ internal/shortcut/smart/at_me.go | 14 +++++ internal/shortcut/smart/chat_messages.go | 4 +- internal/shortcut/smart/my_groups.go | 15 +++++- .../smart/remaining_read_pagination_test.go | 11 +++- 11 files changed, 171 insertions(+), 12 deletions(-) diff --git a/internal/shortcut/chat/chat_conversation.go b/internal/shortcut/chat/chat_conversation.go index 3c77f8f9..edad1f81 100644 --- a/internal/shortcut/chat/chat_conversation.go +++ b/internal/shortcut/chat/chat_conversation.go @@ -359,6 +359,7 @@ var ConversationList = shortcut.Shortcut{ stopReason := "source_complete" truncatedByPageLimit := false truncatedByResultLimit := false + unsafeResultContinuation := false failures := make([]map[string]any, 0) for pagesFetched < pageLimit { if pagesFetched > 0 { @@ -368,7 +369,7 @@ var ConversationList = shortcut.Shortcut{ break } } - params := map[string]any{"limit": rt.Int("limit")} + params := map[string]any{"limit": shortcut.AutoPageRequestSize(rt, rt.Int("limit"), len(convs))} if cursor > 0 { params["cursor"] = cursor } @@ -384,6 +385,7 @@ var ConversationList = shortcut.Shortcut{ break } pagesFetched++ + overflowOnPage := false for _, conversation := range conversationListProject(data) { id := strings.TrimSpace(fmt.Sprint(conversation["openConversationId"])) if id != "" && id != "" { @@ -394,6 +396,7 @@ var ConversationList = shortcut.Shortcut{ } if maxItems := rt.Int("max-items"); maxItems > 0 && len(convs) >= maxItems { truncatedByResultLimit = true + overflowOnPage = true continue } convs = append(convs, conversation) @@ -405,6 +408,14 @@ var ConversationList = shortcut.Shortcut{ failures = append(failures, map[string]any{"stage": "conversation-pagination", "error": "下层未返回 hasMore,无法证明结果完整"}) break } + if overflowOnPage { + hasMore = true + nextCursor = 0 + unsafeResultContinuation = true + failures = append(failures, map[string]any{"stage": "conversation-pagination", "error": "下层返回条数超过请求的剩余额度,无法生成不跳项的安全续页游标"}) + stopReason = "pagination_error" + break + } if !hasMore { complete = !truncatedByResultLimit if truncatedByResultLimit { @@ -450,7 +461,25 @@ var ConversationList = shortcut.Shortcut{ "partial": len(failures) > 0, } chatmsg.ApplyTruncation(payload) - return rt.Output(payload) + if err := rt.Output(payload); err != nil { + return err + } + if stopReason == "delay_interrupted" && rt.Command().Context().Err() != nil { + return rt.Command().Context().Err() + } + if unsafeResultContinuation { + return apperrors.NewAPI( + fmt.Sprintf("会话列表分页未完成:成功读取 %d 页,存在 %d 个失败项", pagesFetched, len(failures)), + apperrors.WithOperation("im/list_all_conversations"), + apperrors.WithReason("conversation_list_incomplete"), + apperrors.WithOrigin("mcp_gateway"), + apperrors.WithFailureStage("pagination"), + apperrors.WithExecutionStarted(true), + apperrors.WithRetryable(true), + apperrors.WithHint("请根据 failures 和 nextCursor 重试"), + ) + } + return nil }, } diff --git a/internal/shortcut/chat/chat_conversation_projection_test.go b/internal/shortcut/chat/chat_conversation_projection_test.go index cec59a68..320807e1 100644 --- a/internal/shortcut/chat/chat_conversation_projection_test.go +++ b/internal/shortcut/chat/chat_conversation_projection_test.go @@ -15,6 +15,7 @@ package chat import ( "bytes" + "context" "encoding/json" "reflect" "testing" @@ -150,7 +151,7 @@ func TestCrossPlatformCoverageConversationListSinglePagePreservesTypedCursor(t * func TestCrossPlatformCoverageConversationListMaxItemsPublishesStableTruncation(t *testing.T) { fake := &larkAlignmentCaller{responses: map[string]string{ - "im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1"},{"openConversationId":"cid-2"}],"hasMore":true,"nextCursor":2}}`, + "im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1"}],"hasMore":true,"nextCursor":2}}`, }} helpers.InitDeps(fake) root := newPlatformCoverageRoot() @@ -168,6 +169,54 @@ func TestCrossPlatformCoverageConversationListMaxItemsPublishesStableTruncation( payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" { t.Fatalf("payload = %#v", payload) } + if len(fake.calls) != 1 || fake.calls[0].args["limit"] != 1 || payload["nextCursor"] != float64(2) { + t.Fatalf("unsafe continuation: calls=%#v payload=%#v", fake.calls, payload) + } +} + +func TestCrossPlatformCoverageConversationListRejectsOversizedLimitPage(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1"},{"openConversationId":"cid-2"}],"hasMore":true,"nextCursor":2}}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{"chat", "+conversation-list", "--page-all", "--max-items", "1"}) + if err := root.Execute(); err == nil { + t.Fatal("oversized lower page unexpectedly published a safe continuation") + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["stopReason"] != "pagination_error" || payload["failedCount"] != float64(1) || payload["nextCursor"] != float64(0) { + t.Fatalf("payload = %#v", payload) + } +} + +func TestCrossPlatformCoverageConversationListPropagatesDelayCancellation(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1"}],"hasMore":true,"nextCursor":2}}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + root.SetContext(ctx) + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{"chat", "+conversation-list", "--page-all", "--page-delay", "1"}) + if err := root.Execute(); err == nil || err != context.Canceled { + t.Fatalf("delay cancellation error = %v, want context.Canceled", err) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["stopReason"] != "delay_interrupted" || payload["failedCount"] != float64(1) { + t.Fatalf("payload = %#v", payload) + } } func TestCrossPlatformCoverageConversationListDeduplicatesStableIDs(t *testing.T) { diff --git a/internal/shortcut/chat/chat_group.go b/internal/shortcut/chat/chat_group.go index 6bba8430..731c9c40 100644 --- a/internal/shortcut/chat/chat_group.go +++ b/internal/shortcut/chat/chat_group.go @@ -974,7 +974,8 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any) break } } - params := map[string]any{"limit": baseParams["limit"]} + pageSize, _ := baseParams["limit"].(int) + params := map[string]any{"limit": shortcut.AutoPageRequestSize(rt, pageSize, len(allGroups))} if cursorKey != "0" { params["cursor"] = cursorValue } @@ -991,6 +992,7 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any) } pagesFetched++ pageGroups := chatListAllProject(data) + overflowOnPage := false for _, group := range pageGroups { id := strings.TrimSpace(fmt.Sprint(group["openConversationId"])) if id == "" { @@ -1004,6 +1006,7 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any) } if maxItems := rt.Int("max-items"); maxItems > 0 && len(allGroups) >= maxItems { truncatedByResultLimit = true + overflowOnPage = true continue } allGroups = append(allGroups, group) @@ -1020,6 +1023,16 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any) break } hasMore = pageHasMore + if overflowOnPage { + hasMore = true + nextCursor = nil + failures = append(failures, map[string]any{ + "page": pagesFetched, "stage": "pagination", + "error": "已加入群列表下层返回条数超过请求的剩余额度,无法生成不跳项的安全续页游标", + }) + stopReason = "pagination_error" + break + } if !hasMore { complete = !truncatedByResultLimit nextCursor = nil diff --git a/internal/shortcut/chat/flag_list_pagination_test.go b/internal/shortcut/chat/flag_list_pagination_test.go index 9f58795b..cc7c2ad3 100644 --- a/internal/shortcut/chat/flag_list_pagination_test.go +++ b/internal/shortcut/chat/flag_list_pagination_test.go @@ -80,7 +80,7 @@ func TestCrossPlatformCoverageFlagListPageTokenAndPageLimit(t *testing.T) { func TestCrossPlatformCoverageFlagListMaxItemsPublishesStableTruncation(t *testing.T) { fake := &larkAlignmentCaller{responses: map[string]string{ - "im/list_message_favorites": `{"result":{"items":[{"openMessageId":"msg-1"},{"openMessageId":"msg-2"}],"hasMore":true,"nextCursor":9}}`, + "im/list_message_favorites": `{"result":{"items":[{"openMessageId":"msg-1"}],"hasMore":true,"nextCursor":9}}`, }} helpers.InitDeps(fake) root := newPlatformCoverageRoot() @@ -98,6 +98,9 @@ func TestCrossPlatformCoverageFlagListMaxItemsPublishesStableTruncation(t *testi payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" { t.Fatalf("payload = %#v", payload) } + if len(fake.calls) != 1 || fake.calls[0].args["size"] != "1" || payload["nextCursor"] != float64(9) { + t.Fatalf("unsafe continuation: calls=%#v payload=%#v", fake.calls, payload) + } } func TestCrossPlatformCoverageFlagListFailureModes(t *testing.T) { diff --git a/internal/shortcut/chat/lark_alignment.go b/internal/shortcut/chat/lark_alignment.go index 04d7706b..c8e01234 100644 --- a/internal/shortcut/chat/lark_alignment.go +++ b/internal/shortcut/chat/lark_alignment.go @@ -613,7 +613,8 @@ func executeFlagList(rt *shortcut.RuntimeContext) error { break } } - data, callErr := rt.CallMCPData("im", "list_message_favorites", flagListRequestParams(cursor, pageSize)) + requestPageSize := shortcut.AutoPageRequestSize(rt, pageSize, len(items)) + data, callErr := rt.CallMCPData("im", "list_message_favorites", flagListRequestParams(cursor, requestPageSize)) if callErr != nil { if pagesFetched == 0 { return callErr @@ -626,6 +627,7 @@ func executeFlagList(rt *shortcut.RuntimeContext) error { } pagesFetched++ pageItems := flagListItems(data) + overflowOnPage := false for _, item := range pageItems { messageID := firstNonEmptyMapString(item, "openMessageId", "messageId", "itemId", "id") if messageID != "" && seenMessages[messageID] { @@ -636,6 +638,7 @@ func executeFlagList(rt *shortcut.RuntimeContext) error { } if maxItems := rt.Int("max-items"); maxItems > 0 && len(items) >= maxItems { truncatedByResultLimit = true + overflowOnPage = true continue } items = append(items, item) @@ -666,6 +669,16 @@ func executeFlagList(rt *shortcut.RuntimeContext) error { } } hasMore = pageHasMore + if overflowOnPage { + hasMore = true + nextCursor = 0 + failures = append(failures, map[string]any{ + "page": pagesFetched, "stage": "pagination", + "error": "收藏列表下层返回条数超过请求的剩余额度,无法生成不跳项的安全续页游标", + }) + stopReason = "pagination_error" + break + } if !hasMore { complete = !truncatedByResultLimit nextCursor = 0 diff --git a/internal/shortcut/chat/remaining_read_pagination_test.go b/internal/shortcut/chat/remaining_read_pagination_test.go index 66b8d235..878d989b 100644 --- a/internal/shortcut/chat/remaining_read_pagination_test.go +++ b/internal/shortcut/chat/remaining_read_pagination_test.go @@ -102,7 +102,7 @@ func TestCrossPlatformCoverageDirectMessagesPageLimitPublishesExecutableContinua func TestCrossPlatformCoverageChatListAllMaxItemsPublishesStableTruncation(t *testing.T) { fake := &larkAlignmentCaller{responses: map[string]string{ - "im/list_my_groups_pagination": `{"result":{"groups":[{"openConversationId":"g1"},{"openConversationId":"g2"}],"hasMore":true,"nextCursor":88}}`, + "im/list_my_groups_pagination": `{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":88}}`, }} helpers.InitDeps(fake) root := newPlatformCoverageRoot() @@ -120,6 +120,9 @@ func TestCrossPlatformCoverageChatListAllMaxItemsPublishesStableTruncation(t *te payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" { t.Fatalf("payload = %#v", payload) } + if len(fake.calls) != 1 || fake.calls[0].args["limit"] != 1 || payload["nextCursor"] != float64(88) { + t.Fatalf("unsafe continuation: calls=%#v payload=%#v", fake.calls, payload) + } } func TestCrossPlatformCoverageDirectMessagesPageAllFailsClosedWithoutMillisecondCursor(t *testing.T) { diff --git a/internal/shortcut/pagination.go b/internal/shortcut/pagination.go index 451d8ec1..81973344 100644 --- a/internal/shortcut/pagination.go +++ b/internal/shortcut/pagination.go @@ -57,6 +57,21 @@ func ValidateAutoPageControls(rt *RuntimeContext) error { return nil } +// AutoPageRequestSize caps the next lower-page request to the remaining item +// budget. A cursor returned for that request is therefore safe to resume from: +// the CLI did not intentionally discard a suffix of the lower page. +func AutoPageRequestSize(rt *RuntimeContext, pageSize, itemCount int) int { + maxItems := rt.Int("max-items") + if maxItems <= 0 { + return pageSize + } + remaining := maxItems - itemCount + if remaining > 0 && remaining < pageSize { + return remaining + } + return pageSize +} + // WaitAutoPageDelay waits between successful pages and remains cancellable so // a throttled pagination run cannot ignore command cancellation. func WaitAutoPageDelay(rt *RuntimeContext) error { diff --git a/internal/shortcut/smart/at_me.go b/internal/shortcut/smart/at_me.go index 155100f5..1723d4e6 100644 --- a/internal/shortcut/smart/at_me.go +++ b/internal/shortcut/smart/at_me.go @@ -259,6 +259,8 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m for key, value := range baseParams { params[key] = value } + pageSize, _ := baseParams["limit"].(int) + params["limit"] = shortcut.AutoPageRequestSize(rt, pageSize, len(allItems)) params["cursor"] = cursor data, err := rt.CallMCPData("chat", "search_at_me_message", params) if err != nil { @@ -273,6 +275,7 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m } pagesFetched++ pageItems := atMeMessageItems(data) + overflowOnPage := false for _, item := range pageItems { id := chatmsg.StableMessageID(item) if id != "" && seenMessages[id] { @@ -283,6 +286,7 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m } if maxItems := rt.Int("max-items"); maxItems > 0 && len(allItems) >= maxItems { truncatedByResultLimit = true + overflowOnPage = true continue } allItems = append(allItems, item) @@ -299,6 +303,16 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m break } hasMore = pageHasMore + if overflowOnPage { + hasMore = true + nextCursor = "" + failures = append(failures, map[string]any{ + "page": pagesFetched, "stage": "pagination", + "error": "@我消息下层返回条数超过请求的剩余额度,无法生成不跳项的安全续页游标", + }) + stopReason = "pagination_error" + break + } if !hasMore { complete = !truncatedByResultLimit nextCursor = "" diff --git a/internal/shortcut/smart/chat_messages.go b/internal/shortcut/smart/chat_messages.go index 1f7bf413..ed03dd4c 100644 --- a/internal/shortcut/smart/chat_messages.go +++ b/internal/shortcut/smart/chat_messages.go @@ -117,10 +117,10 @@ var ChatMessages = shortcut.Shortcut{ {Name: "page-size", Type: shortcut.FlagInt, Desc: "--limit 的兼容别名", Hidden: true}, {Name: "direction", Type: shortcut.FlagString, Enum: []string{"newer", "older"}, Desc: "时间方向 newer/older;省略时为 older,从时间边界向前读取"}, {Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"}, - {Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 typed nextPage.time 自动读取后续页;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-items 仅与 --page-all 一起使用且不能为负数;--page-delay 仅与 --page-all 一起使用且不能为负数"}, + {Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 typed nextPage.time 自动读取后续页;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-items 仅与 --page-all 一起使用且不能为负数;--max-results 仅与 --page-all 一起使用且不能为负数;--page-delay 仅与 --page-all 一起使用且不能为负数"}, {Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"}, {Name: "max-items", Type: shortcut.FlagInt, Desc: "自动翻页最多返回条数(默认 0 表示不限制);--max-items 仅与 --page-all 一起使用且不能为负数"}, - {Name: "max-results", Type: shortcut.FlagInt, Desc: "--max-items 的公开兼容别名;仅与 --page-all 一起使用且不能为负数"}, + {Name: "max-results", Type: shortcut.FlagInt, Desc: "--max-items 的公开兼容别名;--max-results 仅与 --page-all 一起使用且不能为负数"}, {Name: "page-delay", Type: shortcut.FlagInt, Desc: "自动翻页每页之间等待毫秒数(默认 0 表示不等待);--page-delay 仅与 --page-all 一起使用且不能为负数"}, {Name: "output", Shorthand: "o", Type: shortcut.FlagString, Desc: "把完整结构化 ledger 原子写入工作目录内的相对 JSON 文件"}, }, chatshortcut.MessageResourceDownloadFlags()...), diff --git a/internal/shortcut/smart/my_groups.go b/internal/shortcut/smart/my_groups.go index 072fdcbb..0f84432a 100644 --- a/internal/shortcut/smart/my_groups.go +++ b/internal/shortcut/smart/my_groups.go @@ -197,7 +197,8 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma break } } - params := map[string]any{"limit": baseParams["limit"]} + pageSize, _ := baseParams["limit"].(int) + params := map[string]any{"limit": shortcut.AutoPageRequestSize(rt, pageSize, eligibleCount)} if cursorKey != "0" { params["cursor"] = cursorValue } @@ -214,6 +215,7 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma } pagesFetched++ pageGroups := myGroupsExtract(data) + overflowOnPage := false for _, group := range pageGroups { id := myGroupsStr(group, "openConversationId", "openConversationID", "conversationId", "openCid", "cid", "id") if id != "" && seenGroups[id] { @@ -224,6 +226,7 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma } if maxItems := rt.Int("max-items"); maxItems > 0 && myGroupsMatchesFilter(rt, group) && eligibleCount >= maxItems { truncatedByResultLimit = true + overflowOnPage = true continue } allGroups = append(allGroups, group) @@ -243,6 +246,16 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma break } hasMore = pageHasMore + if overflowOnPage { + hasMore = true + nextCursor = nil + failures = append(failures, map[string]any{ + "page": pagesFetched, "stage": "pagination", + "error": "我的群列表下层返回的匹配条数超过请求的剩余额度,无法生成不跳项的安全续页游标", + }) + stopReason = "pagination_error" + break + } if !hasMore { complete = !truncatedByResultLimit nextCursor = nil diff --git a/internal/shortcut/smart/remaining_read_pagination_test.go b/internal/shortcut/smart/remaining_read_pagination_test.go index 65f29f6f..220da567 100644 --- a/internal/shortcut/smart/remaining_read_pagination_test.go +++ b/internal/shortcut/smart/remaining_read_pagination_test.go @@ -85,7 +85,7 @@ func TestCrossPlatformCoverageAtMePageAllContinuesAcrossEmptyIntermediatePage(t func TestCrossPlatformCoverageAtMeMaxItemsPublishesStableTruncation(t *testing.T) { caller := &chatMessagesPagingCaller{responses: []string{ - `{"result":{"conversationMessagesList":[{"messages":[{"openMessageId":"m1"},{"openMessageId":"m2"}]}],"hasMore":true,"nextCursor":"cursor-2"}}`, + `{"result":{"conversationMessagesList":[{"messages":[{"openMessageId":"m1"}]}],"hasMore":true,"nextCursor":"cursor-2"}}`, }} helpers.InitDeps(caller) root := newPlatformCoverageRoot() @@ -103,6 +103,9 @@ func TestCrossPlatformCoverageAtMeMaxItemsPublishesStableTruncation(t *testing.T payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" { t.Fatalf("payload = %#v", payload) } + if len(caller.args) != 1 || caller.args[0]["limit"] != 1 || payload["nextCursor"] != "cursor-2" { + t.Fatalf("unsafe continuation: calls=%#v payload=%#v", caller.args, payload) + } } func TestCrossPlatformCoverageMyGroupsPageAllUsesNumericCursorAndFiltersAfterMerge(t *testing.T) { @@ -133,7 +136,8 @@ func TestCrossPlatformCoverageMyGroupsPageAllUsesNumericCursorAndFiltersAfterMer func TestCrossPlatformCoverageMyGroupsMaxItemsAppliesAfterTypeFilter(t *testing.T) { caller := &chatMessagesPagingCaller{responses: []string{ - `{"result":{"groups":[{"openConversationId":"p1","groupType":"p2p"},{"openConversationId":"g1","groupType":"group"},{"openConversationId":"g2","groupType":"group"}],"hasMore":false}}`, + `{"result":{"groups":[{"openConversationId":"p1","groupType":"p2p"}],"hasMore":true,"nextCursor":2}}`, + `{"result":{"groups":[{"openConversationId":"g1","groupType":"group"}],"hasMore":true,"nextCursor":3}}`, }} helpers.InitDeps(caller) root := newPlatformCoverageRoot() @@ -151,6 +155,9 @@ func TestCrossPlatformCoverageMyGroupsMaxItemsAppliesAfterTypeFilter(t *testing. payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" { t.Fatalf("payload = %#v", payload) } + if len(caller.args) != 2 || caller.args[0]["limit"] != 1 || caller.args[1]["limit"] != 1 || payload["nextCursor"] != float64(3) { + t.Fatalf("unsafe filtered continuation: calls=%#v payload=%#v", caller.args, payload) + } } func TestCrossPlatformCoverageRemainingReadPaginationValidation(t *testing.T) { From d07bf39586c3162047a47f78c6264afe3792059d Mon Sep 17 00:00:00 2001 From: Dennis Date: Wed, 12 Aug 2026 16:43:55 +0800 Subject: [PATCH 43/86] fix(chat): bound automatic page delays --- internal/shortcut/chat/chat_conversation.go | 6 +- .../chat/chat_conversation_projection_test.go | 21 +++++ internal/shortcut/chat/chat_group.go | 9 +- .../chat/flag_list_pagination_test.go | 51 +++++++++++ internal/shortcut/chat/lark_alignment.go | 9 +- .../chat/remaining_read_pagination_test.go | 63 +++++++++++++ internal/shortcut/chatmsg/chatmsg_test.go | 1 + internal/shortcut/pagination.go | 11 ++- internal/shortcut/pagination_test.go | 91 +++++++++++++++++++ internal/shortcut/smart/at_me.go | 9 +- internal/shortcut/smart/chat_messages_test.go | 27 ++++++ internal/shortcut/smart/my_groups.go | 9 +- .../smart/remaining_read_pagination_test.go | 78 ++++++++++++++++ 13 files changed, 350 insertions(+), 35 deletions(-) create mode 100644 internal/shortcut/pagination_test.go diff --git a/internal/shortcut/chat/chat_conversation.go b/internal/shortcut/chat/chat_conversation.go index edad1f81..a575f9a2 100644 --- a/internal/shortcut/chat/chat_conversation.go +++ b/internal/shortcut/chat/chat_conversation.go @@ -417,11 +417,7 @@ var ConversationList = shortcut.Shortcut{ break } if !hasMore { - complete = !truncatedByResultLimit - if truncatedByResultLimit { - hasMore = true - stopReason = "result_limit" - } + complete = true break } nextCursor, err = conversationPaginationCursor(page["nextCursor"]) diff --git a/internal/shortcut/chat/chat_conversation_projection_test.go b/internal/shortcut/chat/chat_conversation_projection_test.go index 320807e1..acddbd0b 100644 --- a/internal/shortcut/chat/chat_conversation_projection_test.go +++ b/internal/shortcut/chat/chat_conversation_projection_test.go @@ -17,6 +17,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "reflect" "testing" @@ -219,6 +220,26 @@ func TestCrossPlatformCoverageConversationListPropagatesDelayCancellation(t *tes } } +func TestCrossPlatformCoverageConversationListAutoPageValidationAndOutputFailure(t *testing.T) { + helpers.InitDeps(&larkAlignmentCaller{}) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+conversation-list", "--max-items", "1"}) + if err := root.Execute(); err == nil { + t.Fatal("max-items without page-all unexpectedly succeeded") + } + + fake := &larkAlignmentCaller{responses: map[string]string{ + "im/list_all_conversations": `{"result":{"conversationList":[],"hasMore":false}}`, + }} + helpers.InitDeps(fake) + root = newPlatformCoverageRoot() + root.SetOut(chatOutputErrorWriter{err: errors.New("fixture output")}) + root.SetArgs([]string{"chat", "+conversation-list"}) + if err := root.Execute(); err == nil { + t.Fatal("output error was swallowed") + } +} + func TestCrossPlatformCoverageConversationListDeduplicatesStableIDs(t *testing.T) { fake := &larkAlignmentCaller{responses: map[string]string{ "im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1","title":"一"},{"openConversationId":"cid-1","title":"重复"}],"hasMore":false}}`, diff --git a/internal/shortcut/chat/chat_group.go b/internal/shortcut/chat/chat_group.go index 731c9c40..d9b5d31c 100644 --- a/internal/shortcut/chat/chat_group.go +++ b/internal/shortcut/chat/chat_group.go @@ -1034,14 +1034,9 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any) break } if !hasMore { - complete = !truncatedByResultLimit + complete = true nextCursor = nil - if truncatedByResultLimit { - hasMore = true - stopReason = "result_limit" - } else { - stopReason = "source_complete" - } + stopReason = "source_complete" break } nextCursor = page["nextCursor"] diff --git a/internal/shortcut/chat/flag_list_pagination_test.go b/internal/shortcut/chat/flag_list_pagination_test.go index cc7c2ad3..b9a4c5b7 100644 --- a/internal/shortcut/chat/flag_list_pagination_test.go +++ b/internal/shortcut/chat/flag_list_pagination_test.go @@ -5,6 +5,7 @@ package chat import ( "bytes" + "context" "encoding/json" "errors" "testing" @@ -103,6 +104,56 @@ func TestCrossPlatformCoverageFlagListMaxItemsPublishesStableTruncation(t *testi } } +func TestCrossPlatformCoverageFlagListFailsClosedOnOversizeAndCanceledDelay(t *testing.T) { + t.Run("oversized lower page", func(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "im/list_message_favorites": `{"result":{"items":[{"openMessageId":"m1"},{"openMessageId":"m2"}],"hasMore":true,"nextCursor":9}}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{"chat", "+flag-list", "--page-all", "--max-items", "1"}) + if err := root.Execute(); err == nil { + t.Fatal("oversized lower page unexpectedly published a continuation") + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["stopReason"] != "pagination_error" || payload["failedCount"] != float64(1) || payload["nextCursor"] != float64(0) { + t.Fatalf("payload = %#v", payload) + } + if len(fake.calls) != 1 || fake.calls[0].args["size"] != "1" { + t.Fatalf("calls = %#v", fake.calls) + } + }) + + t.Run("canceled delay", func(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "im/list_message_favorites": `{"result":{"items":[{"openMessageId":"m1"}],"hasMore":true,"nextCursor":9}}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + root.SetContext(ctx) + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{"chat", "+flag-list", "--page-all", "--page-delay", "1"}) + if err := root.Execute(); err == nil { + t.Fatal("canceled delay unexpectedly succeeded") + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["stopReason"] != "delay_interrupted" || payload["failedCount"] != float64(1) { + t.Fatalf("payload = %#v", payload) + } + }) +} + func TestCrossPlatformCoverageFlagListFailureModes(t *testing.T) { t.Run("later read failure keeps partial result", func(t *testing.T) { fake := &larkAlignmentCaller{ diff --git a/internal/shortcut/chat/lark_alignment.go b/internal/shortcut/chat/lark_alignment.go index c8e01234..10b238e3 100644 --- a/internal/shortcut/chat/lark_alignment.go +++ b/internal/shortcut/chat/lark_alignment.go @@ -680,14 +680,9 @@ func executeFlagList(rt *shortcut.RuntimeContext) error { break } if !hasMore { - complete = !truncatedByResultLimit + complete = true nextCursor = 0 - if truncatedByResultLimit { - hasMore = true - stopReason = "result_limit" - } else { - stopReason = "source_complete" - } + stopReason = "source_complete" break } if cursorErr != nil || nextCursor <= 0 || seenCursors[nextCursor] { diff --git a/internal/shortcut/chat/remaining_read_pagination_test.go b/internal/shortcut/chat/remaining_read_pagination_test.go index 878d989b..d5506dbc 100644 --- a/internal/shortcut/chat/remaining_read_pagination_test.go +++ b/internal/shortcut/chat/remaining_read_pagination_test.go @@ -5,12 +5,14 @@ package chat import ( "bytes" + "context" "encoding/json" "errors" "testing" "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) func TestCrossPlatformCoverageChatListAllPageAllUsesNumericCursorAndDeduplicates(t *testing.T) { @@ -125,6 +127,56 @@ func TestCrossPlatformCoverageChatListAllMaxItemsPublishesStableTruncation(t *te } } +func TestCrossPlatformCoverageChatListAllFailsClosedOnOversizeAndCanceledDelay(t *testing.T) { + t.Run("oversized lower page", func(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "im/list_my_groups_pagination": `{"result":{"groups":[{"openConversationId":"g1"},{"openConversationId":"g2"}],"hasMore":true,"nextCursor":88}}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{"chat", "+chat-list-all", "--page-all", "--max-items", "1"}) + if err := root.Execute(); err == nil { + t.Fatal("oversized lower page unexpectedly published a continuation") + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["stopReason"] != "pagination_error" || payload["failedCount"] != float64(1) { + t.Fatalf("payload = %#v", payload) + } + if len(fake.calls) != 1 || fake.calls[0].args["limit"] != 1 || payload["nextCursor"] != nil { + t.Fatalf("unsafe continuation: calls=%#v payload=%#v", fake.calls, payload) + } + }) + + t.Run("canceled delay", func(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "im/list_my_groups_pagination": `{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":88}}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + root.SetContext(ctx) + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{"chat", "+chat-list-all", "--page-all", "--page-delay", "1"}) + if err := root.Execute(); err == nil { + t.Fatal("canceled delay unexpectedly succeeded") + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["stopReason"] != "delay_interrupted" || payload["failedCount"] != float64(1) { + t.Fatalf("payload = %#v", payload) + } + }) +} + func TestCrossPlatformCoverageDirectMessagesPageAllFailsClosedWithoutMillisecondCursor(t *testing.T) { fake := &larkAlignmentCaller{responses: map[string]string{ "chat/list_individual_chat_message": `{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":true}}`, @@ -188,6 +240,17 @@ func TestCrossPlatformCoverageChatRemainingPaginationValidation(t *testing.T) { t.Fatalf("invalid args succeeded: %v", args) } } + root := newPlatformCoverageRoot() + cmd, _, err := root.Find([]string{"chat", "+chat-list-all"}) + if err != nil { + t.Fatal(err) + } + if err := cmd.Flags().Set("max-items", "1"); err != nil { + t.Fatal(err) + } + if err := validateChatListAll(shortcut.RuntimeContextForTest(cmd, ChatListAll)); err == nil { + t.Fatal("direct auto-page validation unexpectedly succeeded") + } } func TestCrossPlatformCoverageChatListAllAdditionalEdges(t *testing.T) { diff --git a/internal/shortcut/chatmsg/chatmsg_test.go b/internal/shortcut/chatmsg/chatmsg_test.go index 73eadbb9..baf4a9f2 100644 --- a/internal/shortcut/chatmsg/chatmsg_test.go +++ b/internal/shortcut/chatmsg/chatmsg_test.go @@ -441,6 +441,7 @@ func TestCrossPlatformCoverageReactionsNormalizesEmotionReplyList(t *testing.T) } func TestCrossPlatformCoverageApplyPaginationReadsNestedEnvelope(t *testing.T) { + ApplyTruncation(nil) payload := map[string]any{"count": 98} ApplyPagination(payload, map[string]any{ "result": map[string]any{ diff --git a/internal/shortcut/pagination.go b/internal/shortcut/pagination.go index 81973344..c11839a6 100644 --- a/internal/shortcut/pagination.go +++ b/internal/shortcut/pagination.go @@ -15,9 +15,12 @@ package shortcut import ( "fmt" + "math" "time" ) +const maxAutoPageDelayMS = int64(math.MaxInt64) / int64(time.Millisecond) + // AutoPageControlFlags returns the shared item and pacing controls used by // cursor-based shortcuts. Callers retain ownership of --page-all and their // product-specific --page-limit defaults. @@ -51,9 +54,13 @@ func ValidateAutoPageControls(rt *RuntimeContext) error { if rt.Int("max-items") < 0 { return fmt.Errorf("--max-items 必须大于等于 0") } - if rt.Int("page-delay") < 0 { + delayMS := rt.Int("page-delay") + if delayMS < 0 { return fmt.Errorf("--page-delay 必须大于等于 0") } + if int64(delayMS) > maxAutoPageDelayMS { + return fmt.Errorf("--page-delay 不能大于 %d 毫秒", maxAutoPageDelayMS) + } return nil } @@ -79,7 +86,7 @@ func WaitAutoPageDelay(rt *RuntimeContext) error { if delayMS <= 0 { return nil } - timer := time.NewTimer(time.Duration(delayMS) * time.Millisecond) + timer := time.NewTimer(time.Duration(int64(delayMS)) * time.Millisecond) defer timer.Stop() select { case <-rt.Command().Context().Done(): diff --git a/internal/shortcut/pagination_test.go b/internal/shortcut/pagination_test.go new file mode 100644 index 00000000..20fd3445 --- /dev/null +++ b/internal/shortcut/pagination_test.go @@ -0,0 +1,91 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package shortcut + +import ( + "context" + "strconv" + "testing" + + "github.com/spf13/cobra" +) + +func autoPageRuntimeForTest(t *testing.T, pageAll bool, maxItems, pageDelay string) *RuntimeContext { + t.Helper() + cmd := &cobra.Command{Use: "page"} + cmd.SetContext(context.Background()) + cmd.Flags().Bool("page-all", false, "") + cmd.Flags().Int("max-items", 0, "") + cmd.Flags().Int("page-delay", 0, "") + if pageAll { + if err := cmd.Flags().Set("page-all", "true"); err != nil { + t.Fatal(err) + } + } + for name, value := range map[string]string{"max-items": maxItems, "page-delay": pageDelay} { + if value != "" { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatal(err) + } + } + } + return RuntimeContextForTest(cmd, Shortcut{}) +} + +func TestCrossPlatformCoverageAutoPageControlsBoundDelayAndRequestSize(t *testing.T) { + if err := ValidateAutoPageControls(autoPageRuntimeForTest(t, false, "1", "")); err == nil { + t.Fatal("max-items without page-all unexpectedly succeeded") + } + if err := ValidateAutoPageControls(autoPageRuntimeForTest(t, true, "-1", "")); err == nil { + t.Fatal("negative max-items unexpectedly succeeded") + } + if err := ValidateAutoPageControls(autoPageRuntimeForTest(t, true, "", "-1")); err == nil { + t.Fatal("negative page-delay unexpectedly succeeded") + } + if err := ValidateAutoPageControls(autoPageRuntimeForTest(t, true, "", strconv.FormatInt(maxAutoPageDelayMS, 10))); err != nil { + t.Fatalf("maximum safe page-delay failed: %v", err) + } + if strconv.IntSize == 64 { + tooLarge := strconv.FormatInt(maxAutoPageDelayMS+1, 10) + if err := ValidateAutoPageControls(autoPageRuntimeForTest(t, true, "", tooLarge)); err == nil { + t.Fatal("overflowing page-delay unexpectedly succeeded") + } + } + + if got := AutoPageRequestSize(autoPageRuntimeForTest(t, true, "0", ""), 100, 40); got != 100 { + t.Fatalf("unlimited request size = %d", got) + } + if got := AutoPageRequestSize(autoPageRuntimeForTest(t, true, "50", ""), 100, 40); got != 10 { + t.Fatalf("remaining request size = %d", got) + } + if got := AutoPageRequestSize(autoPageRuntimeForTest(t, true, "50", ""), 100, 50); got != 100 { + t.Fatalf("exhausted request size = %d", got) + } +} + +func TestCrossPlatformCoverageWaitAutoPageDelayNoopAndCancellation(t *testing.T) { + if err := WaitAutoPageDelay(autoPageRuntimeForTest(t, true, "", "0")); err != nil { + t.Fatalf("zero delay = %v", err) + } + if err := WaitAutoPageDelay(autoPageRuntimeForTest(t, true, "", "1")); err != nil { + t.Fatalf("elapsed delay = %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + rt := autoPageRuntimeForTest(t, true, "", "1") + rt.Command().SetContext(ctx) + if err := WaitAutoPageDelay(rt); err != context.Canceled { + t.Fatalf("canceled delay = %v, want context.Canceled", err) + } +} diff --git a/internal/shortcut/smart/at_me.go b/internal/shortcut/smart/at_me.go index 1723d4e6..e67330a5 100644 --- a/internal/shortcut/smart/at_me.go +++ b/internal/shortcut/smart/at_me.go @@ -314,14 +314,9 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m break } if !hasMore { - complete = !truncatedByResultLimit + complete = true nextCursor = "" - if truncatedByResultLimit { - hasMore = true - stopReason = "result_limit" - } else { - stopReason = "source_complete" - } + stopReason = "source_complete" break } nextCursor = atMeCursorString(page["nextCursor"]) diff --git a/internal/shortcut/smart/chat_messages_test.go b/internal/shortcut/smart/chat_messages_test.go index c79abd76..0c3918c9 100644 --- a/internal/shortcut/smart/chat_messages_test.go +++ b/internal/shortcut/smart/chat_messages_test.go @@ -516,6 +516,16 @@ func TestCrossPlatformCoverageChatMessagesKeepsMaxResultsPublic(t *testing.T) { } func TestCrossPlatformCoverageChatMessagesAdditionalValidationAndHelpers(t *testing.T) { + for _, values := range []map[string]string{ + {"max-results": "1"}, + {"max-items": "1"}, + {"page-all": "true", "max-results": "-1"}, + {"page-all": "true", "max-items": "1", "max-results": "1"}, + } { + if err := validateChatMessages(chatMessagesRuntimeForTest(t, values)); err == nil { + t.Fatalf("pagination validation unexpectedly accepted %#v", values) + } + } for _, values := range []map[string]string{ {"time": "2026-01-01", "start": "2026-01-01"}, {"direction": "older", "start": "2026-01-01"}, @@ -664,6 +674,23 @@ func TestCrossPlatformCoverageChatMessagesAdditionalCollectionEdges(t *testing.T } }) + t.Run("canceled delay", func(t *testing.T) { + caller := &chatMessagesPagingCaller{responses: []string{ + `{"result":{"hasMore":true,"nextCursor":1234,"messages":[{"openMessageId":"m1"}]}}`, + }} + rt := runtimeWith(t, caller, map[string]string{"page-delay": "1"}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + rt.Command().SetContext(ctx) + payload, _, err := collectAllChatMessages( + rt, + chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older"}, + ) + if err == nil || payload["stopReason"] != "delay_interrupted" || payload["failedCount"] != 1 { + t.Fatalf("payload=%#v err=%v", payload, err) + } + }) + t.Run("first failure ledger output error", func(t *testing.T) { helpers.InitDeps(&chatMessagesPagingCaller{failAt: 1}) root := newPlatformCoverageRoot() diff --git a/internal/shortcut/smart/my_groups.go b/internal/shortcut/smart/my_groups.go index 0f84432a..b4f6f57e 100644 --- a/internal/shortcut/smart/my_groups.go +++ b/internal/shortcut/smart/my_groups.go @@ -257,14 +257,9 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma break } if !hasMore { - complete = !truncatedByResultLimit + complete = true nextCursor = nil - if truncatedByResultLimit { - hasMore = true - stopReason = "result_limit" - } else { - stopReason = "source_complete" - } + stopReason = "source_complete" break } nextCursor = page["nextCursor"] diff --git a/internal/shortcut/smart/remaining_read_pagination_test.go b/internal/shortcut/smart/remaining_read_pagination_test.go index 220da567..b4ab47b7 100644 --- a/internal/shortcut/smart/remaining_read_pagination_test.go +++ b/internal/shortcut/smart/remaining_read_pagination_test.go @@ -5,6 +5,7 @@ package smart import ( "bytes" + "context" "encoding/json" "testing" @@ -160,6 +161,83 @@ func TestCrossPlatformCoverageMyGroupsMaxItemsAppliesAfterTypeFilter(t *testing. } } +func TestCrossPlatformCoverageRemainingListsFailClosedOnOversizeAndCanceledDelay(t *testing.T) { + tests := []struct { + name string + command string + response string + extra []string + }{ + { + name: "at-me", + command: "+at-me", + response: `{"result":{"conversationMessagesList":[{"messages":[{"openMessageId":"m1"},{"openMessageId":"m2"}]}],` + + `"hasMore":true,"nextCursor":"next"}}`, + }, + { + name: "my-groups", + command: "+my-groups", + response: `{"result":{"groups":[{"openConversationId":"g1","groupType":"group"},{"openConversationId":"g2","groupType":"group"}],"hasMore":true,"nextCursor":2}}`, + extra: []string{"--type", "group"}, + }, + } + for _, tc := range tests { + t.Run(tc.name+" oversized lower page", func(t *testing.T) { + caller := &chatMessagesPagingCaller{responses: []string{tc.response}} + helpers.InitDeps(caller) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + args := append([]string{"chat", tc.command}, tc.extra...) + args = append(args, "--page-all", "--max-items", "1") + root.SetArgs(args) + if err := root.Execute(); err == nil { + t.Fatal("oversized lower page unexpectedly published a continuation") + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["stopReason"] != "pagination_error" || payload["failedCount"] != float64(1) { + t.Fatalf("payload = %#v", payload) + } + if len(caller.args) != 1 || caller.args[0]["limit"] != 1 || payload["nextCursor"] != nil { + t.Fatalf("unsafe continuation: calls=%#v payload=%#v", caller.args, payload) + } + }) + + t.Run(tc.name+" canceled delay", func(t *testing.T) { + continuing := tc.response + if tc.command == "+at-me" { + continuing = `{"result":{"conversationMessagesList":[{"messages":[{"openMessageId":"m1"}]}],"hasMore":true,"nextCursor":"next"}}` + } else { + continuing = `{"result":{"groups":[{"openConversationId":"g1","groupType":"group"}],"hasMore":true,"nextCursor":2}}` + } + caller := &chatMessagesPagingCaller{responses: []string{continuing}} + helpers.InitDeps(caller) + root := newPlatformCoverageRoot() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + root.SetContext(ctx) + var output bytes.Buffer + root.SetOut(&output) + args := append([]string{"chat", tc.command}, tc.extra...) + args = append(args, "--page-all", "--page-delay", "1") + root.SetArgs(args) + if err := root.Execute(); err == nil { + t.Fatal("canceled delay unexpectedly succeeded") + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["stopReason"] != "delay_interrupted" || payload["failedCount"] != float64(1) { + t.Fatalf("payload = %#v", payload) + } + }) + } +} + func TestCrossPlatformCoverageRemainingReadPaginationValidation(t *testing.T) { for _, args := range [][]string{ {"chat", "+at-me", "--page-limit", "2"}, From fdbd11e0eaa1c21986e38f494277c240db5b989e Mon Sep 17 00:00:00 2001 From: Dennis Date: Wed, 12 Aug 2026 19:28:29 +0800 Subject: [PATCH 44/86] docs(changelog): add IM pagination release note --- .changes/970-chat-auto-pagination.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/970-chat-auto-pagination.md diff --git a/.changes/970-chat-auto-pagination.md b/.changes/970-chat-auto-pagination.md new file mode 100644 index 00000000..4b665e47 --- /dev/null +++ b/.changes/970-chat-auto-pagination.md @@ -0,0 +1,5 @@ +--- +category: Added +--- + +- **Chat automatic pagination controls** (#970) — adds bounded `--max-items` and cancellable `--page-delay` support to the core IM list shortcuts, with safe continuation metadata and truncation reporting. From 2f8e136dc08f7c0aa63cf3aa1057a428f9714d84 Mon Sep 17 00:00:00 2001 From: Dennis Date: Wed, 12 Aug 2026 19:51:00 +0800 Subject: [PATCH 45/86] fix(chat): fail closed on bounded legacy pages --- .../chat/flag_list_pagination_test.go | 29 +++++++++++++++++++ internal/shortcut/chat/lark_alignment.go | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/internal/shortcut/chat/flag_list_pagination_test.go b/internal/shortcut/chat/flag_list_pagination_test.go index b9a4c5b7..2f997956 100644 --- a/internal/shortcut/chat/flag_list_pagination_test.go +++ b/internal/shortcut/chat/flag_list_pagination_test.go @@ -104,6 +104,35 @@ func TestCrossPlatformCoverageFlagListMaxItemsPublishesStableTruncation(t *testi } } +func TestCrossPlatformCoverageFlagListLegacyFullRemainingPageFailsClosed(t *testing.T) { + fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{ + "im/list_message_favorites": { + `{"result":{"items":[{"openMessageId":"m1"},{"openMessageId":"m2"}],"hasMore":true,"nextCursor":7}}`, + `{"result":{"items":[{"openMessageId":"m3"}]}}`, + }, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{"chat", "+flag-list", "--page-size", "2", "--page-all", "--max-items", "3"}) + if err := root.Execute(); err == nil { + t.Fatal("full remaining-budget legacy page unexpectedly declared a complete result") + } + if len(fake.calls) != 2 || fake.calls[0].args["size"] != "2" || fake.calls[1].args["size"] != "1" { + t.Fatalf("request sizes = %#v", fake.calls) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["count"] != float64(3) || payload["complete"] != false || + payload["paginationKnown"] != false || payload["stopReason"] != "pagination_error" || + payload["failedCount"] != float64(1) || payload["nextCursor"] != float64(0) { + t.Fatalf("payload = %#v", payload) + } +} + func TestCrossPlatformCoverageFlagListFailsClosedOnOversizeAndCanceledDelay(t *testing.T) { t.Run("oversized lower page", func(t *testing.T) { fake := &larkAlignmentCaller{responses: map[string]string{ diff --git a/internal/shortcut/chat/lark_alignment.go b/internal/shortcut/chat/lark_alignment.go index 10b238e3..f8a7a605 100644 --- a/internal/shortcut/chat/lark_alignment.go +++ b/internal/shortcut/chat/lark_alignment.go @@ -651,7 +651,7 @@ func executeFlagList(rt *shortcut.RuntimeContext) error { switch { case nextCursor > 0: pageHasMore = true - case len(pageItems) < pageSize: + case len(pageItems) < requestPageSize: paginationKnown = false complete = true hasMore = false From 7bf8ce79bd3c5c5a29ea7f0a76de2ed6cb8e67d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Thu, 13 Aug 2026 12:07:06 +0800 Subject: [PATCH 46/86] chore(policy): to #85411130 add idempotency flag migration ledger --- .../approved-flag-migrations-v1.json | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json index d52e59e4..a89aaea1 100644 --- a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json +++ b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json @@ -928,6 +928,39 @@ "state": "pending", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, + { + "command": "dws chat message send", + "legacy": { + "name": "uuid", + "before": { + "present": true, + "type": "string", + "scope": "local", + "required": false + }, + "after": { + "present": true, + "type": "string", + "scope": "local", + "hidden": true, + "alias_of": "idempotency-key" + } + }, + "canonical": { + "name": "idempotency-key", + "before": { + "present": false + }, + "after": { + "present": true, + "type": "string", + "scope": "local", + "required": false + } + }, + "state": "pending", + "reason": "保留旧 argv 兼容性,并将幂等参数规范 flag 设为唯一可见入口" + }, { "command": "dws chat message set-pin-msg", "legacy": { From def6ed4d2fbb89639e2b20fa9987536fd8932d9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Thu, 13 Aug 2026 12:16:16 +0800 Subject: [PATCH 47/86] test(chat): align list-all time expectations --- internal/helpers/chat_message_pagination_test.go | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/internal/helpers/chat_message_pagination_test.go b/internal/helpers/chat_message_pagination_test.go index 78eca26c..61ad4c2f 100644 --- a/internal/helpers/chat_message_pagination_test.go +++ b/internal/helpers/chat_message_pagination_test.go @@ -83,7 +83,12 @@ func TestChatMessagePaginationDefaultSinglePageUnchanged(t *testing.T) { args: []string{"message", "list-all", "--start", "2026-08-01 00:00:00", "--end", "2026-08-02 00:00:00"}, server: "", tool: "search_messages_by_time_range", - want: map[string]any{"startTime": "2026-08-01 00:00:00", "endTime": "2026-08-02 00:00:00", "limit": 50, "cursor": "0"}, + want: map[string]any{ + "startTime": formatChatMessageListAllTime(1785513600000), + "endTime": formatChatMessageListAllTime(1785600000000), + "limit": 50, + "cursor": "0", + }, }, { name: "list-by-sender", @@ -276,8 +281,8 @@ func TestChatMessagePaginationDefaultsStartFromExplicitEnd(t *testing.T) { t.Fatalf("startTime = %d, want %d", startMs, wantStartMs) } if tt.wantTool == "search_messages_by_time_range" { - assertStringArg(t, got.args["startTime"], "2025-12-31 00:00:00") - assertStringArg(t, got.args["endTime"], "2026-01-01 00:00:00") + assertStringArg(t, got.args["startTime"], formatChatMessageListAllTime(wantStartMs)) + assertStringArg(t, got.args["endTime"], formatChatMessageListAllTime(endMs)) } }) } @@ -343,7 +348,7 @@ func TestChatMessagePaginationDefaultsEndFromNowWhenOnlyStartProvided(t *testing t.Fatalf("endTime = %d, want between %d and %d", endMs, wantEndMin, wantEndMax) } if tt.wantTool == "search_messages_by_time_range" { - assertStringArg(t, got.args["startTime"], "2026-01-01 00:00:00") + assertStringArg(t, got.args["startTime"], formatChatMessageListAllTime(startMs)) parseChatMessageListAllTimeArg(t, got.args["endTime"]) } }) @@ -423,7 +428,7 @@ func parseChatMessageListAllTimeArg(t *testing.T, value any) time.Time { if strings.Contains(raw, "T") { t.Fatalf("time arg = %q, want yyyy-MM-dd HH:mm:ss without RFC3339 separator", raw) } - parsed, err := time.ParseInLocation("2006-01-02 15:04:05", raw, shanghaiLocation()) + parsed, err := time.ParseInLocation("2006-01-02 15:04:05", raw, time.Local) if err != nil { t.Fatalf("time arg = %q, parse err = %v", raw, err) } From c7236a1844e54e8f7b74b5f64412519a6e1d29d4 Mon Sep 17 00:00:00 2001 From: chichuan Date: Thu, 13 Aug 2026 13:18:21 +0800 Subject: [PATCH 48/86] release: seal v1.0.58-beta.5 changelog --- .../85384225-agent-version-ext.md | 0 .../85387314-chat-image-guide.md | 0 .../1.0.58-beta.5}/961-drive-file-comments.md | 0 .../970-chat-auto-pagination.md | 0 .../doc-drive-wiki-space-routing.md | 0 CHANGELOG.md | 20 +++++++++++++++++++ 6 files changed, 20 insertions(+) rename .changes/{ => released/1.0.58-beta.5}/85384225-agent-version-ext.md (100%) rename .changes/{ => released/1.0.58-beta.5}/85387314-chat-image-guide.md (100%) rename .changes/{ => released/1.0.58-beta.5}/961-drive-file-comments.md (100%) rename .changes/{ => released/1.0.58-beta.5}/970-chat-auto-pagination.md (100%) rename .changes/{ => released/1.0.58-beta.5}/doc-drive-wiki-space-routing.md (100%) diff --git a/.changes/85384225-agent-version-ext.md b/.changes/released/1.0.58-beta.5/85384225-agent-version-ext.md similarity index 100% rename from .changes/85384225-agent-version-ext.md rename to .changes/released/1.0.58-beta.5/85384225-agent-version-ext.md diff --git a/.changes/85387314-chat-image-guide.md b/.changes/released/1.0.58-beta.5/85387314-chat-image-guide.md similarity index 100% rename from .changes/85387314-chat-image-guide.md rename to .changes/released/1.0.58-beta.5/85387314-chat-image-guide.md diff --git a/.changes/961-drive-file-comments.md b/.changes/released/1.0.58-beta.5/961-drive-file-comments.md similarity index 100% rename from .changes/961-drive-file-comments.md rename to .changes/released/1.0.58-beta.5/961-drive-file-comments.md diff --git a/.changes/970-chat-auto-pagination.md b/.changes/released/1.0.58-beta.5/970-chat-auto-pagination.md similarity index 100% rename from .changes/970-chat-auto-pagination.md rename to .changes/released/1.0.58-beta.5/970-chat-auto-pagination.md diff --git a/.changes/doc-drive-wiki-space-routing.md b/.changes/released/1.0.58-beta.5/doc-drive-wiki-space-routing.md similarity index 100% rename from .changes/doc-drive-wiki-space-routing.md rename to .changes/released/1.0.58-beta.5/doc-drive-wiki-space-routing.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 02d4254e..7d1d23e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +## [1.0.58-beta.5] - 2026-08-13 + +### Added + +- **Agent version and extended context passthrough** (Aone 85384225) — adds + validated `DWS_AGENT_VER` and sensitive JSON `DWS_AGENT_EXT` metadata to + ordinary non-plugin MCP requests without forwarding it to A2A, OAuth, + Discovery, or third-party plugins. + +- **Drive file comments** (#961) — adds `dws drive comment list` and `dws drive comment create` for comments on ordinary preview files. + +- **Chat automatic pagination controls** (#970) — adds bounded `--max-items` and cancellable `--page-delay` support to the core IM list shortcuts, with safe continuation metadata and truncation reporting. + +### Changed + +- **Chat message send help** - Clarifies Markdown image syntax for inline mixed text and images. + +- **Doc/drive/wiki routing descriptions** — clarifies the document-space container-vs-content boundary across the doc, drive, and wiki skill descriptions for more predictable first-round Agent selection, without changing CLI behavior. + + ## [1.0.58-beta.4] - 2026-08-12 ### Added From d32bbe009dd638d8a455d2d98ca97efac54f52bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Thu, 13 Aug 2026 13:28:00 +0800 Subject: [PATCH 49/86] fix(chat): expose idempotency key for message send --- internal/app/param_alias_e2e_test.go | 25 ++++++++++++++++++- .../param_alias_payload_equivalence_test.go | 6 ++--- internal/app/root_help_test.go | 8 ++++++ internal/cli/schema_catalog_test.go | 9 +++++++ internal/helpers/chat.go | 7 ++++-- 5 files changed, 49 insertions(+), 6 deletions(-) diff --git a/internal/app/param_alias_e2e_test.go b/internal/app/param_alias_e2e_test.go index 8267390c..a14170de 100644 --- a/internal/app/param_alias_e2e_test.go +++ b/internal/app/param_alias_e2e_test.go @@ -324,7 +324,7 @@ func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) { "chat", "message", "send", "--to-user", "D-recipient", "--text", "hello alias", - "--uuid", "alias-e2e", + "--idempotency-key", "alias-e2e", ) if err != nil { t.Fatalf("chat write alias E2E error = %v", err) @@ -350,6 +350,29 @@ func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) { } } +func TestCrossPlatformCoverageChatMessageSendLegacyUUIDAliasFinalPayload(t *testing.T) { + caller := ¶mAliasCaptureCaller{} + _, err := executeParamAliasE2E(t, caller, + "chat", "message", "send", + "--group", "fixture-conversation", + "--text", "hello legacy uuid", + "--uuid", "legacy-alias-e2e", + ) + if err != nil { + t.Fatalf("chat message send legacy uuid error = %v", err) + } + if len(caller.calls) != 1 || caller.calls[0].tool != "send_personal_message" { + t.Fatalf("chat calls = %#v", caller.calls) + } + payload := caller.calls[0].args + if payload["uuid"] != "legacy-alias-e2e" || payload["openConversationId"] != "fixture-conversation" { + t.Fatalf("chat legacy uuid payload = %#v", payload) + } + if _, exists := payload["idempotency-key"]; exists { + t.Fatalf("chat payload leaked CLI-only idempotency-key: %#v", payload) + } +} + func TestCrossPlatformCoverageChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) { tests := []struct { name string diff --git a/internal/app/param_alias_payload_equivalence_test.go b/internal/app/param_alias_payload_equivalence_test.go index 494a8b6c..0db9b2dc 100644 --- a/internal/app/param_alias_payload_equivalence_test.go +++ b/internal/app/param_alias_payload_equivalence_test.go @@ -88,7 +88,7 @@ var paramAliasCompleteCommands = map[string][]string{ "chat message recall": {"chat", "message", "recall", "--conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"}, "chat message reply": {"chat", "message", "reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", "D-sender", "--text", "hello fixture", "--yes"}, "chat message search-advanced": {"chat", "message", "search-advanced", "--conversation-ids", "fixture-conversation", "--query", "fixture"}, - "chat message send": {"chat", "message", "send", "--user", "D-recipient", "--text", "hello fixture", "--uuid", "param-alias-equivalence", "--yes"}, + "chat message send": {"chat", "message", "send", "--user", "D-recipient", "--text", "hello fixture", "--idempotency-key", "param-alias-equivalence", "--yes"}, "chat message send-by-bot": {"chat", "message", "send-by-bot", "--robot-code", "robot-1", "--group", "fixture-conversation", "--title", "Fixture Alert", "--text", "@user-1 @user-2 fixture", "--at-user-ids", "user-1,user-2", "--yes"}, "chat message send-by-webhook": {"chat", "message", "send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"}, "contact +dept-members": {"contact", "+dept-members", "--dept", "Fixture Dept"}, @@ -163,8 +163,8 @@ var paramAliasCompleteCommandVariants = map[string]map[string][]string{ "sender-open-dingtalk-id": {"chat", "message", "list-by-sender", "--sender-open-dingtalk-id", "D-sender", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"}, }, "chat message send": { - "group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--uuid", "param-alias-equivalence-group", "--yes"}, - "file-path": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file-path", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--uuid", "param-alias-equivalence-file", "--yes"}, + "group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--idempotency-key", "param-alias-equivalence-group", "--yes"}, + "file-path": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file-path", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--idempotency-key", "param-alias-equivalence-file", "--yes"}, }, "chat +conversation-set-top": { "conversation-ids": {"chat", "+conversation-set-top", "--conversation-ids", "fixture-conversation-1,fixture-conversation-2", "--yes"}, diff --git a/internal/app/root_help_test.go b/internal/app/root_help_test.go index eb370a7b..79c1c967 100644 --- a/internal/app/root_help_test.go +++ b/internal/app/root_help_test.go @@ -369,6 +369,14 @@ func TestChatFileUploadDownlinedButMessageFileSendStays(t *testing.T) { t.Fatalf("chat message send missing --%s", flag) } } + idempotencyKey := send.Flags().Lookup("idempotency-key") + if idempotencyKey == nil { + t.Fatal("chat message send missing --idempotency-key") + } + legacyUUID := send.Flags().Lookup("uuid") + if legacyUUID == nil || !legacyUUID.Hidden { + t.Fatalf("chat message send --uuid hidden = %#v, want hidden compatibility flag", legacyUUID) + } got, err := executeRootCaptureStdout(t, []string{ "chat", "file", "upload", diff --git a/internal/cli/schema_catalog_test.go b/internal/cli/schema_catalog_test.go index e653256a..0360ae3a 100644 --- a/internal/cli/schema_catalog_test.go +++ b/internal/cli/schema_catalog_test.go @@ -1356,6 +1356,7 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) { {"chat message edit", "conversation-id", "openConversationId", true, ""}, {"chat message edit", "msg-id", "openMessageId", true, ""}, {"chat message edit", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"}, + {"chat message send", "idempotency-key", "uuid", false, ""}, {"chat message send-card", "at-all", "atAll", false, ""}, {"chat message send-card", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"}, {"chat message update-text-emotion", "msg-id", "openMsgId", true, ""}, @@ -1407,6 +1408,14 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) { t.Fatalf("chat message edit unexpectedly publishes hidden alias --%s", hidden) } } + sendLeaf, err = queryDeliverySchemaPayload([]string{"chat message send"}) + if err != nil { + t.Fatal(err) + } + sendParams = schemaMap(sendLeaf["parameters"]) + if _, ok := sendParams["uuid"]; ok { + t.Fatal("chat message send unexpectedly publishes hidden alias --uuid") + } listByConv, err := queryDeliverySchemaPayload([]string{"chat category list-by-conv"}) if err != nil { t.Fatal(err) diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index dd91e2eb..1f8ad37f 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -2835,7 +2835,7 @@ func newChatCommand() *cobra.Command { groupID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat") userID, _ := cmd.Flags().GetString("user") openDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id") - msgUuid, _ := cmd.Flags().GetString("uuid") + msgUuid := flagOrFallback(cmd, "idempotency-key", "uuid") specified := 0 if groupID != "" { specified++ @@ -3050,6 +3050,7 @@ func newChatCommand() *cobra.Command { {Name: "ai-tag", Property: "clawType", InterfaceType: "string"}, {Name: "at-open-dingtalk-ids", Property: "atOpenDingTalkIds"}, {Name: "group", Property: "openConversationId"}, + {Name: "idempotency-key", Property: "uuid"}, {Name: "open-dingtalk-id", Property: "receiverOpenDingTalkId"}, }, }, @@ -4385,7 +4386,9 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 _ = chatMessageSendCmd.Flags().MarkHidden("file-type") _ = chatMessageSendCmd.Flags().MarkHidden("file-size") chatMessageSendCmd.Flags().Bool("ai-tag", true, "消息是否带 AI 发送角标(默认 true)") - chatMessageSendCmd.Flags().String("uuid", "", "幂等 UUID,相同 uuid 在 24h 内不会重复发送(可选)") + chatMessageSendCmd.Flags().String("idempotency-key", "", "幂等键,相同 key 在 24h 内不会重复发送(可选)") + chatMessageSendCmd.Flags().String("uuid", "", "--idempotency-key 的兼容别名") + _ = chatMessageSendCmd.Flags().MarkHidden("uuid") cli.AttachRuntimeSchema(chatMessageSendCmd, "chat", "send_personal_message", "hardcoded:chat") cli.AnnotateRuntimeConstraints(chatMessageSendCmd, cli.RuntimeSchemaConstraints{ MutuallyExclusive: [][]string{{"group", "user", "open-dingtalk-id"}}, From e3fef0b6d4a3232b4a3f29bb7f75e4b5c6402cd5 Mon Sep 17 00:00:00 2001 From: chichuan Date: Thu, 13 Aug 2026 13:34:43 +0800 Subject: [PATCH 50/86] ci: fast-path release seal fragment archival --- .github/workflows/ci.yml | 100 ++++++++++++++++++++++++++++++++++----- 1 file changed, 89 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eace8f78..53ed6bdd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,7 @@ jobs: pull-requests: read outputs: changelog_only: ${{ steps.classify.outputs.changelog_only }} + release_seal_only: ${{ steps.classify.outputs.release_seal_only }} changelog_changed: ${{ steps.classify.outputs.changelog_changed }} docs_only: ${{ steps.classify.outputs.docs_only }} full_suite: ${{ steps.classify.outputs.full_suite }} @@ -39,6 +40,7 @@ jobs: with: script: | let changelogOnly = false; + let releaseSealOnly = false; let changelogChanged = false; let docsOnly = false; let fullSuite = context.eventName === 'push'; @@ -162,6 +164,43 @@ jobs: filename === 'go.mod' || filename === 'go.sum' ); + const isExactReleaseSeal = (candidates) => { + const changelog = candidates.filter( + ({ filename, status, previous_filename }) => + filename === 'CHANGELOG.md' && + status === 'modified' && + !previous_filename + ); + if (changelog.length !== 1 || candidates.length < 2) { + return false; + } + let version = ''; + return candidates.every((file) => { + if (file.filename === 'CHANGELOG.md') { + return file.status === 'modified' && !file.previous_filename; + } + if ( + file.status !== 'renamed' || + typeof file.filename !== 'string' || + typeof file.previous_filename !== 'string' || + file.additions !== 0 || + file.deletions !== 0 + ) { + return false; + } + const target = file.filename.match( + /^\.changes\/released\/([0-9]+\.[0-9]+\.[0-9]+(?:-beta\.[1-9][0-9]*)?)\/([a-z0-9][a-z0-9._-]*\.md)$/ + ); + if (!target || file.previous_filename !== `.changes/${target[2]}`) { + return false; + } + if (version && version !== target[1]) { + return false; + } + version = target[1]; + return true; + }); + }; const classifyFiles = (complete) => { const paths = files.flatMap(({ filename, previous_filename }) => [filename, previous_filename].filter( @@ -248,19 +287,26 @@ jobs: ); } - changelogOnly = + const exactChangelogDiff = files.length === 1 && files[0].filename === 'CHANGELOG.md' && files[0].status === 'modified' && !files[0].previous_filename; + releaseSealOnly = isExactReleaseSeal(files); + changelogOnly = exactChangelogDiff || releaseSealOnly; changelogChanged = files.some( ({ filename, previous_filename }) => filename === 'CHANGELOG.md' || previous_filename === 'CHANGELOG.md' ); classifyFiles(true); + if (releaseSealOnly) { + fullSuite = false; + } fastPathTrust = changelogOnly - ? 'exact pull-request revision and synthetic merge policy' + ? releaseSealOnly + ? 'exact release-seal fragment archival and synthetic merge policy' + : 'exact CHANGELOG-only revision and synthetic merge policy' : docsOnly ? 'documentation-only focused admission' : fullSuite @@ -307,8 +353,9 @@ jobs: files[0].filename === 'CHANGELOG.md' && files[0].status === 'modified' && !files[0].previous_filename; + const exactReleaseSealDiff = isExactReleaseSeal(files); - if (linearFromValidatedTip && exactChangelogDiff) { + if (linearFromValidatedTip && (exactChangelogDiff || exactReleaseSealDiff)) { const requiredContexts = [ 'Lint', 'Test', @@ -359,9 +406,15 @@ jobs: if (missing.length === 0 && nonSuccess.length === 0) { changelogOnly = true; + releaseSealOnly = exactReleaseSealDiff; changelogChanged = true; + if (releaseSealOnly) { + fullSuite = false; + } fastPathTrust = - `exact CHANGELOG-only successor of validated ${expectedBefore}`; + releaseSealOnly + ? `exact release-seal successor of validated ${expectedBefore}` + : `exact CHANGELOG-only successor of validated ${expectedBefore}`; } else { fastPathTrust = 'predecessor Code Admission is not fully successful; ' + @@ -376,6 +429,7 @@ jobs: } core.setOutput('changelog_only', String(changelogOnly)); + core.setOutput('release_seal_only', String(releaseSealOnly)); core.setOutput('changelog_changed', String(changelogChanged)); core.setOutput('docs_only', String(docsOnly)); core.setOutput('full_suite', String(fullSuite)); @@ -387,7 +441,8 @@ jobs: await core.summary .addHeading('Code Admission scope') .addRaw(`- Event: \`${context.eventName}\`\n`) - .addRaw(`- Exact modified CHANGELOG only: \`${changelogOnly}\`\n`) + .addRaw(`- Metadata-only fast path: \`${changelogOnly}\`\n`) + .addRaw(`- Release-seal fragments only: \`${releaseSealOnly}\`\n`) .addRaw(`- CHANGELOG touched: \`${changelogChanged}\`\n`) .addRaw(`- Documentation-only: \`${docsOnly}\`\n`) .addRaw(`- Full suite: \`${fullSuite}\`\n`) @@ -402,7 +457,14 @@ jobs: - name: Record CHANGELOG-only fast path if: steps.classify.outputs.changelog_only == 'true' - run: echo "Lint is satisfied by the trusted CHANGELOG-only Policy path." >> "$GITHUB_STEP_SUMMARY" + env: + RELEASE_SEAL_ONLY: ${{ steps.classify.outputs.release_seal_only }} + run: | + if [ "$RELEASE_SEAL_ONLY" = true ]; then + echo "Lint is satisfied by the trusted release-seal fragment Policy path." >> "$GITHUB_STEP_SUMMARY" + else + echo "Lint is satisfied by the trusted CHANGELOG-only Policy path." >> "$GITHUB_STEP_SUMMARY" + fi - name: Record documentation-only fast path if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only == 'true' @@ -1282,7 +1344,7 @@ jobs: fi mode=--content-only - if [ "$CHANGELOG_ONLY" = true ]; then + if [ "$CHANGELOG_ONLY" = true ] && [ "$RELEASE_SEAL_ONLY" != true ]; then mode=--fast-path fi ./scripts/policy/check-changelog-pr.sh \ @@ -1294,25 +1356,41 @@ jobs: PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} run: ./scripts/policy/check-release-fragments.sh "$PR_BASE_SHA" HEAD - - name: Validate trusted main CHANGELOG-only push + - name: Validate trusted main metadata-only push if: github.event_name == 'push' && needs.lint.outputs.changelog_only == 'true' env: PUSH_BEFORE_SHA: ${{ github.event.before }} PUSH_AFTER_SHA: ${{ github.event.after }} + RELEASE_SEAL_ONLY: ${{ needs.lint.outputs.release_seal_only }} run: | set -eu test "$(git rev-parse HEAD)" = "$PUSH_AFTER_SHA" || { echo "checked-out push revision does not match event after SHA" >&2 exit 1 } + mode=--fast-path + if [ "$RELEASE_SEAL_ONLY" = true ]; then + mode=--content-only + fi ./scripts/policy/check-changelog-pr.sh \ - --fast-path "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA" + "$mode" "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA" + if [ "$RELEASE_SEAL_ONLY" = true ]; then + ./scripts/policy/check-release-fragments.sh \ + "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA" + fi - name: Record CHANGELOG-only fast path if: needs.lint.outputs.changelog_only == 'true' + env: + RELEASE_SEAL_ONLY: ${{ needs.lint.outputs.release_seal_only }} run: | - echo "Only the trusted base-equivalent CHANGELOG validator ran; executable sources are unchanged." \ - >> "$GITHUB_STEP_SUMMARY" + if [ "$RELEASE_SEAL_ONLY" = true ]; then + echo "Only the trusted release-seal and fragment validators ran; executable sources are unchanged." \ + >> "$GITHUB_STEP_SUMMARY" + else + echo "Only the trusted base-equivalent CHANGELOG validator ran; executable sources are unchanged." \ + >> "$GITHUB_STEP_SUMMARY" + fi - name: Validate scoped policy if: ${{ needs.lint.outputs.changelog_only != 'true' && (needs.lint.outputs.docs_only == 'true' || (needs.lint.outputs.full_suite != 'true' && needs.lint.outputs.interface_sensitive != 'true')) }} From e58b85ea452b285a819930dc6ccc8545021a87eb Mon Sep 17 00:00:00 2001 From: chichuan Date: Thu, 13 Aug 2026 13:44:23 +0800 Subject: [PATCH 51/86] test: cover release seal CI fast path --- test/scripts/changelog_pr_gate_test.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/scripts/changelog_pr_gate_test.go b/test/scripts/changelog_pr_gate_test.go index bf05855b..dcf398e7 100644 --- a/test/scripts/changelog_pr_gate_test.go +++ b/test/scripts/changelog_pr_gate_test.go @@ -1025,6 +1025,12 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) { "files[0].filename === 'CHANGELOG.md'", "files[0].status === 'modified'", "!files[0].previous_filename", + "const isExactReleaseSeal", + "file.status !== 'renamed'", + "file.additions !== 0", + "file.deletions !== 0", + "releaseSealOnly = isExactReleaseSeal(files)", + "release_seal_only: ${{ steps.classify.outputs.release_seal_only }}", "pre-classification", "post-classification", "before.changed_files !== files.length", @@ -1048,7 +1054,8 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) { "mode=--fast-path", `"$mode" "$PR_BASE_SHA" HEAD`, `test "$(git rev-parse HEAD)" = "$PUSH_AFTER_SHA"`, - `--fast-path "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA"`, + `"$mode" "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA"`, + `./scripts/policy/check-release-fragments.sh \`, "needs.lint.outputs.platform_sensitive == 'true'", `COVERAGE_TARGET: "100"`, `COVERAGE_ENFORCE_OVERALL: "false"`, From 9edc587e96799d5a25b5b860acd75a98c8530ba9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Thu, 13 Aug 2026 12:07:06 +0800 Subject: [PATCH 52/86] chore(policy): to #85411130 add idempotency flag migration ledger --- .../approved-flag-migrations-v1.json | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json index d52e59e4..a89aaea1 100644 --- a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json +++ b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json @@ -928,6 +928,39 @@ "state": "pending", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, + { + "command": "dws chat message send", + "legacy": { + "name": "uuid", + "before": { + "present": true, + "type": "string", + "scope": "local", + "required": false + }, + "after": { + "present": true, + "type": "string", + "scope": "local", + "hidden": true, + "alias_of": "idempotency-key" + } + }, + "canonical": { + "name": "idempotency-key", + "before": { + "present": false + }, + "after": { + "present": true, + "type": "string", + "scope": "local", + "required": false + } + }, + "state": "pending", + "reason": "保留旧 argv 兼容性,并将幂等参数规范 flag 设为唯一可见入口" + }, { "command": "dws chat message set-pin-msg", "legacy": { From 9c59206d2f230440de55d84d8a23afbb98227577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=B7=E5=BF=83=E8=8F=9C?= Date: Mon, 10 Aug 2026 20:11:48 +0800 Subject: [PATCH 53/86] feat(drive): add drive list --type/--start/--end client-side filtering --- CHANGELOG.md | 11 + internal/helpers/drive.go | 28 +- internal/helpers/drive_depth.go | 25 +- internal/helpers/drive_depth_test.go | 34 +- internal/helpers/drive_list_filter.go | 229 +++++++++ internal/helpers/drive_list_filter_test.go | 469 ++++++++++++++++++ internal/helpers/pr868_coverage_gaps_test.go | 2 +- skills/mono/references/products/drive.md | 21 + .../multi/dingtalk-drive/references/drive.md | 21 + 9 files changed, 808 insertions(+), 32 deletions(-) create mode 100644 internal/helpers/drive_list_filter.go create mode 100644 internal/helpers/drive_list_filter_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d1d23e1..bfb18edc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,14 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th - **Personal OA approval events** — personal event consumers now support task creation, completion, redirection, instance start, termination, and completion events, with typed output and matching usage documentation. +- **Drive list type/time filtering** — `dws drive list` gains `--type + file|folder`, `--start`, and `--end` for client-side filtering by node type + and modification time on both the pan and workspace routes. Filtering runs + a bounded full scan of the target directory (2000-entry cap, reported via + `truncated=true`), composes with `--latest`/`--pattern`/`--depth`, and is + mutually exclusive with `--versions`/`--cursor`/`--order-by`/`--order`/ + `--limit`. Time values accept relative forms (`24h`/`7d`/`2w`), RFC 3339, + zone-less ISO 8601 (Asia/Shanghai), or a plain date. ### Fixed @@ -103,6 +111,9 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th stale OAuth profile. Detached buses negotiate an owner-only, memory-only IPC credential channel; tokens are never placed in child argv, environment, profiles, logs, or run-state files. +- **Drive list pattern filtering** — `dws drive list --pattern` on the + single-layer pan route now filters the returned page by name pattern; the + flag was previously accepted but silently ignored. ### Changed diff --git a/internal/helpers/drive.go b/internal/helpers/drive.go index ccceafbf..6fbf6782 100644 --- a/internal/helpers/drive.go +++ b/internal/helpers/drive.go @@ -413,6 +413,13 @@ func newDriveCommand() *cobra.Command { } } + // --type/--start/--end 客户端过滤:激活即切 BFS 全量拉取后筛(两路由统一); + // 未启用返回零值,存量分支字节级不变。互斥/非法值/start>end 在此拒绝。 + filter, err := parseDriveListFilter(cmd) + if err != nil { + return err + } + // --versions 模式:列出文件历史版本(仅普通文件) // 先于 --depth 校验执行:versions 模式合法使用 --limit, // 不应被「--limit 与 --depth 不兼容」的误导性报错拦截。 @@ -454,7 +461,8 @@ func newDriveCommand() *cobra.Command { if workspaceID != "" { // depth>1 时 --pattern 放开(先递归后过滤);--order-by/--space-id/--thumbnail // 知识库无对应参数,静默忽略。 - if depth > 1 || latest > 0 { + // filter 激活时 depth==1 也走 BFS 退化态(全量拉取→CLI 侧筛)。 + if depth > 1 || latest > 0 || filter.active() { quiet, _ := cmd.Flags().GetBool("quiet") baseArgs := map[string]any{"workspaceId": workspaceID} rootFolder := docFolderFlag(cmd, "node", "file-id") @@ -463,7 +471,7 @@ func newDriveCommand() *cobra.Command { return err } } - return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest) + return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest, filter) } if pattern != "" { return &CLIError{ @@ -487,7 +495,9 @@ func newDriveCommand() *cobra.Command { return callMCPToolOnServer("doc", "list_nodes", toolArgs) } - if depth > 1 { + // 钉盘:filter 激活即 depth=1 单层退化态(全量翻完当前目录后 CLI 侧筛); + // filter+latest 单层同走 BFS(先筛后排,不走 runDriveListLatest 凑够即停)。 + if depth > 1 || filter.active() { quiet, _ := cmd.Flags().GetBool("quiet") baseArgs := map[string]any{} if v, _ := cmd.Flags().GetString("space-id"); v != "" { @@ -508,7 +518,7 @@ func newDriveCommand() *cobra.Command { return err } } - return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest) + return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest, filter) } // 默认路由:钉盘文件列表 @@ -563,6 +573,11 @@ func newDriveCommand() *cobra.Command { if v, _ := cmd.Flags().GetBool("thumbnail"); v { argsMap["withThumbnail"] = true } + // --pattern 页内过滤(现状缺口附带修复:透传即打印无法夹过滤,取回解析后筛); + // 不带 pattern 时保持 callMCPTool 纯透传,存量行为不变。 + if pattern != "" { + return callDriveListPageWithPattern(argsMap, pattern) + } return callMCPTool("list_files", argsMap) }, } @@ -591,11 +606,13 @@ func newDriveCommand() *cobra.Command { "用户要浏览「我的文件」/钉盘/网盘某目录下有哪些文件或文件夹时", "已知父文件夹 dentryUuid,要列出其子项以便继续 download/copy/move 时", "传 --workspace 时要列出文档空间/知识库根或子目录(与 wiki node list 场景重叠时,用户说钉盘/我的文件优先本命令)", + "要在已知目录内按类型/修改时间做无关键词筛选时:--type file --start 7d(钉盘与知识库路由均可,CLI 侧过滤)", }, AvoidWhen: []string{ "只记得关键词、不知道所在目录时改用 dws drive search", "明确要在某个知识库内按目录浏览且已有 workspaceId 时可用 dws wiki node list", "要找最近打开/编辑过的文档改用 dws drive recent", + "带关键词的过滤改用 dws drive search(--extensions/--modified-from 等已可用)", }, Examples: []string{ "dws drive list --limit 20 --format json", @@ -1211,6 +1228,9 @@ func newDriveCommand() *cobra.Command { driveListCmd.Flags().Int("depth", 1, "递归列出子目录层级,默认 1(仅当前层),最大 5;与 --cursor/--limit 互斥;与 --workspace 组合时走知识库递归 (可选)") driveListCmd.Flags().Int("latest", 0, "按修改时间取最新 N 个文件(1~50);与 --pattern 组合时表示名称匹配的文件中最新 N 个;可与 --workspace/--depth 组合;与 --order-by/--order/--limit/--cursor 互斥 (可选)") driveListCmd.Flags().Bool("quiet", false, "关闭递归进度输出(stderr),不影响 stdout JSON (--depth>1 或 --latest 多页扫描时有效) (可选)") + driveListCmd.Flags().String("type", "", "按节点类型过滤: file|folder(客户端过滤:全量扫描后筛,钉盘/知识库均可用;与 --versions/--cursor/--order-by/--order/--limit 互斥)(可选)") + driveListCmd.Flags().String("start", "", "按修改时间过滤·起始: 相对时间如 24h/7d/2w、RFC3339、YYYY-MM-DD(客户端过滤,互斥同 --type)(可选)") + driveListCmd.Flags().String("end", "", "按修改时间过滤·截止: 语法同 --start(客户端过滤,互斥同 --type)(可选)") driveInfoCmd.Flags().String("node", "", "节点 ID (dentryUuid) (必填)") driveInfoCmd.Flags().String("space-id", "", "节点所属空间 ID (可选)") diff --git a/internal/helpers/drive_depth.go b/internal/helpers/drive_depth.go index 4dfe98ee..b70a6c1f 100644 --- a/internal/helpers/drive_depth.go +++ b/internal/helpers/drive_depth.go @@ -178,7 +178,8 @@ func newDocDepthRoute() driveDepthRoute { } // SIGINT 检查两点(出队后发首页前 + 翻页循环发每页前),入队是纯内存操作不检查。 -func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool, latest int) error { +// filter 零值 = 未启用;启用时由 emitDriveDepthResult 管线在 pattern 之后、latest 之前筛。 +func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool, latest int, filter driveListFilter) error { if deps.Caller.DryRun() { return printDriveDepthDryRun(route, baseArgs, maxDepth) } @@ -209,7 +210,7 @@ func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[s bfs: for len(queue) > 0 { if ctx.Err() != nil { - return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth) + return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth, route, filter) } folder := queue[0] queue = queue[1:] @@ -220,7 +221,7 @@ bfs: pages := 0 for { if ctx.Err() != nil { - return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth) + return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth, route, filter) } pages++ if pages > maxPagesPerFolder { @@ -233,7 +234,7 @@ bfs: args := route.buildArgs(baseArgs, folder.id, pageToken) text, err := route.fetchPage(ctx, args) if ctx.Err() != nil { - return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth) + return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth, route, filter) } if err != nil { folderErr = err @@ -297,7 +298,7 @@ bfs: if driveDepthUnrecoverable(folderErr) { // partial 照吐 stdout,错误详情走 stderr,非零退出 errs = append(errs, newDriveDepthError(folder, folderErr)) - if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth); emitErr != nil { + if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth, route, filter); emitErr != nil { return emitErr } return folderErr @@ -338,18 +339,18 @@ bfs: } } - return emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth) + return emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth, route, filter) } -func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string, latest, reqDepth int) error { - if err := emitDriveDepthResult(items, errs, true, pattern, latest, reqDepth); err != nil { +func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string, latest, reqDepth int, route driveDepthRoute, filter driveListFilter) error { + if err := emitDriveDepthResult(items, errs, true, pattern, latest, reqDepth, route, filter); err != nil { return err } return &driveDepthCancelledError{} } // depth>1 不输出 nextToken。 -func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string, latest, reqDepth int) error { +func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string, latest, reqDepth int, route driveDepthRoute, filter driveListFilter) error { if pattern != "" { // 先递归后过滤,过滤仅作用于输出项,不阻止文件夹下钻 filtered := make([]map[string]any, 0, len(items)) @@ -364,6 +365,10 @@ func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, trunca } items = filtered } + if filter.active() { + // 类型(route.isFolder 反判)+ 时间(直读收集段注入的 sortTime 毫秒)区间筛 + items = applyDriveListFilter(items, route, filter) + } if latest > 0 { items = applyDriveListLatest(items, latest) } else { @@ -383,7 +388,7 @@ func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, trunca maxDepth = d } } - if latest > 0 && reqDepth == 1 { + if (latest > 0 || filter.active()) && reqDepth == 1 { stripDriveDepthDecorations(items) } if items == nil { diff --git a/internal/helpers/drive_depth_test.go b/internal/helpers/drive_depth_test.go index 14b44201..d4af7ebd 100644 --- a/internal/helpers/drive_depth_test.go +++ b/internal/helpers/drive_depth_test.go @@ -306,7 +306,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) { {"name": "a-file.xlsx", "rel_path": "a", "depth": 2, "fileId": "f1"}, {"name": "skip-me.csv", "rel_path": "c", "depth": 1, "fileId": "f3"}, } - if err := emitDriveDepthResult(items, nil, false, "*.xlsx", 0, 0); err != nil { + if err := emitDriveDepthResult(items, nil, false, "*.xlsx", 0, 0, newDrivePanDepthRoute(), driveListFilter{}); err != nil { t.Fatal(err) } result := decodeDepthResult(t, out) @@ -325,7 +325,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) { } out.Reset() - if err := emitDriveDepthResult(nil, nil, false, "", 0, 0); err != nil { + if err := emitDriveDepthResult(nil, nil, false, "", 0, 0, newDrivePanDepthRoute(), driveListFilter{}); err != nil { t.Fatal(err) } result = decodeDepthResult(t, out) @@ -338,7 +338,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) { {"name": "dup", "rel_path": "p/dup", "fileId": "a1"}, } out.Reset() - if err := emitDriveDepthResult(samePath, nil, false, "", 0, 0); err != nil { + if err := emitDriveDepthResult(samePath, nil, false, "", 0, 0, newDrivePanDepthRoute(), driveListFilter{}); err != nil { t.Fatal(err) } got = decodeDepthResult(t, out)["items"].([]any) @@ -347,7 +347,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) { } deps.Out.w = failingWriter{} - if err := emitDriveDepthResult(nil, nil, false, "", 0, 0); err == nil { + if err := emitDriveDepthResult(nil, nil, false, "", 0, 0, newDrivePanDepthRoute(), driveListFilter{}); err == nil { t.Fatal("failing writer returned nil") } } @@ -382,7 +382,7 @@ func runDepthBFS(t *testing.T, caller *scriptedToolCaller, route driveDepthRoute t.Helper() out := installDepthCaller(t, caller) cmd := &cobra.Command{Use: "list"} - err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true, 0) + err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true, 0, driveListFilter{}) return decodeDepthResult(t, out), err } @@ -390,7 +390,7 @@ func TestCrossPlatformCoverageRunDriveListDepthDryRun(t *testing.T) { caller := &scriptedToolCaller{format: "json", dry: true} out := installDepthCaller(t, caller) cmd := &cobra.Command{Use: "list"} - if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0); err != nil { + if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0, driveListFilter{}); err != nil { t.Fatal(err) } if caller.calls != 0 { @@ -413,7 +413,7 @@ func TestCrossPlatformCoverageRunDriveListDepthPanBFS(t *testing.T) { }} out := installDepthCaller(t, caller) cmd := &cobra.Command{Use: "list"} - if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", false, 0); err != nil { + if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", false, 0, driveListFilter{}); err != nil { t.Fatal(err) } if caller.calls != 2 { @@ -548,7 +548,7 @@ func TestCrossPlatformCoverageRunDriveListDepthRateLimitResumesFromFailedPage(t deps.Out.w = out deps.Out.errW = io.Discard cmd := &cobra.Command{Use: "list"} - if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0); err != nil { + if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{}); err != nil { t.Fatal(err) } if len(caller.calls) != 3 { @@ -593,7 +593,7 @@ func TestCrossPlatformCoverageRunDriveListDepthRootFailure(t *testing.T) { }} installDepthCaller(t, caller) cmd := &cobra.Command{Use: "list"} - err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0) + err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{}) if err == nil { t.Fatal("root failure returned nil") } @@ -610,7 +610,7 @@ func TestCrossPlatformCoverageRunDriveListDepthUnrecoverable(t *testing.T) { }} out := installDepthCaller(t, caller) cmd := &cobra.Command{Use: "list"} - err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0) + err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{}) if err == nil { t.Fatal("unrecoverable returned nil") } @@ -635,7 +635,7 @@ func TestCrossPlatformCoverageRunDriveListDepthUnrecoverableEmitFailure(t *testi installDepthCaller(t, caller) deps.Out.w = failingWriter{} cmd := &cobra.Command{Use: "list"} - err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0) + err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{}) if err == nil || !strings.Contains(err.Error(), "write failed") { t.Fatalf("err = %v, want emit failure", err) } @@ -648,7 +648,7 @@ func TestCrossPlatformCoverageRunDriveListDepthPaginationLoop(t *testing.T) { }} installDepthCaller(t, caller) cmd := &cobra.Command{Use: "list"} - err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0) + err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{}) if err == nil || !strings.Contains(err.Error(), "cursor loop suspected") { t.Fatalf("err = %v, want pagination anomaly", err) } @@ -690,7 +690,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelled(t *testing.T) { cancel() cmd := &cobra.Command{Use: "list"} cmd.SetContext(ctx) - err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0) + err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{}) var cancelErr *driveDepthCancelledError if !errors.As(err, &cancelErr) { t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err) @@ -710,7 +710,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledEmitFailure(t *testing.T cancel() cmd := &cobra.Command{Use: "list"} cmd.SetContext(ctx) - err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0) + err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{}) if err == nil || !strings.Contains(err.Error(), "write failed") { t.Fatalf("err = %v, want emit failure", err) } @@ -724,7 +724,7 @@ func TestCrossPlatformCoverageRunDriveListDepthFinalEmitFailure(t *testing.T) { installDepthCaller(t, caller) deps.Out.w = failingWriter{} cmd := &cobra.Command{Use: "list"} - err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0) + err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{}) if err == nil || !strings.Contains(err.Error(), "write failed") { t.Fatalf("err = %v, want emit failure", err) } @@ -772,7 +772,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledInsidePagination(t *test out := installDepthCaller(t, caller) cmd := &cobra.Command{Use: "list"} cmd.SetContext(ctx) - err := runDriveListDepth(cmd, route, map[string]any{}, "", 3, "", true, 0) + err := runDriveListDepth(cmd, route, map[string]any{}, "", 3, "", true, 0, driveListFilter{}) var cancelErr *driveDepthCancelledError if !errors.As(err, &cancelErr) { t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err) @@ -801,7 +801,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledAfterFetch(t *testing.T) deps.Out.errW = io.Discard cmd := &cobra.Command{Use: "list"} cmd.SetContext(ctx) - err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0) + err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{}) var cancelErr *driveDepthCancelledError if !errors.As(err, &cancelErr) { t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err) diff --git a/internal/helpers/drive_list_filter.go b/internal/helpers/drive_list_filter.go new file mode 100644 index 00000000..f86bda84 --- /dev/null +++ b/internal/helpers/drive_list_filter.go @@ -0,0 +1,229 @@ +package helpers + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" +) + +// ────────────────────────────────────────────────────────── +// drive list --type/--start/--end 客户端过滤(CLI 侧筛) +// +// 两路由统一(拍板⑤):钉盘与知识库均为全量拉取后在进程内筛—— +// 复用 depth/latest 的 BFS 基建(钉盘单层 = depth=1 退化态,全量翻完当前目录不下钻), +// 过滤挂在 emitDriveDepthResult 管线内(pattern 名称筛之后、latest Top-N 之前)。 +// 类型判定复用 route.isFolder 反判;时间比较直读 BFS 收集段注入的 sortTime 毫秒。 +// ────────────────────────────────────────────────────────── + +type driveListFilter struct { + nodeType string // ""=不过滤 / "file" / "folder" + startMs int64 + endMs int64 + hasStart bool + hasEnd bool +} + +func (f driveListFilter) active() bool { + return f.nodeType != "" || f.hasStart || f.hasEnd +} + +// parseDriveListFilter 读取并校验 --type/--start/--end。 +// 三 flag 均未给值时返回零值(active()=false,调用方走存量分支)。 +// 非法值 / start>end / 互斥组合在此拒绝(退出码 3,风格对齐 drive_latest.go 互斥条款)。 +func parseDriveListFilter(cmd *cobra.Command) (driveListFilter, error) { + var filter driveListFilter + nodeType, _ := cmd.Flags().GetString("type") + startRaw, _ := cmd.Flags().GetString("start") + endRaw, _ := cmd.Flags().GetString("end") + nodeType = strings.TrimSpace(nodeType) + startRaw = strings.TrimSpace(startRaw) + endRaw = strings.TrimSpace(endRaw) + if nodeType == "" && startRaw == "" && endRaw == "" { + return filter, nil + } + + if nodeType != "" { + switch strings.ToLower(nodeType) { + case "file", "folder": + filter.nodeType = strings.ToLower(nodeType) + default: + return filter, &CLIError{ + Code: CodeInvalidParam, + Message: fmt.Sprintf("--type 取值非法: %q(合法值: file|folder)", nodeType), + } + } + } + if startRaw != "" { + ms, err := parseDriveListTime(startRaw) + if err != nil { + return filter, &CLIError{Code: CodeInvalidParam, Message: fmt.Sprintf("--start %s", err)} + } + filter.startMs = ms + filter.hasStart = true + } + if endRaw != "" { + ms, err := parseDriveListTime(endRaw) + if err != nil { + return filter, &CLIError{Code: CodeInvalidParam, Message: fmt.Sprintf("--end %s", err)} + } + filter.endMs = ms + filter.hasEnd = true + } + if filter.hasStart && filter.hasEnd && filter.startMs > filter.endMs { + return filter, &CLIError{ + Code: CodeInvalidParam, + Message: fmt.Sprintf("--start %q 晚于 --end %q,请修正时间范围", startRaw, endRaw), + } + } + + // 互斥:filter 为 CLI 侧全量扫描模式,服务端分页/排序语义无法保持。 + if cmd.Flags().Changed("versions") { + return filter, driveListFilterExclusiveError("versions", "--versions 为独立的版本列表模式,请去掉 --versions 或过滤条件") + } + if v := flagOrFallback(cmd, "cursor", "next-token", "page-token"); v != "" { + return filter, driveListFilterExclusiveError("cursor", "过滤模式为从头全量扫描,无连续游标;如需逐页翻页请去掉过滤条件") + } + for _, f := range []string{"order-by", "order"} { + if cmd.Flags().Changed(f) { + return filter, driveListFilterExclusiveError(f, "过滤模式输出由客户端筛选后重排,服务端排序语义无法保持;如需服务端排序请去掉过滤条件") + } + } + if cmd.Flags().Changed("limit") || cmd.Flags().Changed("max") { + return filter, driveListFilterExclusiveError("limit", "过滤模式为全量扫描,数据量由全局上限 2000 与目录范围控制;如需控制单页条数请去掉过滤条件") + } + return filter, nil +} + +func driveListFilterExclusiveError(flag, guidance string) error { + return &CLIError{ + Code: CodeInvalidParam, + Message: fmt.Sprintf("--type/--start/--end 不能与 --%s 同时使用:%s", flag, guidance), + } +} + +// parseDriveListTime 单一时间解析入口:先试相对时间语法,失败回落 ISO8601 +// (agoal.go parseISO8601ToMillis,RFC3339/无时区默认 Asia/Shanghai/仅日期)。 +// 两 flag 共用同一入口;不接受毫秒时间戳(规约红线,与 search 历史遗留切割)。 +func parseDriveListTime(value string) (int64, error) { + if ms, ok := parseDriveRelativeTimeAgo(value); ok { + return ms, nil + } + if ms, err := parseISO8601ToMillis(value); err == nil { + return ms, nil + } + return 0, fmt.Errorf("时间格式不支持: %q(支持: 相对时间如 24h/7d/2w、RFC3339 如 2026-08-01T00:00:00+08:00、无时区 ISO8601 如 2026-08-01 08:00:00(默认 Asia/Shanghai)、仅日期 2026-08-01;不支持毫秒时间戳与 m 单位)", value) +} + +// parseDriveRelativeTimeAgo 相对时间 Nh/Nd/Nw(小时/天/周),按本机时钟换算为绝对毫秒。 +// 不支持 m 单位(lark 双解析器 m=分钟/月语义打架的教训,直接规避); +// ok=false 表示非相对时间语法,由调用方回落 ISO8601 解析。 +func parseDriveRelativeTimeAgo(value string) (int64, bool) { + s := strings.TrimSpace(value) + if len(s) < 2 { + return 0, false + } + var unit time.Duration + switch s[len(s)-1] { + case 'h': + unit = time.Hour + case 'd': + unit = 24 * time.Hour + case 'w': + unit = 7 * 24 * time.Hour + default: + return 0, false + } + n, err := strconv.Atoi(s[:len(s)-1]) + if err != nil || n <= 0 { + return 0, false + } + return time.Now().Add(-time.Duration(n) * unit).UnixMilli(), true +} + +// applyDriveListFilter 在 emit 管线内做类型/时间筛(pattern 之后、latest Top-N 之前)。 +func applyDriveListFilter(items []map[string]any, route driveDepthRoute, filter driveListFilter) []map[string]any { + filtered := make([]map[string]any, 0, len(items)) + for _, item := range items { + if filter.nodeType != "" { + folder := route.isFolder(item) + if filter.nodeType == "folder" && !folder { + continue + } + if filter.nodeType == "file" && folder { + continue + } + } + if filter.hasStart || filter.hasEnd { + // sortTime 由 BFS 收集段无条件注入(drive_depth.go 时间戳归一); + // 无时间信息的条目(sortTime<=0)在时间条件下不可判定,保守滤除。 + ts, _ := item["sortTime"].(int64) + if ts <= 0 { + continue + } + if filter.hasStart && ts < filter.startMs { + continue + } + if filter.hasEnd && ts > filter.endMs { + continue + } + } + filtered = append(filtered, item) + } + return filtered +} + +// callDriveListPageWithPattern 单层钉盘 --pattern 页内过滤(现状缺口附带修复: +// 纯透传分支此前未消费 pattern,flag 文案承诺的客户端过滤静默失效)。 +// callMCPTool 透传即打印、无夹过滤的钩子,故取回解析后页内筛再输出; +// 输出保留原 body 形态(nextToken 等分页字段不动),仅条目数组被筛。 +func callDriveListPageWithPattern(argsMap map[string]any, pattern string) error { + if deps.Caller.DryRun() { + return deps.Out.PrintJSON(map[string]any{ + "dry_run": true, + "executed": false, + "tool": "list_files", + "arguments": argsMap, + "pattern": pattern, + "note": "pattern 为客户端过滤,仅作用于本页返回条目", + }) + } + text, err := callMCPToolReturnText(context.Background(), "list_files", argsMap) + if err != nil { + return err + } + var body map[string]any + if json.Unmarshal([]byte(text), &body) != nil { + // 非 JSON 返回无法筛,原样输出(与透传分支的兜底形态一致)。 + deps.Out.PrintRaw(text) + return nil + } + target := body + if inner, ok := body["result"].(map[string]any); ok && driveDepthListItemsKey(inner) != "" { + target = inner + } + if key := driveDepthListItemsKey(target); key != "" { + arr, _ := target[key].([]any) + filtered := make([]any, 0, len(arr)) + for _, entry := range arr { + item, ok := entry.(map[string]any) + if !ok { + filtered = append(filtered, entry) + continue + } + name, _ := item["name"].(string) + if name == "" { + name, _ = item["fileName"].(string) + } + if matchDriveNamePattern(name, pattern) { + filtered = append(filtered, entry) + } + } + target[key] = filtered + } + return deps.Out.PrintJSON(body) +} diff --git a/internal/helpers/drive_list_filter_test.go b/internal/helpers/drive_list_filter_test.go new file mode 100644 index 00000000..e4e0e13d --- /dev/null +++ b/internal/helpers/drive_list_filter_test.go @@ -0,0 +1,469 @@ +package helpers + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "strings" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +// drive list --type/--start/--end 客户端过滤测试矩阵(设计稿 §9.3)。 +// 命令级用例统一经 executeDriveListCapture 捕获 stdout JSON; +// 零值 filter 的存量分支行为由 drive_depth_test.go / drive_list_modes_test.go 既有用例兜底。 + +// 钉盘路由(serverID 空)经 resolveProductID 扫 os.Args 推断产品,命令级测试需伪装命令行。 +func useDriveListArgs(t *testing.T) { + t.Helper() + old := os.Args + os.Args = []string{"dws", "drive", "list"} + t.Cleanup(func() { os.Args = old }) +} + +func executeDriveListCapture(t *testing.T, caller edition.ToolCaller, args ...string) (*bytes.Buffer, error) { + t.Helper() + previousDeps := deps + t.Cleanup(func() { deps = previousDeps }) + InitDeps(caller) + buf := &bytes.Buffer{} + deps.Out.w = buf + deps.Out.errW = io.Discard + root := newDriveCommand() + root.SilenceErrors = true + root.SilenceUsage = true + root.SetArgs(args) + err := root.Execute() + return buf, err +} + +// 1. 正向:--workspace --type file → list_nodes 全量拉取后仅 file,单层剥装饰字段。 +func TestCrossPlatformCoverageDriveListFilterWorkspaceTypeFile(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"nodes":[ + {"nodeId":"n1","name":"doc1","nodeType":"doc","updateTime":1754000000000}, + {"nodeId":"n2","name":"sub","nodeType":"folder","updateTime":1754000000000} + ],"hasMore":false}`}, + }} + buf, err := executeDriveListCapture(t, caller, "list", "--workspace", "ws-1", "--type", "file") + if err != nil { + t.Fatal(err) + } + if caller.calls != 1 { + t.Fatalf("calls = %d, want 1(depth=1 退化态不下钻 folder)", caller.calls) + } + result := decodeDepthResult(t, buf) + items := result["items"].([]any) + if len(items) != 1 { + t.Fatalf("filtered items = %#v", items) + } + item := items[0].(map[string]any) + if item["nodeId"] != "n1" { + t.Fatalf("item = %#v", item) + } + for _, key := range []string{"depth", "parentId", "rel_path", "sortTime"} { + if _, ok := item[key]; ok { + t.Fatalf("decoration %q not stripped: %#v", key, item) + } + } + if result["truncated"] != false || result["maxDepth"] != float64(1) { + t.Fatalf("result = %#v", result) + } +} + +// 2. 正向:--workspace --start 7d --end → sortTime 区间断言。 +func TestCrossPlatformCoverageDriveListFilterWorkspaceTimeRange(t *testing.T) { + now := time.Now() + within := now.Add(-24 * time.Hour).UnixMilli() + before := now.Add(-30 * 24 * time.Hour).UnixMilli() + after := now.Add(24 * time.Hour).UnixMilli() + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: fmt.Sprintf(`{"nodes":[ + {"nodeId":"in","name":"within","nodeType":"doc","updateTime":%d}, + {"nodeId":"old","name":"before","nodeType":"doc","updateTime":%d}, + {"nodeId":"new","name":"after","nodeType":"doc","updateTime":%d} + ],"hasMore":false}`, within, before, after)}, + }} + buf, err := executeDriveListCapture(t, caller, + "list", "--workspace", "ws-1", "--start", "7d", "--end", now.Format(time.RFC3339)) + if err != nil { + t.Fatal(err) + } + items := decodeDepthResult(t, buf)["items"].([]any) + if len(items) != 1 || items[0].(map[string]any)["nodeId"] != "in" { + t.Fatalf("time-range items = %#v", items) + } +} + +// 3. 组合:--workspace --depth 2 --type file → BFS 路径 filter 生效,装饰字段保留。 +func TestCrossPlatformCoverageDriveListFilterWithDepthKeepsDecorations(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"nodes":[ + {"nodeId":"fA","name":"dirA","nodeType":"folder"}, + {"nodeId":"n1","name":"doc1","nodeType":"doc"} + ],"hasMore":false}`}, + {text: `{"nodes":[{"nodeId":"n2","name":"doc2","nodeType":"doc"}],"hasMore":false}`}, + }} + buf, err := executeDriveListCapture(t, caller, + "list", "--workspace", "ws-1", "--depth", "2", "--type", "file") + if err != nil { + t.Fatal(err) + } + if caller.calls != 2 { + t.Fatalf("calls = %d, want 2(depth=2 下钻 dirA)", caller.calls) + } + items := decodeDepthResult(t, buf)["items"].([]any) + if len(items) != 2 { + t.Fatalf("filtered items = %#v(folder 应被滤除)", items) + } + for _, raw := range items { + item := raw.(map[string]any) + if _, ok := item["depth"]; !ok { + t.Fatalf("depth>1 装饰字段应保留: %#v", item) + } + } +} + +// 4. 组合:--workspace --type file --latest 3 → filter 先筛后 Top-N; +// 触顶时 LATEST_SCAN_TRUNCATED 拒绝优先于 filter 的 partial 语义。 +func TestCrossPlatformCoverageDriveListFilterWithLatestTopN(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"nodes":[ + {"nodeId":"d1","name":"dir","nodeType":"folder","updateTime":400}, + {"nodeId":"f1","name":"a","nodeType":"doc","updateTime":100}, + {"nodeId":"f2","name":"b","nodeType":"doc","updateTime":300}, + {"nodeId":"f3","name":"c","nodeType":"doc","updateTime":200}, + {"nodeId":"f4","name":"d","nodeType":"doc","updateTime":50} + ],"hasMore":false}`}, + }} + buf, err := executeDriveListCapture(t, caller, + "list", "--workspace", "ws-1", "--type", "file", "--latest", "2") + if err != nil { + t.Fatal(err) + } + items := decodeDepthResult(t, buf)["items"].([]any) + if len(items) != 2 { + t.Fatalf("top-n items = %#v", items) + } + // folder(updateTime 最大)不得进入 Top-N 排序基;筛选后按 sortTime 倒序。 + if items[0].(map[string]any)["nodeId"] != "f2" || items[1].(map[string]any)["nodeId"] != "f3" { + t.Fatalf("top-n order = %#v", items) + } +} + +func TestCrossPlatformCoverageDriveListFilterLatestTruncatedRejected(t *testing.T) { + useDriveListArgs(t) + var sb strings.Builder + sb.WriteString(`{"items":[`) + for i := 0; i < driveDepthMaxItems; i++ { + if i > 0 { + sb.WriteString(",") + } + fmt.Fprintf(&sb, `{"fileId":"f%d","name":"file-%d.txt","type":"FILE","modifyTime":%d}`, i, i, 1000+i) + } + sb.WriteString(`]}`) + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: sb.String()}}} + _, err := executeDriveListCapture(t, caller, "list", "--type", "file", "--latest", "2") + if err == nil || !strings.Contains(err.Error(), "LATEST_SCAN_TRUNCATED") { + t.Fatalf("err = %v, want LATEST_SCAN_TRUNCATED", err) + } +} + +// 5. 钉盘路由:--folder + --type file 单层退化态全量翻页后仅 file; +// --start 7d --latest 3 走 BFS(depth=1);filter 与 cursor/order-by/order/limit/versions 互斥退出码 3。 +func TestCrossPlatformCoverageDriveListFilterPanFolderTypeFile(t *testing.T) { + useDriveListArgs(t) + caller := &depthArgsRecordingCaller{steps: []scriptedToolStep{ + {text: `{"items":[ + {"fileId":"d1","name":"sub","type":"FOLDER"}, + {"fileId":"f1","name":"a.txt","type":"FILE"} + ],"nextToken":"p2"}`}, + {text: `{"items":[{"fileId":"f2","name":"b.txt","type":"FILE"}]}`}, + }} + buf, err := executeDriveListCapture(t, caller, "list", "--folder", "root-1", "--type", "file") + if err != nil { + t.Fatal(err) + } + if len(caller.calls) != 2 { + t.Fatalf("calls = %d, want 2(翻完当前目录两页,sub 不下钻)", len(caller.calls)) + } + if caller.calls[0]["parentId"] != "root-1" || caller.calls[0]["maxResults"] != float64(driveDepthPageSize) { + t.Fatalf("page args = %#v", caller.calls[0]) + } + if caller.calls[1]["nextToken"] != "p2" { + t.Fatalf("page2 args = %#v", caller.calls[1]) + } + items := decodeDepthResult(t, buf)["items"].([]any) + if len(items) != 2 { + t.Fatalf("filtered items = %#v", items) + } + for _, raw := range items { + item := raw.(map[string]any) + if item["type"] != "FILE" { + t.Fatalf("folder leaked: %#v", item) + } + if _, ok := item["depth"]; ok { + t.Fatalf("single-layer decorations not stripped: %#v", item) + } + } +} + +func TestCrossPlatformCoverageDriveListFilterPanStartWithLatest(t *testing.T) { + useDriveListArgs(t) + now := time.Now() + within := now.Add(-24 * time.Hour).UnixMilli() + stale := now.Add(-30 * 24 * time.Hour).UnixMilli() + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: fmt.Sprintf(`{"items":[ + {"fileId":"f1","name":"a.txt","type":"FILE","modifyTime":%d}, + {"fileId":"f2","name":"b.txt","type":"FILE","modifyTime":%d}, + {"fileId":"f3","name":"c.txt","type":"FILE","modifyTime":%d} + ]}`, within-1000, within, stale)}, + }} + buf, err := executeDriveListCapture(t, caller, "list", "--start", "7d", "--latest", "1") + if err != nil { + t.Fatal(err) + } + result := decodeDepthResult(t, buf) + if _, ok := result["truncated"]; !ok { + t.Fatalf("filter+latest 应走 BFS 聚合形态: %#v", result) + } + items := result["items"].([]any) + // --start 7d 先筛掉 stale,Top-1 取 sortTime 最大的 f2。 + if len(items) != 1 || items[0].(map[string]any)["fileId"] != "f2" { + t.Fatalf("items = %#v", items) + } +} + +func TestCrossPlatformCoverageDriveListFilterExclusiveFlags(t *testing.T) { + cases := [][]string{ + {"list", "--type", "file", "--cursor", "c1"}, + {"list", "--type", "file", "--order-by", "name"}, + {"list", "--start", "7d", "--order", "asc"}, + {"list", "--type", "file", "--limit", "5"}, + {"list", "--end", "2026-08-01", "--max", "5"}, + {"list", "--versions", "--node", "n1", "--type", "file"}, + } + for _, args := range cases { + caller := &scriptedToolCaller{} + _, err := executeDriveListCapture(t, caller, args...) + var cliErr *CLIError + if !errors.As(err, &cliErr) || cliErr.ExitCode() != ExitValidation { + t.Fatalf("args %v: err = %v, want CLIError exit 3", args, err) + } + if !strings.Contains(err.Error(), "不能与") || !strings.Contains(err.Error(), "同时使用") { + t.Fatalf("args %v: err = %v, want exclusivity message", args, err) + } + if caller.calls != 0 { + t.Fatalf("args %v: calls = %d, want 0", args, caller.calls) + } + } +} + +// 6. 边界:--type 非法值列合法值;start>end 退出码 3;相对时间解析与 m/毫秒拒绝。 +func TestCrossPlatformCoverageDriveListFilterInvalidValues(t *testing.T) { + caller := &scriptedToolCaller{} + _, err := executeDriveListCapture(t, caller, "list", "--type", "dir") + var cliErr *CLIError + if !errors.As(err, &cliErr) || cliErr.ExitCode() != ExitValidation { + t.Fatalf("--type dir err = %v, want exit 3", err) + } + if !strings.Contains(err.Error(), "file|folder") { + t.Fatalf("--type dir err = %v, want valid values listed", err) + } + + _, err = executeDriveListCapture(t, caller, "list", "--start", "2026-08-10", "--end", "2026-08-01") + if !errors.As(err, &cliErr) || cliErr.ExitCode() != ExitValidation { + t.Fatalf("start>end err = %v, want exit 3", err) + } + if !strings.Contains(err.Error(), "晚于") { + t.Fatalf("start>end err = %v", err) + } + if caller.calls != 0 { + t.Fatalf("calls = %d, want 0", caller.calls) + } +} + +func TestCrossPlatformCoverageDriveListFilterTimeParsing(t *testing.T) { + // 相对时间按本机时钟换算为绝对毫秒。 + for _, tc := range []struct { + raw string + unit time.Duration + }{ + {"24h", time.Hour}, + {"7d", 24 * time.Hour}, + {"2w", 7 * 24 * time.Hour}, + } { + before := time.Now() + ms, err := parseDriveListTime(tc.raw) + after := time.Now() + if err != nil { + t.Fatalf("%s: %v", tc.raw, err) + } + expected := time.Duration(mustAtoi(t, tc.raw[:len(tc.raw)-1])) * tc.unit + lo := before.Add(-expected).UnixMilli() + hi := after.Add(-expected).UnixMilli() + if ms < lo || ms > hi { + t.Fatalf("%s: ms = %d, want in [%d, %d]", tc.raw, ms, lo, hi) + } + } + + // RFC3339 / 无时区 ISO8601(默认 Asia/Shanghai)/ 仅日期。 + shanghai := time.FixedZone("CST", 8*3600) + for _, tc := range []struct { + raw string + want int64 + }{ + {"2026-08-01T00:00:00+08:00", time.Date(2026, 8, 1, 0, 0, 0, 0, shanghai).UnixMilli()}, + {"2026-08-01 08:00:00", time.Date(2026, 8, 1, 8, 0, 0, 0, shanghai).UnixMilli()}, + {"2026-08-01", time.Date(2026, 8, 1, 0, 0, 0, 0, shanghai).UnixMilli()}, + } { + ms, err := parseDriveListTime(tc.raw) + if err != nil || ms != tc.want { + t.Fatalf("%s: ms = %d err = %v, want %d", tc.raw, ms, err, tc.want) + } + } + + // m 单位 / 毫秒时间戳 / 零与负值一律拒绝。 + for _, raw := range []string{"7m", "30m", "1754000000000", "0d", "-3d", "abc"} { + if _, err := parseDriveListTime(raw); err == nil { + t.Fatalf("%q accepted, want rejection", raw) + } + } +} + +func mustAtoi(t *testing.T, s string) int { + t.Helper() + n := 0 + if _, err := fmt.Sscanf(s, "%d", &n); err != nil { + t.Fatalf("atoi %q: %v", s, err) + } + return n +} + +// 7. dry-run:--workspace --type file --dry-run → printDriveDepthDryRun 路径,不发起调用。 +func TestCrossPlatformCoverageDriveListFilterDryRun(t *testing.T) { + caller := &scriptedToolCaller{format: "json", dry: true} + buf, err := executeDriveListCapture(t, caller, "list", "--workspace", "ws-1", "--type", "file") + if err != nil { + t.Fatal(err) + } + if caller.calls != 0 { + t.Fatalf("dry-run calls = %d", caller.calls) + } + result := decodeDepthResult(t, buf) + if result["dry_run"] != true || result["tool"] != "list_nodes" || result["maxDepth"] != float64(1) { + t.Fatalf("dry-run payload = %#v", result) + } +} + +// 8. 触顶:2000 条上限 → partial + truncated=true + 退出码 0(filter 触顶结果不全但不会错)。 +func TestCrossPlatformCoverageDriveListFilterTruncatedPartial(t *testing.T) { + useDriveListArgs(t) + var sb strings.Builder + sb.WriteString(`{"items":[`) + for i := 0; i < driveDepthMaxItems; i++ { + if i > 0 { + sb.WriteString(",") + } + fmt.Fprintf(&sb, `{"fileId":"f%d","name":"file-%d.txt","type":"FILE","modifyTime":%d}`, i, i, 1000+i) + } + sb.WriteString(`]}`) + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: sb.String()}}} + buf, err := executeDriveListCapture(t, caller, "list", "--type", "file") + if err != nil { + t.Fatalf("filter 触顶应放行(退出码 0): %v", err) + } + result := decodeDepthResult(t, buf) + if result["truncated"] != true { + t.Fatalf("truncated = %#v", result["truncated"]) + } + if got := len(result["items"].([]any)); got != driveDepthMaxItems { + t.Fatalf("items len = %d, want %d(全 FILE 类型筛全保留)", got, driveDepthMaxItems) + } +} + +// 9. 回归:不带 filter 的存量分支行为不变;--pattern 单层钉盘页内过滤为本次有意修复。 +func TestCrossPlatformCoverageDriveListFilterRegressionUnaffectedPaths(t *testing.T) { + useDriveListArgs(t) + // 单层纯透传(无 filter/pattern):body 原样输出,args 语义不变。 + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"items":[{"fileId":"f1","name":"a.txt"}],"nextToken":"nt"}`}, + }} + buf, err := executeDriveListCapture(t, caller, "list") + if err != nil { + t.Fatal(err) + } + result := decodeDepthResult(t, buf) + if result["nextToken"] != "nt" || len(result["items"].([]any)) != 1 { + t.Fatalf("透传 body 被改变: %#v", result) + } + if _, ok := result["truncated"]; ok { + t.Fatalf("透传形态不应含 BFS 聚合字段: %#v", result) + } + if caller.args["maxResults"] != float64(20) { + t.Fatalf("args = %#v", caller.args) + } + + // workspace 单层透传:无 filter/depth/latest 时仍走单页 list_nodes。 + caller2 := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"nodes":[{"nodeId":"n1","name":"doc1","nodeType":"doc"}],"hasMore":false}`}, + }} + buf2, err := executeDriveListCapture(t, caller2, "list", "--workspace", "ws-1") + if err != nil { + t.Fatal(err) + } + result2 := decodeDepthResult(t, buf2) + if _, ok := result2["nodes"]; !ok { + t.Fatalf("workspace 单层透传形态被改变: %#v", result2) + } + if caller2.args["pageSize"] != 20 || caller2.args["workspaceId"] != "ws-1" { + t.Fatalf("args = %#v", caller2.args) + } + + // 钉盘单层 --latest(无 filter):仍走 runDriveListLatest(非 BFS 聚合形态)。 + now := time.Now().UnixMilli() + caller3 := &depthArgsRecordingCaller{steps: []scriptedToolStep{ + {text: fmt.Sprintf(`{"items":[{"fileId":"f1","name":"a.txt","type":"FILE","modifyTime":%d}],"nextToken":""}`, now)}, + }} + buf3, err := executeDriveListCapture(t, caller3, "list", "--latest", "1") + if err != nil { + t.Fatal(err) + } + result3 := decodeDepthResult(t, buf3) + if _, ok := result3["truncated"]; ok { + t.Fatalf("单层 latest 不应切到 BFS 聚合形态: %#v", result3) + } + if len(result3["items"].([]any)) != 1 { + t.Fatalf("items = %#v", result3["items"]) + } + if caller3.calls[0]["orderBy"] != "modifyTime" || caller3.calls[0]["order"] != "desc" { + t.Fatalf("latest 扫描参数被改变: %#v", caller3.calls[0]) + } +} + +func TestCrossPlatformCoverageDriveListPatternSingleLayerFixed(t *testing.T) { + useDriveListArgs(t) + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"items":[ + {"fileId":"f1","name":"a.txt"}, + {"fileId":"f2","name":"b.md"} + ],"nextToken":"nt"}`}, + }} + buf, err := executeDriveListCapture(t, caller, "list", "--pattern", "*.txt") + if err != nil { + t.Fatal(err) + } + result := decodeDepthResult(t, buf) + items := result["items"].([]any) + if len(items) != 1 || items[0].(map[string]any)["fileId"] != "f1" { + t.Fatalf("pattern 页内过滤未生效: %#v", items) + } + if result["nextToken"] != "nt" { + t.Fatalf("分页字段应保留: %#v", result) + } +} diff --git a/internal/helpers/pr868_coverage_gaps_test.go b/internal/helpers/pr868_coverage_gaps_test.go index cc34a257..5b77a200 100644 --- a/internal/helpers/pr868_coverage_gaps_test.go +++ b/internal/helpers/pr868_coverage_gaps_test.go @@ -674,7 +674,7 @@ func TestCrossPlatformCoverageDriveDepthLatestTruncatedAndSortTime(t *testing.T) caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: sb.String()}}} out := installDepthCaller(t, caller) cmd := &cobra.Command{Use: "list"} - err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 2) + err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 2, driveListFilter{}) if err == nil || !strings.Contains(err.Error(), "LATEST_SCAN_TRUNCATED") { t.Fatalf("err=%v, want LATEST_SCAN_TRUNCATED", err) } diff --git a/skills/mono/references/products/drive.md b/skills/mono/references/products/drive.md index 80e7e3ed..96fceca4 100644 --- a/skills/mono/references/products/drive.md +++ b/skills/mono/references/products/drive.md @@ -34,6 +34,7 @@ Usage: Example: dws drive list --limit 20 dws drive list --limit 20 --folder --order-by name --order asc + dws drive list --folder --type file --start 7d Flags: --limit int 每页返回数量,默认 20,最大 50 (可选) --cursor string 分页游标,首次不传 (可选) @@ -43,10 +44,30 @@ Flags: --space-id string 钉盘空间 ID (纯数字),不传则使用「我的文件」对应 spaceId (可选) --workspace string 文档空间/知识库 ID (加密 string 或 URL),传入则路由到文档空间 (可选) --thumbnail 是否返回缩略图信息 (可选,仅钉盘) + --pattern string 按名称通配过滤结果,如 "*日报*"(客户端过滤,无通配符时按子串匹配)(可选) + --depth int 递归列出子目录层级,默认 1(仅当前层),最大 5;与 --cursor/--limit 互斥 (可选) + --type string 按节点类型过滤: file|folder(客户端过滤,见下节)(可选) + --start string 按修改时间过滤·起始,如 7d / 2026-08-01 / RFC3339 (可选) + --end string 按修改时间过滤·截止,语法同 --start (可选) ``` > 统一入口:默认列钉盘空间(`--space-id` 纯数字);传 `--workspace` 时路由到文档空间/知识库列表。 +类型/时间过滤(`--type` / `--start` / `--end`): +- 语义:`--type` 按节点类型(file=文件 / folder=文件夹);`--start`/`--end` 按**修改时间**圈定区间。 + 注意与 `dws drive search` 的 `--modified-from/--modified-to` 区分:那两个收毫秒时间戳,这里收字符串语法; + `--type`(节点类型)与 search 的 `--file-types`(内容类型 alidoc/image/...)也不是一回事。 +- 时间语法:相对时间 `24h`/`7d`/`2w`(小时/天/周,按本机时钟换算)、RFC3339(`2026-08-01T00:00:00+08:00`)、 + 无时区 ISO8601(`2026-08-01 08:00:00`,默认 Asia/Shanghai)、仅日期(`2026-08-01`); + 不支持毫秒时间戳,不支持 `m` 单位。 +- 执行方式:钉盘与知识库(--workspace)两路由统一为**客户端过滤**——全量扫描当前目录后在进程内筛选; + 与 `--depth>1` 组合时递归扫描后筛(被滤掉的条目仍占 2000 条全局上限)。 +- 互斥:与 `--versions`/`--cursor`/`--order-by`/`--order`/`--limit` 不能同时使用(过滤模式为全量扫描, + 无游标与服务端排序语义);可与 `--latest`/`--pattern`/`--depth` 组合,`+--latest` 表示「符合条件的条目中最新 N 个」。 +- 输出形态:带过滤时输出从单页透传变为聚合形态 `{items, maxDepth, truncated, errors}`。 +- 已知代价:大目录(>2000 条)触顶截断时 `truncated=true`(退出码 0,结果每条都正确但没扫完); + 建议用 `--folder` 指定子目录缩小扫描范围;带关键词的过滤场景改用 `dws drive search`。 + ### 获取钉盘空间列表 > **Deprecated**:推荐改用 `dws wiki space list --type orgSpace` / `--type mySpace`(见 [wiki.md](./wiki.md))。本命令仍可用,仅作兼容保留。 diff --git a/skills/multi/dingtalk-drive/references/drive.md b/skills/multi/dingtalk-drive/references/drive.md index 29680fa2..21a7534d 100644 --- a/skills/multi/dingtalk-drive/references/drive.md +++ b/skills/multi/dingtalk-drive/references/drive.md @@ -30,6 +30,7 @@ Usage: Example: dws drive list --limit 20 dws drive list --limit 20 --folder --order-by name --order asc + dws drive list --folder --type file --start 7d Flags: --limit int 每页返回数量,默认 20,最大 50 (可选) --cursor string 分页游标,首次不传 (可选) @@ -38,8 +39,28 @@ Flags: --folder string 父节点 ID (dentryUuid),不传则列出空间根目录 (可选) --space-id string 空间 ID,不传则使用「我的文件」对应 spaceId (可选) --thumbnail 是否返回缩略图信息 (可选) + --pattern string 按名称通配过滤结果,如 "*日报*"(客户端过滤,无通配符时按子串匹配)(可选) + --depth int 递归列出子目录层级,默认 1(仅当前层),最大 5;与 --cursor/--limit 互斥 (可选) + --type string 按节点类型过滤: file|folder(客户端过滤,见下节)(可选) + --start string 按修改时间过滤·起始,如 7d / 2026-08-01 / RFC3339 (可选) + --end string 按修改时间过滤·截止,语法同 --start (可选) ``` +类型/时间过滤(`--type` / `--start` / `--end`): +- 语义:`--type` 按节点类型(file=文件 / folder=文件夹);`--start`/`--end` 按**修改时间**圈定区间。 + 注意与 `dws drive search` 的 `--modified-from/--modified-to` 区分:那两个收毫秒时间戳,这里收字符串语法; + `--type`(节点类型)与 search 的 `--file-types`(内容类型 alidoc/image/...)也不是一回事。 +- 时间语法:相对时间 `24h`/`7d`/`2w`(小时/天/周,按本机时钟换算)、RFC3339(`2026-08-01T00:00:00+08:00`)、 + 无时区 ISO8601(`2026-08-01 08:00:00`,默认 Asia/Shanghai)、仅日期(`2026-08-01`); + 不支持毫秒时间戳,不支持 `m` 单位。 +- 执行方式:钉盘与知识库(--workspace)两路由统一为**客户端过滤**——全量扫描当前目录后在进程内筛选; + 与 `--depth>1` 组合时递归扫描后筛(被滤掉的条目仍占 2000 条全局上限)。 +- 互斥:与 `--versions`/`--cursor`/`--order-by`/`--order`/`--limit` 不能同时使用(过滤模式为全量扫描, + 无游标与服务端排序语义);可与 `--latest`/`--pattern`/`--depth` 组合,`+--latest` 表示「符合条件的条目中最新 N 个」。 +- 输出形态:带过滤时输出从单页透传变为聚合形态 `{items, maxDepth, truncated, errors}`。 +- 已知代价:大目录(>2000 条)触顶截断时 `truncated=true`(退出码 0,结果每条都正确但没扫完); + 建议用 `--folder` 指定子目录缩小扫描范围;带关键词的过滤场景改用 `dws drive search`。 + ### 获取钉盘空间列表 ``` From 647bdb251c5240cc5b821997751c90a262048f96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=B7=E5=BF=83=E8=8F=9C?= Date: Tue, 11 Aug 2026 11:31:25 +0800 Subject: [PATCH 54/86] test(drive): cover drive list filter/pattern edge branches --- internal/helpers/drive_list_filter_test.go | 95 ++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/internal/helpers/drive_list_filter_test.go b/internal/helpers/drive_list_filter_test.go index e4e0e13d..6e56912d 100644 --- a/internal/helpers/drive_list_filter_test.go +++ b/internal/helpers/drive_list_filter_test.go @@ -467,3 +467,98 @@ func TestCrossPlatformCoverageDriveListPatternSingleLayerFixed(t *testing.T) { t.Fatalf("分页字段应保留: %#v", result) } } + +// 10. 钉盘路由:--type folder 反向筛(FILE 被滤除,仅 FOLDER 保留)。 +func TestCrossPlatformCoverageDriveListFilterPanFolderTypeFolder(t *testing.T) { + useDriveListArgs(t) + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"items":[ + {"fileId":"d1","name":"sub","type":"FOLDER"}, + {"fileId":"f1","name":"a.txt","type":"FILE"} + ]}`}, + }} + buf, err := executeDriveListCapture(t, caller, "list", "--folder", "root-1", "--type", "folder") + if err != nil { + t.Fatal(err) + } + if caller.calls != 1 { + t.Fatalf("calls = %d, want 1(depth=1 退化态不下钻 folder)", caller.calls) + } + items := decodeDepthResult(t, buf)["items"].([]any) + if len(items) != 1 || items[0].(map[string]any)["fileId"] != "d1" { + t.Fatalf("folder filter items = %#v", items) + } +} + +// 11. 钉盘路由:--start 下无时间信息的条目(sortTime<=0)不可判定,保守滤除。 +func TestCrossPlatformCoverageDriveListFilterPanMissingTimeDropped(t *testing.T) { + useDriveListArgs(t) + now := time.Now().UnixMilli() + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: fmt.Sprintf(`{"items":[ + {"fileId":"f1","name":"a.txt","type":"FILE","modifyTime":%d}, + {"fileId":"f2","name":"no-time.txt","type":"FILE"} + ]}`, now)}, + }} + buf, err := executeDriveListCapture(t, caller, "list", "--start", "7d") + if err != nil { + t.Fatal(err) + } + items := decodeDepthResult(t, buf)["items"].([]any) + if len(items) != 1 || items[0].(map[string]any)["fileId"] != "f1" { + t.Fatalf("missing-time 条目应被滤除: %#v", items) + } +} + +// 12. 单层钉盘 --pattern 页内过滤边界:dry-run 预览 / MCP 错误透传 / 非 JSON 原样输出 / +// 非 map 条目保留 + name 缺失时 fileName 兜底匹配。 +func TestCrossPlatformCoverageDriveListPatternPassthroughEdges(t *testing.T) { + useDriveListArgs(t) + + // dry-run:打印预览,不发起调用。 + dryCaller := &scriptedToolCaller{dry: true} + buf, err := executeDriveListCapture(t, dryCaller, "list", "--pattern", "*.txt") + if err != nil { + t.Fatal(err) + } + if dryCaller.calls != 0 { + t.Fatalf("dry-run calls = %d", dryCaller.calls) + } + preview := decodeDepthResult(t, buf) + if preview["dry_run"] != true || preview["tool"] != "list_files" || preview["pattern"] != "*.txt" { + t.Fatalf("dry-run payload = %#v", preview) + } + + // MCP 错误:原样返回。 + errCaller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("boom")}}} + if _, err := executeDriveListCapture(t, errCaller, "list", "--pattern", "*.txt"); err == nil || !strings.Contains(err.Error(), "boom") { + t.Fatalf("err = %v, want boom", err) + } + + // 非 JSON 返回:无法筛,原样输出(与透传分支兜底形态一致)。 + rawCaller := &scriptedToolCaller{steps: []scriptedToolStep{{text: "plain-text-result"}}} + rawBuf, err := executeDriveListCapture(t, rawCaller, "list", "--pattern", "*.txt") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(rawBuf.String(), "plain-text-result") { + t.Fatalf("raw output = %q", rawBuf.String()) + } + + // 非 map 条目原样保留;name 缺失时 fileName 兜底参与匹配。 + mixedCaller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"items":[ + "raw-entry", + {"fileId":"f1","fileName":"日报模板.doc"}, + {"fileId":"f2","name":"其他.txt"} + ],"nextToken":"nt"}`}, + }} + mixedBuf, err := executeDriveListCapture(t, mixedCaller, "list", "--pattern", "*模板*") + if err != nil { + t.Fatal(err) + } + mixed := decodeDepthResult(t, mixedBuf)["items"].([]any) + if len(mixed) != 2 || mixed[0] != "raw-entry" || mixed[1].(map[string]any)["fileId"] != "f1" { + t.Fatalf("items = %#v", mixed) + } +} From 273a3ab5ddda0a5bc080605a09f9598146b0882d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=B7=E5=BF=83=E8=8F=9C?= Date: Thu, 13 Aug 2026 11:30:38 +0800 Subject: [PATCH 55/86] chore: migrate drive list changelog entries to release fragments --- .changes/942-drive-list-filter.md | 12 ++++++++++++ .changes/942-drive-list-pattern-fix.md | 7 +++++++ CHANGELOG.md | 11 ----------- 3 files changed, 19 insertions(+), 11 deletions(-) create mode 100644 .changes/942-drive-list-filter.md create mode 100644 .changes/942-drive-list-pattern-fix.md diff --git a/.changes/942-drive-list-filter.md b/.changes/942-drive-list-filter.md new file mode 100644 index 00000000..06deb44e --- /dev/null +++ b/.changes/942-drive-list-filter.md @@ -0,0 +1,12 @@ +--- +category: Added +--- + +- **Drive list type/time filtering** (#942) — `dws drive list` gains `--type + file|folder`, `--start`, and `--end` for client-side filtering by node type + and modification time on both the pan and workspace routes. Filtering runs + a bounded full scan of the target directory (2000-entry cap, reported via + `truncated=true`), composes with `--latest`/`--pattern`/`--depth`, and is + mutually exclusive with `--versions`/`--cursor`/`--order-by`/`--order`/ + `--limit`. Time values accept relative forms (`24h`/`7d`/`2w`), RFC 3339, + zone-less ISO 8601 (Asia/Shanghai), or a plain date. diff --git a/.changes/942-drive-list-pattern-fix.md b/.changes/942-drive-list-pattern-fix.md new file mode 100644 index 00000000..5115bd45 --- /dev/null +++ b/.changes/942-drive-list-pattern-fix.md @@ -0,0 +1,7 @@ +--- +category: Fixed +--- + +- **Drive list pattern filtering** (#942) — `dws drive list --pattern` on the + single-layer pan route now filters the returned page by name pattern; the + flag was previously accepted but silently ignored. diff --git a/CHANGELOG.md b/CHANGELOG.md index bfb18edc..7d1d23e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,14 +83,6 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th - **Personal OA approval events** — personal event consumers now support task creation, completion, redirection, instance start, termination, and completion events, with typed output and matching usage documentation. -- **Drive list type/time filtering** — `dws drive list` gains `--type - file|folder`, `--start`, and `--end` for client-side filtering by node type - and modification time on both the pan and workspace routes. Filtering runs - a bounded full scan of the target directory (2000-entry cap, reported via - `truncated=true`), composes with `--latest`/`--pattern`/`--depth`, and is - mutually exclusive with `--versions`/`--cursor`/`--order-by`/`--order`/ - `--limit`. Time values accept relative forms (`24h`/`7d`/`2w`), RFC 3339, - zone-less ISO 8601 (Asia/Shanghai), or a plain date. ### Fixed @@ -111,9 +103,6 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th stale OAuth profile. Detached buses negotiate an owner-only, memory-only IPC credential channel; tokens are never placed in child argv, environment, profiles, logs, or run-state files. -- **Drive list pattern filtering** — `dws drive list --pattern` on the - single-layer pan route now filters the returned page by name pattern; the - flag was previously accepted but silently ignored. ### Changed From b525497da8987c7988478a7caab841e10a5704d7 Mon Sep 17 00:00:00 2001 From: chichuan Date: Thu, 13 Aug 2026 14:31:50 +0800 Subject: [PATCH 56/86] fix: pass release seal classification to policy --- .github/workflows/ci.yml | 1 + test/scripts/changelog_pr_gate_test.go | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53ed6bdd..2b3a3a4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1318,6 +1318,7 @@ jobs: env: CLASSIFIED_CHANGELOG_CHANGED: ${{ needs.lint.outputs.changelog_changed }} CHANGELOG_ONLY: ${{ needs.lint.outputs.changelog_only }} + RELEASE_SEAL_ONLY: ${{ needs.lint.outputs.release_seal_only }} PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | set -eu diff --git a/test/scripts/changelog_pr_gate_test.go b/test/scripts/changelog_pr_gate_test.go index dcf398e7..ec327062 100644 --- a/test/scripts/changelog_pr_gate_test.go +++ b/test/scripts/changelog_pr_gate_test.go @@ -1019,6 +1019,15 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) { } admission := readWorkflow(".github/workflows/ci.yml") + policyStart := strings.Index(admission, "\n policy:\n") + policyEnd := strings.Index(admission, "\n interface-integrity:\n") + if policyStart < 0 || policyEnd <= policyStart { + t.Fatal("Code Admission workflow missing Policy job boundaries") + } + policyJob := admission[policyStart:policyEnd] + if strings.Count(policyJob, "RELEASE_SEAL_ONLY: ${{ needs.lint.outputs.release_seal_only }}") != 2 { + t.Error("Policy job must pass release-seal classification to both PR and main metadata validators") + } for _, want := range []string{ "name: CI", "files.length === 1", From 45a80185f629864cb6a029ea7642f416a45f1b97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Thu, 13 Aug 2026 14:42:26 +0800 Subject: [PATCH 57/86] fix(chat): pass explicit list-all times through --- .changes/973-chat-message-time-defaults.md | 2 +- internal/helpers/chat.go | 16 +---- .../helpers/chat_message_pagination_test.go | 59 ++++++++++--------- 3 files changed, 36 insertions(+), 41 deletions(-) diff --git a/.changes/973-chat-message-time-defaults.md b/.changes/973-chat-message-time-defaults.md index 1a6e5496..fbe6ba2c 100644 --- a/.changes/973-chat-message-time-defaults.md +++ b/.changes/973-chat-message-time-defaults.md @@ -2,4 +2,4 @@ category: Fixed --- -- **Chat message time defaults** (#973) — validates `chat message list-all` time ranges locally and defaults omitted message-query time bounds without creating reversed windows. +- **Chat message time defaults** (#973) — defaults omitted message-query time bounds without creating reversed windows. diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 34a45d0f..c1c7e672 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -471,20 +471,10 @@ func chatMessageListAllArgs(cmd *cobra.Command) (map[string]any, error) { if err != nil { return nil, err } - startMs, err := parseISOTimeToMillis("start", startRaw) - if err != nil { - return nil, err - } - endMs, _ := parseISOTimeToMillis("end", endRaw) - if err := validateTimeRange(startMs, endMs); err != nil { - return nil, err - } - startTime := formatChatMessageListAllTime(startMs) - endTime := formatChatMessageListAllTime(endMs) cursor, _ := cmd.Flags().GetString("cursor") return map[string]any{ - "startTime": startTime, - "endTime": endTime, + "startTime": startRaw, + "endTime": endRaw, "limit": chatIntFlagOrFallback(cmd, "limit", "size"), "cursor": cursor, }, nil @@ -608,7 +598,7 @@ func defaultChatMessageTimeRange(cmd *cobra.Command, lookback time.Duration) (st anchor := time.Now() if endRaw == "" { endRaw = anchor.Format(time.RFC3339) - } else { + } else if startRaw == "" { endMs, err := parseISOTimeToMillis("end", endRaw) if err != nil { return "", "", err diff --git a/internal/helpers/chat_message_pagination_test.go b/internal/helpers/chat_message_pagination_test.go index 61ad4c2f..f373e130 100644 --- a/internal/helpers/chat_message_pagination_test.go +++ b/internal/helpers/chat_message_pagination_test.go @@ -83,12 +83,7 @@ func TestChatMessagePaginationDefaultSinglePageUnchanged(t *testing.T) { args: []string{"message", "list-all", "--start", "2026-08-01 00:00:00", "--end", "2026-08-02 00:00:00"}, server: "", tool: "search_messages_by_time_range", - want: map[string]any{ - "startTime": formatChatMessageListAllTime(1785513600000), - "endTime": formatChatMessageListAllTime(1785600000000), - "limit": 50, - "cursor": "0", - }, + want: map[string]any{"startTime": "2026-08-01 00:00:00", "endTime": "2026-08-02 00:00:00", "limit": 50, "cursor": "0"}, }, { name: "list-by-sender", @@ -281,8 +276,8 @@ func TestChatMessagePaginationDefaultsStartFromExplicitEnd(t *testing.T) { t.Fatalf("startTime = %d, want %d", startMs, wantStartMs) } if tt.wantTool == "search_messages_by_time_range" { - assertStringArg(t, got.args["startTime"], formatChatMessageListAllTime(wantStartMs)) - assertStringArg(t, got.args["endTime"], formatChatMessageListAllTime(endMs)) + assertStringArg(t, got.args["startTime"], time.UnixMilli(wantStartMs).Format(time.RFC3339)) + assertStringArg(t, got.args["endTime"], endRaw) } }) } @@ -348,28 +343,31 @@ func TestChatMessagePaginationDefaultsEndFromNowWhenOnlyStartProvided(t *testing t.Fatalf("endTime = %d, want between %d and %d", endMs, wantEndMin, wantEndMax) } if tt.wantTool == "search_messages_by_time_range" { - assertStringArg(t, got.args["startTime"], formatChatMessageListAllTime(startMs)) + assertStringArg(t, got.args["startTime"], startRaw) parseChatMessageListAllTimeArg(t, got.args["endTime"]) } }) } } -func TestChatMessageListAllRejectsInvalidTimeRanges(t *testing.T) { +func TestChatMessageListAllPassesExplicitTimeBoundsThrough(t *testing.T) { tests := []struct { - name string - args []string - wantErr string + name string + args []string + wantStart string + wantEnd string }{ { - name: "invalid start", - args: []string{"message", "list-all", "--start", "not-a-time", "--end", "2020-01-01T00:00:00+08:00"}, - wantErr: "cannot parse time for --start", + name: "invalid start", + args: []string{"message", "list-all", "--start", "not-a-time", "--end", "2020-01-01T00:00:00+08:00"}, + wantStart: "not-a-time", + wantEnd: "2020-01-01T00:00:00+08:00", }, { - name: "end before start", - args: []string{"message", "list-all", "--start", "2021-01-01T00:00:00+08:00", "--end", "2020-01-01T00:00:00+08:00"}, - wantErr: "--end must be after --start", + name: "end before start", + args: []string{"message", "list-all", "--start", "2021-01-01T00:00:00+08:00", "--end", "2020-01-01T00:00:00+08:00"}, + wantStart: "2021-01-01T00:00:00+08:00", + wantEnd: "2020-01-01T00:00:00+08:00", }, } @@ -377,15 +375,18 @@ func TestChatMessageListAllRejectsInvalidTimeRanges(t *testing.T) { t.Run(tt.name, func(t *testing.T) { caller := &chatMessagePaginationCaller{} _, err := executeChatMessagePaginationCommand(t, caller, tt.args...) - if err == nil { - t.Fatal("command succeeded, want validation error") + if err != nil { + t.Fatal(err) } - if !strings.Contains(err.Error(), tt.wantErr) { - t.Fatalf("error = %v, want containing %q", err, tt.wantErr) + if len(caller.calls) != 1 { + t.Fatalf("calls = %#v, want one MCP call", caller.calls) } - if len(caller.calls) != 0 { - t.Fatalf("calls = %#v, want no MCP call", caller.calls) + got := caller.calls[0] + if got.tool != "search_messages_by_time_range" { + t.Fatalf("tool = %q, want search_messages_by_time_range", got.tool) } + assertStringArg(t, got.args["startTime"], tt.wantStart) + assertStringArg(t, got.args["endTime"], tt.wantEnd) }) } } @@ -423,10 +424,14 @@ func parseChatMessageListAllTimeArg(t *testing.T, value any) time.Time { t.Helper() raw, ok := value.(string) if !ok { - t.Fatalf("time arg = %#v, want yyyy-MM-dd HH:mm:ss string", value) + t.Fatalf("time arg = %#v, want time string", value) } if strings.Contains(raw, "T") { - t.Fatalf("time arg = %q, want yyyy-MM-dd HH:mm:ss without RFC3339 separator", raw) + parsedMs, err := parseISOTimeToMillis("time", raw) + if err != nil { + t.Fatalf("time arg = %q, parse err = %v", raw, err) + } + return time.UnixMilli(parsedMs) } parsed, err := time.ParseInLocation("2006-01-02 15:04:05", raw, time.Local) if err != nil { From e0c9b4910d2d24de915c840137d7c77a475d4153 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Thu, 13 Aug 2026 14:57:51 +0800 Subject: [PATCH 58/86] fix: align package verifier with Agent skill roots --- scripts/release/verify-package-managers.sh | 85 +++++++++++++++------- test/scripts/package_script_test.go | 58 ++++++++++++++- 2 files changed, 114 insertions(+), 29 deletions(-) diff --git a/scripts/release/verify-package-managers.sh b/scripts/release/verify-package-managers.sh index 631286d7..71b0d8c0 100755 --- a/scripts/release/verify-package-managers.sh +++ b/scripts/release/verify-package-managers.sh @@ -54,7 +54,7 @@ while [ "$#" -gt 0 ]; do done TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-package-verify-XXXXXX")" -HOME_AGENT_PARENTS=" +HOME_SPECIFIC_AGENT_PARENTS=" .claude .cursor .qoder @@ -72,8 +72,7 @@ HOME_AGENT_PARENTS=" .openclaw .hermes " -HOME_SKILL_BASES=" -.agents/skills +HOME_SPECIFIC_SKILL_BASES=" .claude/skills .cursor/skills .qoder/skills @@ -102,43 +101,55 @@ cleanup() { } trap cleanup EXIT INT TERM -seed_agent_homes() { +seed_specific_agent_homes() { home_root="$1" - for parent in $HOME_AGENT_PARENTS; do + for parent in $HOME_SPECIFIC_AGENT_PARENTS; do mkdir -p "$home_root/$parent" done } +verify_skill_base() { + home_root="$1" + base="$2" + need_file "$home_root/$base/dingtalk-shared/SKILL.md" + need_file "$home_root/$base/dingtalk-misc/SKILL.md" + [ ! -e "$home_root/$base/dws" ] || err "unexpected mono Skill layout found in $home_root/$base/dws" +} + verify_skill_targets() { home_root="$1" - for base in $HOME_SKILL_BASES; do - need_file "$home_root/$base/dingtalk-shared/SKILL.md" - need_file "$home_root/$base/dingtalk-misc/SKILL.md" - [ ! -e "$home_root/$base/dws" ] || err "unexpected mono Skill layout found in $home_root/$base/dws" + found_specific=0 + for base in $HOME_SPECIFIC_SKILL_BASES; do + parent="${base%/skills}" + if [ -d "$home_root/$parent" ]; then + verify_skill_base "$home_root" "$base" + found_specific=1 + fi + done + + if [ "$found_specific" -eq 0 ]; then + verify_skill_base "$home_root" ".agents/skills" + return + fi + + for name in dingtalk-shared dingtalk-misc dws; do + [ ! -e "$home_root/.agents/skills/$name" ] || \ + err "unexpected generic Skill copy found in $home_root/.agents/skills/$name" done } -verify_npm() { - need_cmd npm - need_cmd node - need_cmd tar - need_cmd unzip - need_file "$NPM_STAGE_DIR/package.json" - - npm_home="$TMP_ROOT/npm-home" - npm_prefix="$TMP_ROOT/npm-prefix" - npm_cache="$TMP_ROOT/npm-cache" +verify_npm_install() { + tarball_path="$1" + scenario="$2" + npm_home="$TMP_ROOT/npm-home-$scenario" + npm_prefix="$TMP_ROOT/npm-prefix-$scenario" + npm_cache="$TMP_ROOT/npm-cache-$scenario" mkdir -p "$npm_home" "$npm_prefix" "$npm_cache" - seed_agent_homes "$npm_home" - - say "==> verifying npm package install" - tarball_name="$( - cd "$NPM_STAGE_DIR" - HOME="$npm_home" npm_config_cache="$npm_cache" npm pack --silent - )" - tarball_path="$NPM_STAGE_DIR/$tarball_name" - need_file "$tarball_path" + if [ "$scenario" = "specific-agent-roots" ]; then + seed_specific_agent_homes "$npm_home" + fi + say "==> verifying npm package install ($scenario)" HOME="$npm_home" npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \ npm install -g "$tarball_path" >/dev/null @@ -158,6 +169,24 @@ verify_npm() { HOME="$npm_home" npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \ npm uninstall -g dingtalk-workspace-cli >/dev/null +} + +verify_npm() { + need_cmd npm + need_cmd node + need_cmd tar + need_cmd unzip + need_file "$NPM_STAGE_DIR/package.json" + + tarball_name="$( + cd "$NPM_STAGE_DIR" + HOME="$TMP_ROOT/npm-pack-home" npm_config_cache="$TMP_ROOT/npm-pack-cache" npm pack --silent + )" + tarball_path="$NPM_STAGE_DIR/$tarball_name" + need_file "$tarball_path" + + verify_npm_install "$tarball_path" "specific-agent-roots" + verify_npm_install "$tarball_path" "generic-fallback" rm -f "$tarball_path" } diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index d02d5634..62b51c6c 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -70,10 +70,12 @@ func TestPackageManagerVersionVerificationReadsRawBinary(t *testing.T) { t.Fatal("package-manager verifier still requires the version marker to occupy a strings(1) line") } for _, want := range []string{ - "HOME_SKILL_BASES=", + "HOME_SPECIFIC_SKILL_BASES=", `$base/dingtalk-shared/SKILL.md`, `$base/dingtalk-misc/SKILL.md`, "unexpected mono Skill layout", + `verify_npm_install "$tarball_path" "specific-agent-roots"`, + `verify_npm_install "$tarball_path" "generic-fallback"`, } { if !strings.Contains(script, want) { t.Errorf("package-manager verifier is missing multi-layout contract %q", want) @@ -84,6 +86,60 @@ func TestPackageManagerVersionVerificationReadsRawBinary(t *testing.T) { } } +func TestPackageManagerVerifierCoversSpecificAndFallbackSkillRoots(t *testing.T) { + t.Parallel() + + postGoreleaserPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "post-goreleaser.sh")) + if err != nil { + t.Fatalf("Abs(post-goreleaser.sh) error = %v", err) + } + verifierPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "verify-package-managers.sh")) + if err != nil { + t.Fatalf("Abs(verify-package-managers.sh) error = %v", err) + } + + distDir := filepath.Join(t.TempDir(), "dist") + targets := []string{ + "dws-darwin-amd64.tar.gz", + "dws-darwin-arm64.tar.gz", + "dws-linux-amd64.tar.gz", + "dws-linux-arm64.tar.gz", + } + hostArchive := "dws-" + runtime.GOOS + "-" + runtime.GOARCH + ".tar.gz" + if runtime.GOOS == "windows" { + hostArchive = "dws-" + runtime.GOOS + "-" + runtime.GOARCH + ".zip" + } + foundHost := false + for _, target := range targets { + if target == hostArchive { + foundHost = true + break + } + } + if !foundHost { + targets = append(targets, hostArchive) + } + seedDistArtifacts(t, distDir, targets) + + packageCmd := exec.Command("sh", postGoreleaserPath) + packageCmd.Env = postGoreleaserEnv(t, distDir, "v0.0.0-test", "https://downloads.example.com/dws/releases/v0.0.0-test") + if output, err := packageCmd.CombinedOutput(); err != nil { + t.Fatalf("post-goreleaser.sh error = %v\noutput:\n%s", err, output) + } + + verifyCmd := exec.Command("sh", verifierPath, "--npm-only") + verifyCmd.Env = append(os.Environ(), "DWS_PACKAGE_DIST_DIR="+distDir) + output, err := verifyCmd.CombinedOutput() + if err != nil { + t.Fatalf("verify-package-managers.sh error = %v\noutput:\n%s", err, output) + } + for _, scenario := range []string{"specific-agent-roots", "generic-fallback"} { + if !strings.Contains(string(output), "verifying npm package install ("+scenario+")") { + t.Errorf("verifier output is missing %s scenario:\n%s", scenario, output) + } + } +} + func seedDistArchive(t *testing.T, path string) { t.Helper() file, err := os.Create(path) From a26b16b30ee023d41cba2c4487974ab5925e4f23 Mon Sep 17 00:00:00 2001 From: chichuan Date: Thu, 13 Aug 2026 14:58:44 +0800 Subject: [PATCH 59/86] test: scope release seal env assertions --- test/scripts/changelog_pr_gate_test.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/test/scripts/changelog_pr_gate_test.go b/test/scripts/changelog_pr_gate_test.go index ec327062..a132f23b 100644 --- a/test/scripts/changelog_pr_gate_test.go +++ b/test/scripts/changelog_pr_gate_test.go @@ -1025,9 +1025,20 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) { t.Fatal("Code Admission workflow missing Policy job boundaries") } policyJob := admission[policyStart:policyEnd] - if strings.Count(policyJob, "RELEASE_SEAL_ONLY: ${{ needs.lint.outputs.release_seal_only }}") != 2 { - t.Error("Policy job must pass release-seal classification to both PR and main metadata validators") + requirePolicyEnv := func(step, nextStep string) { + t.Helper() + start := strings.Index(policyJob, " - name: "+step+"\n") + end := strings.Index(policyJob[start+1:], " - name: "+nextStep+"\n") + if start < 0 || end < 0 { + t.Fatalf("Policy job missing %q step boundary", step) + } + block := policyJob[start : start+1+end] + if !strings.Contains(block, "RELEASE_SEAL_ONLY: ${{ needs.lint.outputs.release_seal_only }}") { + t.Errorf("Policy %q step must receive release-seal classification", step) + } } + requirePolicyEnv("Validate changed CHANGELOG content", "Validate release fragment lifecycle") + requirePolicyEnv("Validate trusted main metadata-only push", "Record CHANGELOG-only fast path") for _, want := range []string{ "name: CI", "files.length === 1", From 75bd5184472167673f7cc1e8334e26161a1957ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=B7=E5=BF=83=E8=8F=9C?= Date: Thu, 13 Aug 2026 15:19:46 +0800 Subject: [PATCH 60/86] fix(drive): honor --type folder in --latest top-N and harden filter mutexes --- .changes/942-drive-list-pattern-fix.md | 5 ++++ internal/helpers/drive_depth.go | 3 ++- internal/helpers/drive_latest.go | 5 ++-- internal/helpers/drive_list_filter.go | 7 ++++-- internal/helpers/drive_list_filter_test.go | 23 +++++++++++++++++++ internal/helpers/pr868_coverage_gaps_test.go | 2 +- .../helpers/pr868_surfaces_coverage_test.go | 2 +- skills/mono/references/products/drive.md | 2 +- .../multi/dingtalk-drive/references/drive.md | 3 ++- 9 files changed, 43 insertions(+), 9 deletions(-) diff --git a/.changes/942-drive-list-pattern-fix.md b/.changes/942-drive-list-pattern-fix.md index 5115bd45..fe405c48 100644 --- a/.changes/942-drive-list-pattern-fix.md +++ b/.changes/942-drive-list-pattern-fix.md @@ -5,3 +5,8 @@ category: Fixed - **Drive list pattern filtering** (#942) — `dws drive list --pattern` on the single-layer pan route now filters the returned page by name pattern; the flag was previously accepted but silently ignored. + +- **Drive list `--type folder --latest` composition** (#942) — `--latest` now + ranks the filtered entries (folders included when `--type folder` is set) + instead of unconditionally dropping folders, so the documented combination + returns the most recently modified folders rather than an empty list. diff --git a/internal/helpers/drive_depth.go b/internal/helpers/drive_depth.go index b70a6c1f..09432f7c 100644 --- a/internal/helpers/drive_depth.go +++ b/internal/helpers/drive_depth.go @@ -370,7 +370,8 @@ func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, trunca items = applyDriveListFilter(items, route, filter) } if latest > 0 { - items = applyDriveListLatest(items, latest) + // --type folder 时 latest 对过滤后的目录取 Top-N,避免「先留目录再剔目录」的空结果。 + items = applyDriveListLatest(items, latest, filter.nodeType == "folder") } else { // 排列为 rel_path 树序:BFS 只决定截断时哪些条目入选,树序决定呈现顺序。 sort.SliceStable(items, func(i, j int) bool { diff --git a/internal/helpers/drive_latest.go b/internal/helpers/drive_latest.go index 1668d216..db8b9541 100644 --- a/internal/helpers/drive_latest.go +++ b/internal/helpers/drive_latest.go @@ -49,10 +49,11 @@ func driveLatestExclusiveError(flag string, latest int) error { } } -func applyDriveListLatest(items []map[string]any, latest int) []map[string]any { +// foldersOnly=true 时对目录取 Top-N(--type folder --latest 组合),否则对文件取 Top-N。 +func applyDriveListLatest(items []map[string]any, latest int, foldersOnly bool) []map[string]any { files := make([]map[string]any, 0, len(items)) for _, item := range items { - if isDriveDepthFolder(item) || isDocDepthFolder(item) { + if (isDriveDepthFolder(item) || isDocDepthFolder(item)) != foldersOnly { continue } files = append(files, item) diff --git a/internal/helpers/drive_list_filter.go b/internal/helpers/drive_list_filter.go index f86bda84..292dea2b 100644 --- a/internal/helpers/drive_list_filter.go +++ b/internal/helpers/drive_list_filter.go @@ -85,8 +85,11 @@ func parseDriveListFilter(cmd *cobra.Command) (driveListFilter, error) { if cmd.Flags().Changed("versions") { return filter, driveListFilterExclusiveError("versions", "--versions 为独立的版本列表模式,请去掉 --versions 或过滤条件") } - if v := flagOrFallback(cmd, "cursor", "next-token", "page-token"); v != "" { - return filter, driveListFilterExclusiveError("cursor", "过滤模式为从头全量扫描,无连续游标;如需逐页翻页请去掉过滤条件") + // 用 Changed 判定而非值非空:显式 --cursor= 空值同样视为启用游标分页。 + for _, f := range []string{"cursor", "next-token", "page-token"} { + if fl := cmd.Flags().Lookup(f); fl != nil && fl.Changed { + return filter, driveListFilterExclusiveError("cursor", "过滤模式为从头全量扫描,无连续游标;如需逐页翻页请去掉过滤条件") + } } for _, f := range []string{"order-by", "order"} { if cmd.Flags().Changed(f) { diff --git a/internal/helpers/drive_list_filter_test.go b/internal/helpers/drive_list_filter_test.go index 6e56912d..8f4f3fb4 100644 --- a/internal/helpers/drive_list_filter_test.go +++ b/internal/helpers/drive_list_filter_test.go @@ -242,6 +242,7 @@ func TestCrossPlatformCoverageDriveListFilterPanStartWithLatest(t *testing.T) { func TestCrossPlatformCoverageDriveListFilterExclusiveFlags(t *testing.T) { cases := [][]string{ {"list", "--type", "file", "--cursor", "c1"}, + {"list", "--type", "file", "--cursor", ""}, {"list", "--type", "file", "--order-by", "name"}, {"list", "--start", "7d", "--order", "asc"}, {"list", "--type", "file", "--limit", "5"}, @@ -562,3 +563,25 @@ func TestCrossPlatformCoverageDriveListPatternPassthroughEdges(t *testing.T) { t.Fatalf("items = %#v", mixed) } } + +// 13. --type folder --latest 回归(P1 CR):latest 对过滤后的目录按时间取 Top-N, +// 不再「先留目录再剔目录」返回空列表。 +func TestCrossPlatformCoverageDriveListFilterFolderLatest(t *testing.T) { + useDriveListArgs(t) + now := time.Now().UnixMilli() + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: fmt.Sprintf(`{"items":[ + {"fileId":"d1","name":"old-dir","type":"FOLDER","modifyTime":%d}, + {"fileId":"d2","name":"new-dir","type":"FOLDER","modifyTime":%d}, + {"fileId":"f1","name":"a.txt","type":"FILE","modifyTime":%d} + ]}`, now-5000, now, now)}, + }} + buf, err := executeDriveListCapture(t, caller, "list", "--type", "folder", "--latest", "1") + if err != nil { + t.Fatal(err) + } + items := decodeDepthResult(t, buf)["items"].([]any) + if len(items) != 1 || items[0].(map[string]any)["fileId"] != "d2" { + t.Fatalf("folder latest items = %#v", items) + } +} diff --git a/internal/helpers/pr868_coverage_gaps_test.go b/internal/helpers/pr868_coverage_gaps_test.go index 5b77a200..d9cf18b2 100644 --- a/internal/helpers/pr868_coverage_gaps_test.go +++ b/internal/helpers/pr868_coverage_gaps_test.go @@ -611,7 +611,7 @@ func TestCrossPlatformCoverageDriveLatestRemaining(t *testing.T) { {"name": "c", "sortTime": int64(5), "rel_path": "z", "fileId": "3", "type": "file"}, {"nodeType": "Folder", "name": "folder"}, } - got := applyDriveListLatest(items, 10) + got := applyDriveListLatest(items, 10, false) if len(got) != 3 { t.Fatalf("len=%d", len(got)) } diff --git a/internal/helpers/pr868_surfaces_coverage_test.go b/internal/helpers/pr868_surfaces_coverage_test.go index 1a2f86a5..5fc5d438 100644 --- a/internal/helpers/pr868_surfaces_coverage_test.go +++ b/internal/helpers/pr868_surfaces_coverage_test.go @@ -322,7 +322,7 @@ func TestCrossPlatformCoverageDriveLatestHelpers(t *testing.T) { {"name": "a.txt", "sortTime": int64(2), "rel_path": "a", "fileId": "1", "type": "file"}, {"name": "dir", "sortTime": int64(9), "type": "folder", "dentryType": "folder"}, } - got := applyDriveListLatest(items, 1) + got := applyDriveListLatest(items, 1, false) if len(got) != 1 { t.Fatalf("latest len=%d", len(got)) } diff --git a/skills/mono/references/products/drive.md b/skills/mono/references/products/drive.md index 96fceca4..df7c666d 100644 --- a/skills/mono/references/products/drive.md +++ b/skills/mono/references/products/drive.md @@ -63,7 +63,7 @@ Flags: - 执行方式:钉盘与知识库(--workspace)两路由统一为**客户端过滤**——全量扫描当前目录后在进程内筛选; 与 `--depth>1` 组合时递归扫描后筛(被滤掉的条目仍占 2000 条全局上限)。 - 互斥:与 `--versions`/`--cursor`/`--order-by`/`--order`/`--limit` 不能同时使用(过滤模式为全量扫描, - 无游标与服务端排序语义);可与 `--latest`/`--pattern`/`--depth` 组合,`+--latest` 表示「符合条件的条目中最新 N 个」。 + 无游标与服务端排序语义);可与 `--latest`/`--pattern`/`--depth` 组合,`--latest` 表示「符合条件的条目中最新 N 个」。 - 输出形态:带过滤时输出从单页透传变为聚合形态 `{items, maxDepth, truncated, errors}`。 - 已知代价:大目录(>2000 条)触顶截断时 `truncated=true`(退出码 0,结果每条都正确但没扫完); 建议用 `--folder` 指定子目录缩小扫描范围;带关键词的过滤场景改用 `dws drive search`。 diff --git a/skills/multi/dingtalk-drive/references/drive.md b/skills/multi/dingtalk-drive/references/drive.md index 21a7534d..486ef118 100644 --- a/skills/multi/dingtalk-drive/references/drive.md +++ b/skills/multi/dingtalk-drive/references/drive.md @@ -38,6 +38,7 @@ Flags: --order-by string 排序字段: createTime|modifyTime|name (可选) --folder string 父节点 ID (dentryUuid),不传则列出空间根目录 (可选) --space-id string 空间 ID,不传则使用「我的文件」对应 spaceId (可选) + --workspace string 文档空间/知识库 ID (加密 string 或 URL),传入则路由到文档空间 (可选) --thumbnail 是否返回缩略图信息 (可选) --pattern string 按名称通配过滤结果,如 "*日报*"(客户端过滤,无通配符时按子串匹配)(可选) --depth int 递归列出子目录层级,默认 1(仅当前层),最大 5;与 --cursor/--limit 互斥 (可选) @@ -56,7 +57,7 @@ Flags: - 执行方式:钉盘与知识库(--workspace)两路由统一为**客户端过滤**——全量扫描当前目录后在进程内筛选; 与 `--depth>1` 组合时递归扫描后筛(被滤掉的条目仍占 2000 条全局上限)。 - 互斥:与 `--versions`/`--cursor`/`--order-by`/`--order`/`--limit` 不能同时使用(过滤模式为全量扫描, - 无游标与服务端排序语义);可与 `--latest`/`--pattern`/`--depth` 组合,`+--latest` 表示「符合条件的条目中最新 N 个」。 + 无游标与服务端排序语义);可与 `--latest`/`--pattern`/`--depth` 组合,`--latest` 表示「符合条件的条目中最新 N 个」。 - 输出形态:带过滤时输出从单页透传变为聚合形态 `{items, maxDepth, truncated, errors}`。 - 已知代价:大目录(>2000 条)触顶截断时 `truncated=true`(退出码 0,结果每条都正确但没扫完); 建议用 `--folder` 指定子目录缩小扫描范围;带关键词的过滤场景改用 `dws drive search`。 From 9ea527a7c43908a1179992d48cc02473f13f096c Mon Sep 17 00:00:00 2001 From: chichuan Date: Thu, 13 Aug 2026 15:25:11 +0800 Subject: [PATCH 61/86] ci: reject truncated release seal file lists --- .github/workflows/ci.yml | 6 ++++-- test/scripts/changelog_pr_gate_test.go | 13 ++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b3a3a4f..b2c76446 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -341,7 +341,8 @@ jobs: per_page: 100, }); files = Array.isArray(comparison.files) ? comparison.files : []; - classifyFiles(files.length < 300); + const pushFilesComplete = files.length < 300; + classifyFiles(pushFilesComplete); const linearFromValidatedTip = comparison.status === 'ahead' && comparison.merge_base_commit?.sha === expectedBefore && @@ -353,7 +354,8 @@ jobs: files[0].filename === 'CHANGELOG.md' && files[0].status === 'modified' && !files[0].previous_filename; - const exactReleaseSealDiff = isExactReleaseSeal(files); + const exactReleaseSealDiff = + pushFilesComplete && isExactReleaseSeal(files); if (linearFromValidatedTip && (exactChangelogDiff || exactReleaseSealDiff)) { const requiredContexts = [ diff --git a/test/scripts/changelog_pr_gate_test.go b/test/scripts/changelog_pr_gate_test.go index a132f23b..3dca2ca0 100644 --- a/test/scripts/changelog_pr_gate_test.go +++ b/test/scripts/changelog_pr_gate_test.go @@ -1039,6 +1039,15 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) { } requirePolicyEnv("Validate changed CHANGELOG content", "Validate release fragment lifecycle") requirePolicyEnv("Validate trusted main metadata-only push", "Record CHANGELOG-only fast path") + pushStart := strings.Index(admission, "} else if (context.eventName === 'push') {") + pushEnd := strings.Index(admission, "\n core.setOutput('changelog_only'") + if pushStart < 0 || pushEnd <= pushStart { + t.Fatal("Code Admission workflow missing push classification boundaries") + } + pushClassification := admission[pushStart:pushEnd] + if strings.Contains(pushClassification, "const exactReleaseSealDiff = isExactReleaseSeal(files)") { + t.Error("push release-seal fast path must not trust an unguarded compare file list") + } for _, want := range []string{ "name: CI", "files.length === 1", @@ -1087,7 +1096,9 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) { `package_output="$(./scripts/ci/test-packages.sh list`, "docs_only: ${{ steps.classify.outputs.docs_only }}", "full_suite: ${{ steps.classify.outputs.full_suite }}", - "classifyFiles(files.length < 300)", + "const pushFilesComplete = files.length < 300", + "classifyFiles(pushFilesComplete)", + "pushFilesComplete && isExactReleaseSeal(files)", "platformSensitive =", "files.some(isNativeGoChange)", "paths.some(isEditionSensitive)", From 9eeb0681ff2f199984b85a4f17b736465174be17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Thu, 13 Aug 2026 15:31:35 +0800 Subject: [PATCH 62/86] test(chat): cover list-all time defaults in platform gate --- internal/helpers/chat_command_edges_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/helpers/chat_command_edges_test.go b/internal/helpers/chat_command_edges_test.go index cadc2207..2c97ce06 100644 --- a/internal/helpers/chat_command_edges_test.go +++ b/internal/helpers/chat_command_edges_test.go @@ -160,6 +160,9 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T) os.Args = []string{"dws", "chat"} t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs }) caller := &productExampleCaller{} + if got := formatChatMessageListAllTime(0); got == "" { + t.Fatal("formatChatMessageListAllTime returned empty string") + } commands := [][]string{ {"chmod", "chat.read", "--ttl="}, @@ -168,6 +171,10 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T) {"message", "list", "--user=D-user", "--time=2026-01-01", "--limit=1"}, {"message", "list", "--open-dingtalk-id=D-user", "--time=2026-01-01", "--direction=sideways"}, {"message", "list-direct", "--user=D-user", "--limit=1"}, + {"message", "list-all"}, + {"message", "list-all", "--end=2026-01-02T00:00:00Z"}, + {"message", "list-all", "--end=bad"}, + {"message", "list-all", "--start=not-a-time", "--end=2026-01-02T00:00:00Z"}, {"message", "list-all", "--start=2026-01-01 00:00:00", "--end=bad"}, {"message", "list-all", "--start=2026-01-02 00:00:00", "--end=2026-01-01 00:00:00"}, {"message", "list-by-sender", "--sender-user-id=u1", "--start=bad"}, From 8c00068364b999d2f56dd619edaeb999c624b406 Mon Sep 17 00:00:00 2001 From: chichuan Date: Thu, 13 Aug 2026 15:49:59 +0800 Subject: [PATCH 63/86] fix(drive): harden folder mirror safety --- internal/helpers/drive.go | 19 + internal/helpers/drive_folder_contract.go | 146 +++ .../helpers/drive_folder_contract_test.go | 182 ++++ .../drive_mirror_coverage_gaps_test.go | 163 ++++ .../helpers/drive_mirror_preflight_test.go | 376 +++++++ internal/helpers/drive_pull.go | 739 ++++++++++++-- internal/helpers/drive_pull_coverage_test.go | 39 +- .../helpers/drive_pull_plan_coverage_test.go | 507 ++++++++++ internal/helpers/drive_pull_test.go | 40 +- internal/helpers/drive_pull_times_unix.go | 16 + internal/helpers/drive_pull_times_windows.go | 16 + internal/helpers/drive_pull_toctou_test.go | 915 ++++++++++++++++++ internal/helpers/drive_push.go | 395 +++++++- internal/helpers/drive_push_coverage_test.go | 41 +- .../drive_push_sync_final_coverage_test.go | 632 ++++++++++++ internal/helpers/drive_push_test.go | 105 +- internal/helpers/drive_push_toctou_test.go | 136 +++ .../helpers/drive_push_type_conflict_test.go | 6 +- .../helpers/drive_remote_duplicate_test.go | 8 +- .../helpers/drive_remote_folder_id_test.go | 14 +- .../drive_remote_tree_integrity_test.go | 309 ++++++ .../helpers/drive_remote_unsafe_name_test.go | 4 +- internal/helpers/drive_status.go | 104 +- .../helpers/drive_status_coverage_test.go | 34 +- internal/helpers/drive_status_windows.go | 40 +- internal/helpers/drive_sync.go | 255 +++-- .../helpers/drive_sync_confirmation_test.go | 50 +- internal/helpers/drive_sync_conflict_test.go | 99 +- internal/helpers/drive_sync_coverage_test.go | 13 +- internal/helpers/drive_sync_edges_test.go | 53 +- internal/helpers/drive_sync_gaps_test.go | 20 +- internal/helpers/drive_sync_platform_test.go | 19 +- internal/helpers/drive_sync_test.go | 90 +- .../helpers/drive_sync_type_conflict_test.go | 4 +- skills/mono/references/products/drive.md | 15 +- .../multi/dingtalk-drive/references/drive.md | 15 +- 36 files changed, 5067 insertions(+), 552 deletions(-) create mode 100644 internal/helpers/drive_folder_contract.go create mode 100644 internal/helpers/drive_folder_contract_test.go create mode 100644 internal/helpers/drive_mirror_coverage_gaps_test.go create mode 100644 internal/helpers/drive_mirror_preflight_test.go create mode 100644 internal/helpers/drive_pull_plan_coverage_test.go create mode 100644 internal/helpers/drive_pull_times_unix.go create mode 100644 internal/helpers/drive_pull_times_windows.go create mode 100644 internal/helpers/drive_pull_toctou_test.go create mode 100644 internal/helpers/drive_push_sync_final_coverage_test.go create mode 100644 internal/helpers/drive_push_toctou_test.go create mode 100644 internal/helpers/drive_remote_tree_integrity_test.go diff --git a/internal/helpers/drive.go b/internal/helpers/drive.go index b5455fb6..e01197ed 100644 --- a/internal/helpers/drive.go +++ b/internal/helpers/drive.go @@ -3405,6 +3405,7 @@ skipped/failed)、diff 与逐条 items;有失败则以非零退出码退出 PrimaryCLIPath: "drive status", }, Description: "比较本地文件夹与钉盘文件夹的差异,只读不落盘。", + Result: driveFolderStatusResultSpec(), Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -3439,6 +3440,11 @@ skipped/failed)、diff 与逐条 items;有失败则以非零退出码退出 PrimaryCLIPath: "drive pull", }, Description: "把钉盘文件夹单向镜像到本地;写操作需确认,默认跳过本地既有文件。", + DryRun: &contract.DryRunSpec{ + PreviewKind: contract.DryRunPreviewPlan, + RemoteReads: true, + }, + Result: driveFolderPullResultSpec(), Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -3452,6 +3458,7 @@ skipped/failed)、diff 与逐条 items;有失败则以非零退出码退出 "dws drive pull --local-folder /abs/path/repo --remote-folder ", "dws drive pull --local-folder /abs/path/repo --remote-folder --if-exists smart --dry-run", }, + ExampleDispositions: driveFolderStatefulExampleDispositions(), }, Parameters: []contract.ParamDecl{ {Name: "local-folder", Required: boolPtr(true)}, @@ -3473,6 +3480,11 @@ skipped/failed)、diff 与逐条 items;有失败则以非零退出码退出 PrimaryCLIPath: "drive push", }, Description: "把本地文件夹单向镜像到钉盘;写操作需确认,默认跳过远端既有文件。", + DryRun: &contract.DryRunSpec{ + PreviewKind: contract.DryRunPreviewPlan, + RemoteReads: true, + }, + Result: driveFolderPushResultSpec(), Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -3486,6 +3498,7 @@ skipped/failed)、diff 与逐条 items;有失败则以非零退出码退出 "dws drive push --local-folder /abs/path/repo --remote-folder ", "dws drive push --local-folder /abs/path/repo --remote-folder --if-exists smart --dry-run", }, + ExampleDispositions: driveFolderStatefulExampleDispositions(), }, Parameters: []contract.ParamDecl{ {Name: "local-folder", Required: boolPtr(true)}, @@ -3507,6 +3520,11 @@ skipped/failed)、diff 与逐条 items;有失败则以非零退出码退出 PrimaryCLIPath: "drive sync", }, Description: "本地与钉盘文件夹双向同步;写操作需确认,默认跳过双端冲突。", + DryRun: &contract.DryRunSpec{ + PreviewKind: contract.DryRunPreviewPlan, + RemoteReads: true, + }, + Result: driveFolderSyncResultSpec(), Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -3520,6 +3538,7 @@ skipped/failed)、diff 与逐条 items;有失败则以非零退出码退出 "dws drive sync --local-folder /abs/path/repo --remote-folder ", "dws drive sync --local-folder /abs/path/repo --remote-folder --on-conflict remote-wins --dry-run", }, + ExampleDispositions: driveFolderStatefulExampleDispositions(), }, Parameters: []contract.ParamDecl{ {Name: "local-folder", Required: boolPtr(true)}, diff --git a/internal/helpers/drive_folder_contract.go b/internal/helpers/drive_folder_contract.go new file mode 100644 index 00000000..624db528 --- /dev/null +++ b/internal/helpers/drive_folder_contract.go @@ -0,0 +1,146 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helpers + +import ( + "encoding/json" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" +) + +// driveFolderStatefulExampleDispositions 明确记录文件夹镜像示例为什么只做契约校验: +// 预览必须读取用户指定的本地目录与真实钉盘目录,无法在隔离的 Agent 示例环境中伪造。 +// DryRun 能力仍由 ContractFinal 正式发布,命令级测试负责执行并验证零写入预览。 +func driveFolderStatefulExampleDispositions() []contract.ExampleDisposition { + first, second := 0, 1 + return []contract.ExampleDisposition{ + { + Index: &first, + Mode: contract.ExampleDispositionModeContractOnly, + ReasonCode: contract.ExampleDispositionReasonStatefulPreflight, + Reason: "预览需要读取用户指定的真实钉盘目录和本地文件系统状态", + Reviewed: true, + }, + { + Index: &second, + Mode: contract.ExampleDispositionModeContractOnly, + ReasonCode: contract.ExampleDispositionReasonStatefulPreflight, + Reason: "预览需要读取用户指定的真实钉盘目录和本地文件系统状态", + Reviewed: true, + }, + } +} + +func driveFolderStatusResultSpec() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{ + contract.ResultOutcomeSuccess, + contract.ResultOutcomeFailure, + }, + DataSchema: json.RawMessage(`{ + "type":"object", + "description":"本地文件夹与钉盘文件夹的差异", + "properties":{ + "detection":{"type":"string","description":"差异检测模式","enum":["exact","quick"]}, + "new_local":{"type":"array","description":"仅本地存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}}, + "new_remote":{"type":"array","description":"仅钉盘存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}}, + "modified":{"type":"array","description":"双端都存在但内容或时间不同的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}}, + "unchanged":{"type":"array","description":"双端一致的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}}, + "unknown":{"type":"array","description":"exact 模式下因缺少可靠远端哈希而无法判定的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}} + }, + "required":["detection","new_local","new_remote","modified","unchanged","unknown"], + "additionalProperties":false +}`), + } +} + +func driveFolderPullResultSpec() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{ + contract.ResultOutcomeSuccess, + contract.ResultOutcomePartialFailure, + contract.ResultOutcomeFailure, + }, + DataSchema: json.RawMessage(`{ + "type":"object", + "description":"钉盘文件夹拉取的执行结果或预览计划", + "properties":{ + "summary":{"type":"object","description":"真实执行的动作计数","properties":{"downloaded":{"type":"integer","description":"成功下载的文件数"},"skipped":{"type":"integer","description":"按策略跳过的文件数"},"failed":{"type":"integer","description":"失败的文件数"}},"required":["downloaded","skipped","failed"],"additionalProperties":false}, + "items":{"type":"array","description":"真实执行的逐文件结果","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"},"action":{"type":"string","description":"执行动作"},"error":{"type":"string","description":"失败原因"}},"required":["rel_path","action"],"additionalProperties":false}}, + "dry_run":{"type":"boolean","description":"是否为只读预览"}, + "executed":{"type":"boolean","description":"是否执行了写操作"}, + "preview_kind":{"type":"string","description":"预览类型","enum":["plan"]}, + "operation":{"type":"string","description":"预览的命令"}, + "if_exists":{"type":"string","description":"本地同名文件处理策略"}, + "plan":{"type":"object","description":"预览的拉取计划","properties":{"summary":{"type":"object","description":"计划动作计数","properties":{"downloaded":{"type":"integer","description":"计划下载的文件数"},"skipped":{"type":"integer","description":"计划跳过的文件数"},"failed":{"type":"integer","description":"预检失败的文件数"}},"required":["downloaded","skipped","failed"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐文件动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"},"action":{"type":"string","description":"计划动作"},"error":{"type":"string","description":"预检失败原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["summary","items"],"additionalProperties":false} + }, + "additionalProperties":false +}`), + } +} + +func driveFolderPushResultSpec() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{ + contract.ResultOutcomeSuccess, + contract.ResultOutcomePartialFailure, + contract.ResultOutcomeFailure, + }, + DataSchema: json.RawMessage(`{ + "type":"object", + "description":"本地文件夹推送的执行结果或预览计划", + "properties":{ + "summary":{"type":"object","description":"真实执行的动作计数","properties":{"uploaded":{"type":"integer","description":"成功上传或覆盖的文件数"},"skipped":{"type":"integer","description":"按策略跳过的文件数"},"failed":{"type":"integer","description":"失败的条目数"},"aborted":{"type":"boolean","description":"是否因失败中止后续上传"}},"required":["uploaded","skipped","failed","aborted"],"additionalProperties":false}, + "items":{"type":"array","description":"真实执行的逐条目结果","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"执行动作"},"size_bytes":{"type":"integer","description":"文件字节数"},"error":{"type":"string","description":"失败原因"}},"required":["rel_path","action"],"additionalProperties":false}}, + "dry_run":{"type":"boolean","description":"是否为只读预览"}, + "executed":{"type":"boolean","description":"是否执行了写操作"}, + "preview_kind":{"type":"string","description":"预览类型","enum":["plan"]}, + "operation":{"type":"string","description":"预览的命令"}, + "if_exists":{"type":"string","description":"远端同名文件处理策略"}, + "plan":{"type":"object","description":"预览的推送计划","properties":{"summary":{"type":"object","description":"计划动作计数","properties":{"uploaded":{"type":"integer","description":"计划上传或覆盖的文件数"},"skipped":{"type":"integer","description":"计划跳过的文件数"},"failed":{"type":"integer","description":"预检失败的条目数"},"aborted":{"type":"boolean","description":"计划是否会中止"}},"required":["uploaded","skipped","failed","aborted"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"计划动作"},"size_bytes":{"type":"integer","description":"文件字节数"},"error":{"type":"string","description":"预检失败原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["summary","items"],"additionalProperties":false} + }, + "additionalProperties":false +}`), + } +} + +func driveFolderSyncResultSpec() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{ + contract.ResultOutcomeSuccess, + contract.ResultOutcomePartialFailure, + contract.ResultOutcomeFailure, + }, + DataSchema: json.RawMessage(`{ + "type":"object", + "description":"本地与钉盘双向同步的执行结果或预览计划", + "properties":{ + "detection":{"type":"string","description":"差异检测模式","enum":["exact","quick"]}, + "diff":{"type":"object","description":"同步前的双端差异","properties":{"new_local":{"type":"array","description":"仅本地存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"new_remote":{"type":"array","description":"仅钉盘存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"modified":{"type":"array","description":"双端都存在但内容或时间不同的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unchanged":{"type":"array","description":"双端一致的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unknown":{"type":"array","description":"exact 模式下因缺少可靠远端哈希而无法判定的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}}},"required":["new_local","new_remote","modified","unchanged","unknown"],"additionalProperties":false}, + "summary":{"type":"object","description":"真实执行的动作计数","properties":{"pulled":{"type":"integer","description":"成功拉取的条目数"},"pushed":{"type":"integer","description":"成功推送的条目数"},"skipped":{"type":"integer","description":"跳过的条目数"},"failed":{"type":"integer","description":"失败的条目数"}},"required":["pulled","pushed","skipped","failed"],"additionalProperties":false}, + "items":{"type":"array","description":"真实执行的逐条目结果","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"执行动作"},"direction":{"type":"string","description":"同步方向"},"error":{"type":"string","description":"失败或跳过原因"}},"required":["rel_path","action"],"additionalProperties":false}}, + "dry_run":{"type":"boolean","description":"是否为只读预览","const":true}, + "executed":{"type":"boolean","description":"是否执行了写操作","const":false}, + "preview_kind":{"type":"string","description":"预览类型","enum":["plan"]}, + "operation":{"type":"string","description":"预览的命令","const":"drive sync"}, + "plan":{"type":"object","description":"预览的同步计划","properties":{"detection":{"type":"string","description":"计划使用的差异检测模式","enum":["exact","quick"]},"diff":{"type":"object","description":"计划执行前的双端差异","properties":{"new_local":{"type":"array","description":"仅本地存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"new_remote":{"type":"array","description":"仅钉盘存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"modified":{"type":"array","description":"双端都存在但内容或时间不同的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unchanged":{"type":"array","description":"双端一致的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unknown":{"type":"array","description":"exact 模式下因缺少可靠远端哈希而无法判定的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}}},"required":["new_local","new_remote","modified","unchanged","unknown"],"additionalProperties":false},"summary":{"type":"object","description":"计划动作计数","properties":{"pulled":{"type":"integer","description":"计划拉取的条目数"},"pushed":{"type":"integer","description":"计划推送的条目数"},"skipped":{"type":"integer","description":"计划跳过的条目数"},"failed":{"type":"integer","description":"预检失败的条目数"}},"required":["pulled","pushed","skipped","failed"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"计划动作"},"direction":{"type":"string","description":"同步方向"},"error":{"type":"string","description":"失败或跳过原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["detection","diff","summary","items"],"additionalProperties":false} + }, + "oneOf":[ + {"required":["detection","diff","summary","items"]}, + {"required":["dry_run","executed","preview_kind","operation","plan"]} + ], + "additionalProperties":false +}`), + } +} diff --git a/internal/helpers/drive_folder_contract_test.go b/internal/helpers/drive_folder_contract_test.go new file mode 100644 index 00000000..6495708d --- /dev/null +++ b/internal/helpers/drive_folder_contract_test.go @@ -0,0 +1,182 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helpers + +import ( + "encoding/json" + "reflect" + "sort" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal" +) + +func TestCrossPlatformCoverageDriveFolderContractsPublishResultAndDryRun(t *testing.T) { + drive := newDriveCommand() + want := map[string]struct { + canonical string + dryRun bool + outcomes []contract.ResultOutcome + properties []string + }{ + "status": { + canonical: "drive.folder_status", + outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + properties: []string{"detection", "modified", "new_local", "new_remote", "unchanged", "unknown"}, + }, + "pull": { + canonical: "drive.folder_pull", + dryRun: true, + outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomePartialFailure, contract.ResultOutcomeFailure}, + properties: []string{"dry_run", "executed", "if_exists", "items", "operation", "plan", "preview_kind", "summary"}, + }, + "push": { + canonical: "drive.folder_push", + dryRun: true, + outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomePartialFailure, contract.ResultOutcomeFailure}, + properties: []string{"dry_run", "executed", "if_exists", "items", "operation", "plan", "preview_kind", "summary"}, + }, + "sync": { + canonical: "drive.folder_sync", + dryRun: true, + outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomePartialFailure, contract.ResultOutcomeFailure}, + properties: []string{"detection", "diff", "dry_run", "executed", "items", "operation", "plan", "preview_kind", "summary"}, + }, + } + + for name, expectation := range want { + t.Run(name, func(t *testing.T) { + leaf, _, err := drive.Find([]string{name}) + if err != nil || leaf == nil { + t.Fatalf("find drive %s: command=%v err=%v", name, leaf, err) + } + final, ok := contractfinal.RuntimeContractFinal(leaf) + if !ok || final.Identity == nil || final.Identity.CanonicalPath != expectation.canonical { + t.Fatalf("drive %s ContractFinal identity = %#v", name, final.Identity) + } + if final.Result == nil || !reflect.DeepEqual(final.Result.Outcomes, expectation.outcomes) { + t.Fatalf("drive %s result = %#v, want outcomes %#v", name, final.Result, expectation.outcomes) + } + properties := resultSchemaProperties(t, final.Result.DataSchema) + if got := sortedContractSchemaKeys(properties); !reflect.DeepEqual(got, expectation.properties) { + t.Fatalf("drive %s result properties = %#v, want %#v", name, got, expectation.properties) + } + if expectation.dryRun { + if final.DryRun == nil || final.DryRun.PreviewKind != contract.DryRunPreviewPlan || !final.DryRun.RemoteReads { + t.Fatalf("drive %s dry_run = %#v, want remote-reading plan", name, final.DryRun) + } + if final.Selection == nil || len(final.Selection.ExampleDispositions) != len(final.Selection.Examples) { + t.Fatalf("drive %s stateful example dispositions = %#v", name, final.Selection) + } + for _, disposition := range final.Selection.ExampleDispositions { + if disposition.Mode != contract.ExampleDispositionModeContractOnly || + disposition.ReasonCode != contract.ExampleDispositionReasonStatefulPreflight || !disposition.Reviewed { + t.Fatalf("drive %s invalid example disposition: %#v", name, disposition) + } + } + } else if final.DryRun != nil { + t.Fatalf("drive %s unexpectedly publishes dry_run: %#v", name, final.DryRun) + } + + if name == "sync" { + assertDriveSyncResultSchema(t, final.Result.DataSchema) + } + }) + } +} + +func resultSchemaProperties(t *testing.T, raw json.RawMessage) map[string]any { + t.Helper() + var schema map[string]any + if err := json.Unmarshal(raw, &schema); err != nil { + t.Fatalf("result data_schema is not JSON: %v\n%s", err, raw) + } + properties, ok := schema["properties"].(map[string]any) + if !ok { + t.Fatalf("result data_schema.properties = %#v, want object", schema["properties"]) + } + return properties +} + +func sortedContractSchemaKeys(values map[string]any) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func assertDriveSyncResultSchema(t *testing.T, raw json.RawMessage) { + t.Helper() + properties := resultSchemaProperties(t, raw) + diff := schemaObjectProperty(t, properties, "diff") + assertSchemaRequired(t, diff, "new_local", "new_remote", "modified", "unchanged", "unknown") + if got := sortedContractSchemaKeys(schemaProperties(t, diff)); !reflect.DeepEqual(got, []string{"modified", "new_local", "new_remote", "unchanged", "unknown"}) { + t.Fatalf("sync diff properties = %#v", got) + } + + plan := schemaObjectProperty(t, properties, "plan") + assertSchemaRequired(t, plan, "detection", "diff", "summary", "items") + planProperties := schemaProperties(t, plan) + planDiff := schemaObjectProperty(t, planProperties, "diff") + assertSchemaRequired(t, planDiff, "new_local", "new_remote", "modified", "unchanged", "unknown") + + var schema map[string]any + if err := json.Unmarshal(raw, &schema); err != nil { + t.Fatal(err) + } + oneOf, ok := schema["oneOf"].([]any) + if !ok || len(oneOf) != 2 { + t.Fatalf("sync result oneOf = %#v, want execution/dry-run alternatives", schema["oneOf"]) + } +} + +func schemaObjectProperty(t *testing.T, properties map[string]any, name string) map[string]any { + t.Helper() + property, ok := properties[name].(map[string]any) + if !ok || property["type"] != "object" { + t.Fatalf("schema property %s = %#v, want object", name, properties[name]) + } + return property +} + +func schemaProperties(t *testing.T, schema map[string]any) map[string]any { + t.Helper() + properties, ok := schema["properties"].(map[string]any) + if !ok { + t.Fatalf("schema properties = %#v, want object", schema["properties"]) + } + return properties +} + +func assertSchemaRequired(t *testing.T, schema map[string]any, want ...string) { + t.Helper() + raw, ok := schema["required"].([]any) + if !ok { + t.Fatalf("schema required = %#v", schema["required"]) + } + got := make([]string, 0, len(raw)) + for _, value := range raw { + text, ok := value.(string) + if !ok { + t.Fatalf("schema required value = %#v, want string", value) + } + got = append(got, text) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("schema required = %#v, want %#v", got, want) + } +} diff --git a/internal/helpers/drive_mirror_coverage_gaps_test.go b/internal/helpers/drive_mirror_coverage_gaps_test.go new file mode 100644 index 00000000..bfcb5c3e --- /dev/null +++ b/internal/helpers/drive_mirror_coverage_gaps_test.go @@ -0,0 +1,163 @@ +package helpers + +import ( + "context" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +// TestCrossPlatformCoverageDriveMirrorCoverageGaps 覆盖镜像整批 preflight 引入的 +// 输出失败和防御式解析分支。测试只调用命名 helper,不依赖真实 Drive 或网络。 +func TestCrossPlatformCoverageDriveMirrorCoverageGaps(t *testing.T) { + t.Run("preflight output failures", func(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "a.txt"), "local") + caller := syncCaller(map[string]string{ + "ROOT": `{"result":{"items":[{"name":"A.txt","type":"file","fileId":"REMOTE"}],"nextToken":""}}`, + }) + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: failingWriter{}}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive"}) + + pull := findDriveSubcommand(t, "pull") + mustSetFlags(t, pull, map[string]string{"local-folder": root, "remote-folder": "ROOT"}) + if err := runDrivePull(pull, nil); err == nil || !strings.Contains(err.Error(), "write failed") { + t.Fatalf("pull preflight output error = %v", err) + } + + push := findDriveSubcommand(t, "push") + mustSetFlags(t, push, map[string]string{"local-folder": root, "remote-folder": "ROOT"}) + if err := runDrivePush(push, nil); err == nil || !strings.Contains(err.Error(), "write failed") { + t.Fatalf("push preflight output error = %v", err) + } + + sync := findDriveSubcommand(t, "sync") + mustSetFlags(t, sync, map[string]string{"local-folder": root, "remote-folder": "ROOT", "quick": "true"}) + if err := runDriveSync(sync, nil); err == nil || !strings.Contains(err.Error(), "write failed") { + t.Fatalf("sync preflight output error = %v", err) + } + }) + + t.Run("pull remote-only preflight output failure", func(t *testing.T) { + root := t.TempDir() + caller := syncCaller(map[string]string{ + "ROOT": `{"result":{"items":[{"name":"A.txt","type":"file","fileId":"UP"},{"name":"a.txt","type":"file","fileId":"LOW"}],"nextToken":""}}`, + }) + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: failingWriter{}}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive"}) + pull := findDriveSubcommand(t, "pull") + mustSetFlags(t, pull, map[string]string{"local-folder": root, "remote-folder": "ROOT"}) + if err := runDrivePull(pull, nil); err == nil || !strings.Contains(err.Error(), "write failed") { + t.Fatalf("pull remote-only preflight output error = %v", err) + } + }) + + t.Run("dry run preflight output failures", func(t *testing.T) { + root := t.TempDir() + remote := map[string]*remoteFile{"A.txt": {RelPath: "A.txt"}, "a.txt": {RelPath: "a.txt"}} + testseam.Swap(t, &deps, &Deps{Out: &Formatter{w: failingWriter{}}}) + if err := printDrivePullDryRun(root, ifExistsSkip, remote, []string{"A.txt", "a.txt"}, true); err == nil { + t.Fatal("pull dry-run preflight must propagate output failure") + } + local := []localPushFile{{RelPath: "a.txt"}} + if err := printDrivePushDryRun(ifExistsSkip, + map[string]*remoteFile{"A.txt": {RelPath: "A.txt"}}, map[string]string{"": "ROOT"}, nil, local); err == nil { + t.Fatal("push dry-run preflight must propagate output failure") + } + }) + + t.Run("parser errors", func(t *testing.T) { + for _, body := range []string{ + `{"result":`, + `{"result":{"items":[],"hasMore":"bad"}}`, + `{"result":{"items":null}}`, + `{"result":{"items":"bad"}}`, + `{"result":[`, + `{"result":[true]}`, + `{"result":[{}]}`, + `{"result":true}`, + } { + if _, _, err := parseDriveList(body); err == nil { + t.Errorf("parseDriveList(%q) unexpectedly succeeded", body) + } + } + }) + + t.Run("pull dry-run existing-file policies", func(t *testing.T) { + root := t.TempDir() + skipPath := filepath.Join(root, "skip.txt") + smartPath := filepath.Join(root, "smart.txt") + mustWrite(t, skipPath, "skip") + mustWrite(t, smartPath, "smart") + smartMTime := readFileMTimeMillis(t, smartPath) + remote := map[string]*remoteFile{ + "skip.txt": {RelPath: "skip.txt"}, + "smart.txt": {RelPath: "smart.txt", ModifiedTime: smartMTime - 1, ModifiedTimeValid: true}, + } + testseam.Swap(t, &deps, &Deps{Out: &Formatter{w: io.Discard}}) + if err := printDrivePullDryRunWithPreflight(root, ifExistsSkip, remote, []string{"skip.txt"}, false, nil); err != nil { + t.Fatal(err) + } + if err := printDrivePullDryRunWithPreflight(root, ifExistsSmart, remote, []string{"smart.txt"}, false, nil); err != nil { + t.Fatal(err) + } + remote["smart.txt"] = &remoteFile{RelPath: "smart.txt"} + if err := printDrivePullDryRunWithPreflight(root, ifExistsSmart, remote, []string{"smart.txt"}, false, nil); err != nil { + t.Fatal(err) + } + }) + + t.Run("pull mkdir and replace errors", func(t *testing.T) { + root := t.TempDir() + caller := pullListingCaller("") + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive"}) + testseam.Swap(t, &pullMkdirAll, func(string, os.FileMode) error { return errTestDownload }) + if action, err := pullOneFile(context.Background(), "", &remoteFile{FileID: "F"}, + filepath.Join(root, "blocked", "x.txt"), ifExistsOverwrite); action != pullActionFailed || err == nil { + t.Fatalf("mkdir failure = (%q, %v)", action, err) + } + + targetDir := t.TempDir() + mustWrite(t, filepath.Join(targetDir, "target.txt"), "local") + testseam.Swap(t, &pullMkdirAll, os.MkdirAll) + testseam.Swap(t, &pullRename, func(*os.Root, string, string) error { return errTestDownload }) + swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error { + return os.WriteFile(dest, []byte("remote"), 0o644) + }) + if action, err := pullOneFile(context.Background(), "", &remoteFile{FileID: "F"}, + filepath.Join(targetDir, "target.txt"), ifExistsOverwrite); action != pullActionFailed || err == nil { + t.Fatalf("replace failure = (%q, %v)", action, err) + } + }) + + t.Run("sync pull defensive failures", func(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(root, "sub")); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + res := &driveSyncResult{} + syncPullFile(res, context.Background(), "", &remoteFile{FileID: "F"}, root, "sub/a.txt", syncDirectionPull) + if res.Summary.Failed != 1 { + t.Fatalf("escape failed = %d", res.Summary.Failed) + } + }) + + t.Run("pull remote file rechecks symlink escape", func(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(root, "sub")); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + action, err := pullRemoteFile(context.Background(), "", &remoteFile{FileID: "F"}, root, + "sub/a.txt", ifExistsOverwrite) + if action != pullActionFailed || err == nil { + t.Fatalf("pull remote TOCTOU guard = (%q, %v)", action, err) + } + }) +} diff --git a/internal/helpers/drive_mirror_preflight_test.go b/internal/helpers/drive_mirror_preflight_test.go new file mode 100644 index 00000000..b2abc561 --- /dev/null +++ b/internal/helpers/drive_mirror_preflight_test.go @@ -0,0 +1,376 @@ +package helpers + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" + "github.com/spf13/cobra" +) + +func runDriveMirrorCommand(t *testing.T, caller *driveScriptCaller, dryRun bool, args ...string) ([]byte, error) { + t.Helper() + caller.dryRun = dryRun + var out bytes.Buffer + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}}) + + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().BoolP("yes", "y", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.AddCommand(newDriveCommand()) + full := append(append([]string{}, args...), "--yes") + testseam.Swap(t, &os.Args, append([]string{"dws", "drive"}, full...)) + root.SetArgs(append([]string{"drive"}, full...)) + err := root.Execute() + return append([]byte(nil), out.Bytes()...), err +} + +func captureMirrorHTTP(t *testing.T) (getCalls, putCalls *int) { + t.Helper() + gets, puts := 0, 0 + swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error { + gets++ + return nil + }) + testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { + puts++ + return nil + }) + return &gets, &puts +} + +func assertMirrorPreflightNoWrites(t *testing.T, caller *driveScriptCaller, getCalls, putCalls int) { + t.Helper() + for _, tool := range []string{"create_folder", "download_file", "get_upload_info", "commit_upload"} { + if calls := caller.callsFor(tool); len(calls) != 0 { + t.Errorf("mirror preflight must not call %s: %v", tool, calls) + } + } + if getCalls != 0 || putCalls != 0 { + t.Errorf("mirror preflight must not transfer content: GET=%d PUT=%d", getCalls, putCalls) + } +} + +func TestCrossPlatformCoverageDrivePull_nonRegularTargetFailsBeforeDownload(t *testing.T) { + for _, dryRun := range []bool{false, true} { + name := "execute" + if dryRun { + name = "dry-run" + } + t.Run(name, func(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "blocked.txt"), 0o755); err != nil { + t.Fatal(err) + } + caller := syncCaller(map[string]string{ + "ROOT": `{"result":{"items":[{"name":"blocked.txt","type":"file","fileId":"REMOTE"}],"nextToken":""}}`, + }) + gets, puts := captureMirrorHTTP(t) + out, err := runDriveMirrorCommand(t, caller, dryRun, "pull", "--local-folder", root, "--remote-folder", "ROOT") + if dryRun { + if err != nil { + t.Fatalf("pull dry-run: %v", err) + } + var got drivePullDryRunResult + if jsonErr := json.Unmarshal(out, &got); jsonErr != nil { + t.Fatalf("pull dry-run output is not JSON: %v\n%s", jsonErr, out) + } + if got.Plan.Summary.Failed != 1 || len(got.Plan.Items) != 1 || !strings.Contains(got.Plan.Items[0].Error, "不是常规文件") { + t.Fatalf("pull dry-run plan = %+v, want one non-regular target failure", got.Plan) + } + } else { + var failure *drivePartialFailure + if !errors.As(err, &failure) || failure.failed != 1 { + t.Fatalf("pull error = %v, want one failed item", err) + } + } + assertMirrorPreflightNoWrites(t, caller, *gets, *puts) + }) + } +} + +func TestCrossPlatformCoverageDrivePull_nonRegularTargetAbortsWholeBatchBeforeDownload(t *testing.T) { + for _, command := range []string{"pull", "sync"} { + for _, dryRun := range []bool{false, true} { + name := command + "/execute" + if dryRun { + name = command + "/dry-run" + } + t.Run(name, func(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "z-blocked.txt"), 0o755); err != nil { + t.Fatal(err) + } + caller := syncCaller(map[string]string{ + "ROOT": `{"result":{"items":[{"name":"a-first.txt","type":"file","fileId":"FIRST"},{"name":"z-blocked.txt","type":"file","fileId":"BLOCKED"}],"nextToken":""}}`, + }) + gets, puts := captureMirrorHTTP(t) + args := []string{command, "--local-folder", root, "--remote-folder", "ROOT"} + if command == "sync" { + args = append(args, "--quick") + } + out, err := runDriveMirrorCommand(t, caller, dryRun, args...) + if dryRun { + if err != nil { + t.Fatalf("%s dry-run: %v", command, err) + } + if command == "pull" { + var got drivePullDryRunResult + if jsonErr := json.Unmarshal(out, &got); jsonErr != nil || got.Plan.Summary.Failed != 2 { + t.Fatalf("pull dry-run preflight = %+v, json error %v", got, jsonErr) + } + } else { + assertMirrorDryRunFailureJSON(t, command, out) + } + } else if err == nil { + t.Fatalf("%s must abort the whole batch", command) + } + if _, statErr := os.Stat(filepath.Join(root, "a-first.txt")); !os.IsNotExist(statErr) { + t.Fatalf("%s wrote an earlier file before discovering the conflict: %v", command, statErr) + } + assertMirrorPreflightNoWrites(t, caller, *gets, *puts) + }) + } + } +} + +func TestCrossPlatformCoverageDrivePull_remoteEquivalentTreeFailsBeforeDownload(t *testing.T) { + tests := []struct { + name string + listings map[string]string + wantFail int + }{ + { + name: "equivalent files", + listings: map[string]string{ + "ROOT": `{"result":{"items":[{"name":"A.txt","type":"file","fileId":"UP"},{"name":"a.txt","type":"file","fileId":"LOW"}],"nextToken":""}}`, + }, + wantFail: 2, + }, + { + name: "equivalent folder prefixes", + listings: map[string]string{ + "ROOT": `{"result":{"items":[{"name":"Folder","type":"folder","fileId":"UP_DIR"},{"name":"folder","type":"folder","fileId":"LOW_DIR"}],"nextToken":""}}`, + "UP_DIR": `{"result":{"items":[{"name":"up.txt","type":"file","fileId":"UP"}],"nextToken":""}}`, + "LOW_DIR": `{"result":{"items":[{"name":"low.txt","type":"file","fileId":"LOW"}],"nextToken":""}}`, + }, + wantFail: 2, + }, + } + for _, tt := range tests { + for _, dryRun := range []bool{false, true} { + name := tt.name + "/execute" + if dryRun { + name = tt.name + "/dry-run" + } + t.Run(name, func(t *testing.T) { + root := t.TempDir() + caller := syncCaller(tt.listings) + gets, puts := captureMirrorHTTP(t) + out, err := runDriveMirrorCommand(t, caller, dryRun, "pull", "--local-folder", root, "--remote-folder", "ROOT") + if dryRun { + if err != nil { + t.Fatalf("pull dry-run: %v", err) + } + var got drivePullDryRunResult + if jsonErr := json.Unmarshal(out, &got); jsonErr != nil { + t.Fatalf("pull dry-run output is not JSON: %v\n%s", jsonErr, out) + } + if got.Plan.Summary.Failed != tt.wantFail || got.Plan.Summary.Downloaded != 0 { + t.Fatalf("pull dry-run plan = %+v", got.Plan) + } + } else { + var failure *drivePartialFailure + if !errors.As(err, &failure) || failure.failed != tt.wantFail { + t.Fatalf("pull error = %v, want %d failed items", err, tt.wantFail) + } + } + if !strings.Contains(string(out), "等价路径") { + t.Fatalf("pull output must explain equivalent path ambiguity: %s", out) + } + assertMirrorPreflightNoWrites(t, caller, *gets, *puts) + }) + } + } +} + +func TestCrossPlatformCoverageDriveMirror_unsafeLocalNamesFailClosed(t *testing.T) { + if os.PathSeparator == '\\' { + t.Skip("current platform cannot create a local name containing a backslash") + } + for _, command := range []string{"push", "sync"} { + for _, dryRun := range []bool{false, true} { + name := command + "/execute" + if dryRun { + name = command + "/dry-run" + } + t.Run(name, func(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, `unsafe\name.txt`), "local") + caller := syncCaller(nil) + gets, puts := captureMirrorHTTP(t) + args := []string{command, "--local-folder", root, "--remote-folder", "ROOT"} + if command == "sync" { + args = append(args, "--quick") + } + out, err := runDriveMirrorCommand(t, caller, dryRun, args...) + if dryRun { + if err != nil { + t.Fatalf("%s dry-run: %v", command, err) + } + assertMirrorDryRunFailureJSON(t, command, out) + } else if err == nil { + t.Fatalf("%s must fail an unsafe local name", command) + } + if !strings.Contains(string(out), "无法安全映射到远端") { + t.Fatalf("%s output must explain unsafe local name: %s", command, out) + } + assertMirrorPreflightNoWrites(t, caller, *gets, *puts) + }) + } + } + + // NUL 不能由真实文件系统创建,直接覆盖契约 helper,避免该分支成为死角。 + preflight := buildMirrorPreflight(nil, []localPushFile{{RelPath: "bad\x00name.txt"}}, nil, map[string]string{"": "ROOT"}) + if !strings.Contains(preflight["bad\x00name.txt"], "无法安全映射到远端") { + t.Fatalf("NUL name preflight = %v", preflight) + } + if err := rejectNonRegularLocalTarget("bad\x00target"); err == nil || !strings.Contains(err.Error(), "检查本地目标失败") { + t.Fatalf("invalid local target error = %v", err) + } + + localCollision := buildMirrorPreflight(nil, []localPushFile{{RelPath: "A.txt"}, {RelPath: "a.txt"}}, nil, map[string]string{"": "ROOT"}) + for _, rel := range []string{"A.txt", "a.txt"} { + if !strings.Contains(localCollision[rel], "等价路径") { + t.Fatalf("local equivalent collision %q = %q", rel, localCollision[rel]) + } + } + prefixCollision := buildMirrorPreflight(nil, []localPushFile{{RelPath: "A/x.txt"}}, + map[string]*remoteFile{"a/y.txt": {RelPath: "a/y.txt"}}, map[string]string{"": "ROOT"}) + for _, rel := range []string{"A", "a"} { + if !strings.Contains(prefixCollision[rel], "等价路径") { + t.Fatalf("equivalent ancestor collision %q = %q", rel, prefixCollision[rel]) + } + } +} + +func TestCrossPlatformCoverageDriveMirror_crossSideEquivalentPathsFailBeforeWrites(t *testing.T) { + tests := []struct { + name string + prepare func(*testing.T, string) + listings map[string]string + }{ + { + name: "case-equivalent files", + prepare: func(t *testing.T, root string) { mustWrite(t, filepath.Join(root, "a.txt"), "local") }, + listings: map[string]string{ + "ROOT": `{"result":{"items":[{"name":"A.txt","type":"file","fileId":"REMOTE"}],"nextToken":""}}`, + }, + }, + { + name: "case-equivalent folder prefixes", + prepare: func(t *testing.T, root string) { + mustWrite(t, filepath.Join(root, "Folder", "local.txt"), "local") + }, + listings: map[string]string{ + "ROOT": `{"result":{"items":[{"name":"folder","type":"folder","fileId":"REMOTE_DIR"}],"nextToken":""}}`, + "REMOTE_DIR": `{"result":{"items":[{"name":"remote.txt","type":"file","fileId":"REMOTE_FILE"}],"nextToken":""}}`, + }, + }, + { + name: "unicode-equivalent files", + prepare: func(t *testing.T, root string) { mustWrite(t, filepath.Join(root, "caf\u00e9.txt"), "local") }, + listings: map[string]string{ + "ROOT": `{"result":{"items":[{"name":"cafe\u0301.txt","type":"file","fileId":"REMOTE"}],"nextToken":""}}`, + }, + }, + { + name: "equivalent path type ambiguity", + prepare: func(t *testing.T, root string) { mustWrite(t, filepath.Join(root, "item"), "local") }, + listings: map[string]string{ + "ROOT": `{"result":{"items":[{"name":"ITEM","type":"folder","fileId":"REMOTE_DIR"}],"nextToken":""}}`, + "REMOTE_DIR": `{"result":{"items":[],"nextToken":""}}`, + }, + }, + { + name: "remote-only equivalent files", + prepare: func(t *testing.T, root string) { mustWrite(t, filepath.Join(root, "safe-local.txt"), "local") }, + listings: map[string]string{ + "ROOT": `{"result":{"items":[{"name":"A.txt","type":"file","fileId":"UP"},{"name":"a.txt","type":"file","fileId":"LOW"}],"nextToken":""}}`, + }, + }, + { + name: "remote-only equivalent folders", + prepare: func(t *testing.T, root string) { mustWrite(t, filepath.Join(root, "safe-local.txt"), "local") }, + listings: map[string]string{ + "ROOT": `{"result":{"items":[{"name":"Folder","type":"folder","fileId":"UP_DIR"},{"name":"folder","type":"folder","fileId":"LOW_DIR"}],"nextToken":""}}`, + "UP_DIR": `{"result":{"items":[],"nextToken":""}}`, + "LOW_DIR": `{"result":{"items":[],"nextToken":""}}`, + }, + }, + } + + for _, tt := range tests { + for _, command := range []string{"push", "sync"} { + for _, dryRun := range []bool{false, true} { + name := tt.name + "/" + command + "/execute" + if dryRun { + name = tt.name + "/" + command + "/dry-run" + } + t.Run(name, func(t *testing.T) { + root := t.TempDir() + tt.prepare(t, root) + caller := syncCaller(tt.listings) + gets, puts := captureMirrorHTTP(t) + args := []string{command, "--local-folder", root, "--remote-folder", "ROOT"} + if command == "sync" { + args = append(args, "--quick") + } + out, err := runDriveMirrorCommand(t, caller, dryRun, args...) + if dryRun { + if err != nil { + t.Fatalf("%s dry-run: %v", command, err) + } + assertMirrorDryRunFailureJSON(t, command, out) + } else if err == nil { + t.Fatalf("%s must reject an equivalent cross-side path", command) + } + if !strings.Contains(string(out), "等价路径") { + t.Fatalf("%s output must explain equivalent path ambiguity: %s", command, out) + } + assertMirrorPreflightNoWrites(t, caller, *gets, *puts) + }) + } + } + } +} + +func assertMirrorDryRunFailureJSON(t *testing.T, command string, out []byte) { + t.Helper() + switch command { + case "push": + var got drivePushDryRunResult + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("push dry-run output is not JSON: %v\n%s", err, out) + } + if !got.DryRun || got.Executed || got.Operation != "drive push" || got.Plan.Summary.Failed == 0 || !got.Plan.Summary.Aborted { + t.Fatalf("push dry-run result = %+v", got) + } + case "sync": + var got driveSyncDryRunResult + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("sync dry-run output is not standalone JSON: %v\n%s", err, out) + } + if !got.DryRun || got.Executed || got.PreviewKind != "plan" || got.Operation != "drive sync" || got.Plan.Summary.Failed == 0 { + t.Fatalf("sync dry-run result = %+v", got) + } + default: + t.Fatalf("unsupported command %q", command) + } +} diff --git a/internal/helpers/drive_pull.go b/internal/helpers/drive_pull.go index c894d7f3..d8802d5e 100644 --- a/internal/helpers/drive_pull.go +++ b/internal/helpers/drive_pull.go @@ -3,6 +3,8 @@ package helpers import ( "context" "fmt" + "io" + "net/http" "os" "path/filepath" "runtime" @@ -10,6 +12,7 @@ import ( "strings" "time" + "github.com/google/uuid" "github.com/spf13/cobra" "golang.org/x/text/unicode/norm" ) @@ -33,9 +36,37 @@ const ( ifExistsSkip = "skip" // 默认:本地已存在则保持不动 ) -// pullCreateTemp 仅作为文件系统失败分支的确定性测试 seam;测试必须通过 -// testseam.Swap 替换并自动恢复。 -var pullCreateTemp = os.CreateTemp +// pull 下载路径使用 os.Root 固定根目录和目标父目录。以下 seam 只用于 +// 确定性覆盖文件系统与传输失败分支;测试必须通过 testseam.Swap 替换。 +var ( + pullMkdirAll = os.MkdirAll + pullOpenRoot = os.OpenRoot + pullRelPath = filepath.Rel + pullPathStat = os.Stat + pullPathLstat = os.Lstat + pullTargetLstat = os.Lstat + pullRootMkdir = func(root *os.Root, name string, mode os.FileMode) error { + return root.Mkdir(name, mode) + } + pullOpenParentRoot = func(root *os.Root, name string) (*os.Root, error) { + return root.OpenRoot(name) + } + pullRootStat = func(root *os.Root, name string) (os.FileInfo, error) { return root.Stat(name) } + pullRootLstat = func(root *os.Root, name string) (os.FileInfo, error) { return root.Lstat(name) } + pullCreateTemp = createPinnedPullTemp + pullTempName = uuid.NewString + pullSyncTemp = func(file *os.File) error { return file.Sync() } + pullCloseTemp = func(file *os.File) error { return file.Close() } + pullRename = func(root *os.Root, oldName, newName string) error { + return root.Rename(oldName, newName) + } + pullLink = func(root *os.Root, oldName, newName string) error { return root.Link(oldName, newName) } + pullRemove = func(root *os.Root, name string) error { return root.Remove(name) } + pullDownloadFile = defaultPullDownloadFile + pullHTTPDo = func(request *http.Request) (*http.Response, error) { + return (&http.Client{Timeout: 10 * time.Minute}).Do(request) + } +) // pull 动作分类。 const ( @@ -169,6 +200,13 @@ func runDrivePull(cmd *cobra.Command, _ []string) error { if err != nil { return err } + // 在任何远端读取之前固定本地根计划:既有根立即持有 os.Root;不存在的根只固定 + // 最近既存祖先并记录尚缺的逐层后缀。远端清单不完整或预检失败时不得创建本地根。 + rootPlan, err := planPinnedPullRoot(absDir) + if err != nil { + return err + } + defer rootPlan.close() ctx := cmd.Context() // 复用 status 的远端遍历:按 parentId 递归拿到所有 type=FILE 的文件(key 为 rel_path)。 @@ -183,41 +221,34 @@ func runDrivePull(cmd *cobra.Command, _ []string) error { relPaths = append(relPaths, rel) } sort.Strings(relPaths) + preflight, localInfo, err := buildDrivePullPreflightFromPlan(rootPlan, remote) + if err != nil { + return err + } if deps.Caller.DryRun() { - caseInsensitive := runtime.GOOS == "windows" || runtime.GOOS == "darwin" - return printDrivePullDryRun(absDir, ifExists, remote, relPaths, caseInsensitive) + return printDrivePullDryRunWithLocalInfo(ifExists, remote, relPaths, preflight, localInfo) } - - // 落盘前先按目标文件系统的路径等价规则做一次全局冲突检查:大小写不敏感 FS 上 - // A.txt 与 a.txt(或 NFC/NFD 异写)会落到同一本地文件;overwrite 会顺序覆盖、 - // 却把两项都报 downloaded 而静默丢文件。冲突的条目一律标记 failed、都不写入。 - // 探针需要根目录存在,这里按 pull「自动创建目标根」的语义先建好。 - if err := os.MkdirAll(absDir, 0o755); err != nil { - return fmt.Errorf("创建本地目录失败: %w", err) + // 镜像契约独立于当前主机文件系统:远端 A/a 或 NFC/NFD 异写(包括目录 + // 前缀)在任何落盘前整批拒绝,避免同一远端树在不同平台得到不同镜像。 + if len(preflight) > 0 { + res := drivePullPreflightFailureResult(relPaths, preflight) + if perr := deps.Out.PrintJSON(res); perr != nil { + return perr + } + return &drivePartialFailure{failed: res.Summary.Failed} } - collided := detectTargetCollisions(absDir, relPaths, isCaseInsensitiveFS(absDir)) + pinnedRoot, err := rootPlan.materialize() + if err != nil { + return err + } + defer pinnedRoot.close() res := drivePullResult{Items: make([]drivePullItem, 0, len(relPaths))} for _, rel := range relPaths { rf := remote[rel] - // 多个远端条目映射到同一本地目标 → 全部 failed,不写入任何一个,避免静默覆盖丢文件。 - if collided[rel] { - res.Summary.Failed++ - res.Items = append(res.Items, drivePullItem{ - RelPath: rel, Action: pullActionFailed, - Error: "多个远端条目在当前文件系统上映射到同一本地路径(大小写/规范化冲突),已跳过以避免覆盖丢失", - }) - continue - } - // 二次确认下载目标仍在本地根目录内,逃逸路径记为 failed 而非落盘。 - localPath, terr := resolveLocalTarget(absDir, rel) - if terr != nil { - res.Summary.Failed++ - res.Items = append(res.Items, drivePullItem{RelPath: rel, Action: pullActionFailed, Error: terr.Error()}) - continue - } - - action, perr := pullOneFile(ctx, spaceID, rf, localPath, ifExists) + // preflight 与真正下载之间仍需二次解析,挡住祖先目录被并发替换为软链的 + // TOCTOU 逃逸;pullRemoteFile 将该检查收口在写入入口前。 + action, perr := pullRemoteFilePinned(ctx, spaceID, rf, pinnedRoot, rel, ifExists) item := drivePullItem{RelPath: rel, Action: action} switch action { case pullActionDownloaded: @@ -244,25 +275,37 @@ func runDrivePull(cmd *cobra.Command, _ []string) error { } func printDrivePullDryRun(absDir, ifExists string, remote map[string]*remoteFile, relPaths []string, caseInsensitive bool) error { - plan := drivePullResult{Items: make([]drivePullItem, 0, len(relPaths))} - collided := detectTargetCollisions(absDir, relPaths, caseInsensitive) + return printDrivePullDryRunWithPreflight(absDir, ifExists, remote, relPaths, caseInsensitive, + buildDrivePullPreflight(absDir, remote)) +} + +func printDrivePullDryRunWithPreflight(absDir, ifExists string, remote map[string]*remoteFile, relPaths []string, caseInsensitive bool, preflight map[string]string) error { + localInfo := make(map[string]os.FileInfo, len(relPaths)) for _, rel := range relPaths { - if collided[rel] { - plan.Summary.Failed++ - plan.Items = append(plan.Items, drivePullItem{ - RelPath: rel, Action: pullActionFailed, - Error: "多个远端条目在目标平台默认文件系统上映射到同一本地路径,计划已拒绝", - }) - continue - } localPath, err := resolveLocalTarget(absDir, rel) if err != nil { - plan.Summary.Failed++ - plan.Items = append(plan.Items, drivePullItem{RelPath: rel, Action: pullActionFailed, Error: err.Error()}) continue } + if info, statErr := os.Stat(localPath); statErr == nil && info.Mode().IsRegular() { + localInfo[rel] = info + } + } + _ = caseInsensitive // 保留 helper 的平台预览参数兼容性。 + return printDrivePullDryRunWithLocalInfo(ifExists, remote, relPaths, preflight, localInfo) +} + +func printDrivePullDryRunWithLocalInfo(ifExists string, remote map[string]*remoteFile, relPaths []string, preflight map[string]string, localInfo map[string]os.FileInfo) error { + plan := drivePullResult{Items: make([]drivePullItem, 0, len(relPaths))} + if len(preflight) > 0 { + plan = drivePullPreflightFailureResult(relPaths, preflight) + return deps.Out.PrintJSON(drivePullDryRunResult{ + DryRun: true, Executed: false, PreviewKind: "plan", Operation: "drive pull", + IfExists: ifExists, Plan: plan, + }) + } + for _, rel := range relPaths { action := pullActionDownloaded - if fi, statErr := os.Stat(localPath); statErr == nil && fi.Mode().IsRegular() { + if fi := localInfo[rel]; fi != nil { switch ifExists { case ifExistsSkip: action = pullActionSkipped @@ -286,18 +329,130 @@ func printDrivePullDryRun(absDir, ifExists string, remote map[string]*remoteFile }) } +// buildDrivePullPreflight 在创建本地根目录、探测文件系统或下载任一文件之前, +// 一次性验证完整远端树及每个本地落盘目标。任一目标是目录、符号链接、设备文件, +// 或经既有符号链接逃逸时,整批 pull 都必须零写入中止。 +func buildDrivePullPreflight(absDir string, remote map[string]*remoteFile) map[string]string { + failures := buildMirrorPreflight(nil, nil, remote, nil) + addLocalTargetPreflightFailures(absDir, remote, failures) + return failures +} + +// buildDrivePullPreflightFromPlan 只从 list_files 前已固定的根计划读取本地状态。 +// existing 根不再按 absDir 重开路径;missing 根在 ancestor Root 下仍必须完整缺失。 +func buildDrivePullPreflightFromPlan(plan *pinnedPullRootPlan, remote map[string]*remoteFile) (map[string]string, map[string]os.FileInfo, error) { + failures := buildMirrorPreflight(nil, nil, remote, nil) + localInfo := make(map[string]os.FileInfo, len(remote)) + if err := plan.verify(); err != nil { + return nil, nil, err + } + if plan.existing != nil { + addPinnedLocalTargetPreflightFailures(plan.existing, remote, failures) + for rel := range remote { + if mirrorPreflightFailureForRel(rel, failures) != "" { + continue + } + info, err := pullRootLstat(plan.existing.root, filepath.FromSlash(rel)) + if err == nil && info.Mode().IsRegular() { + localInfo[rel] = info + } + } + } + if err := plan.verify(); err != nil { + return nil, nil, err + } + return failures, localInfo, nil +} + +func addLocalTargetPreflightFailures(absDir string, remote map[string]*remoteFile, failures map[string]string) { + for rel := range remote { + // 已被名称/类型冲突根屏蔽的后代不重复计错;一个冲突根只报告一次。 + if mirrorPreflightFailureForRel(rel, failures) != "" { + continue + } + localPath, err := resolveLocalTarget(absDir, rel) + if err == nil { + err = rejectNonRegularLocalTarget(localPath) + } + if err != nil { + failures[rel] = err.Error() + } + } +} + +func drivePullPreflightFailureResult(relPaths []string, preflight map[string]string) drivePullResult { + res := drivePullResult{Items: make([]drivePullItem, 0, len(relPaths))} + for _, rel := range relPaths { + msg := mirrorPreflightFailureForRel(rel, preflight) + if msg == "" { + msg = "另一镜像条目未通过预检,整批拉取已中止" + } + res.Summary.Failed++ + res.Items = append(res.Items, drivePullItem{RelPath: rel, Action: pullActionFailed, Error: msg}) + } + return res +} + +func mirrorPreflightFailureForRel(rel string, preflight map[string]string) string { + for rel != "" { + if msg := preflight[rel]; msg != "" { + return msg + } + rel, _ = splitRel(rel) + } + return "" +} + +func pullRemoteFile(ctx context.Context, spaceID string, rf *remoteFile, absDir, rel, ifExists string) (string, error) { + return pullOneFileAtRoot(ctx, spaceID, rf, absDir, rel, ifExists) +} + +func pullRemoteFilePinned(ctx context.Context, spaceID string, rf *remoteFile, root *pinnedPullRoot, rel, ifExists string) (string, error) { + target, err := root.openTarget(rel) + if err != nil { + return pullActionFailed, err + } + defer target.close() + return pullOneFilePinned(ctx, spaceID, rf, target, ifExists) +} + // pullOneFile 处理单个远端文件:按 --if-exists 决定是否跳过,否则下载到 localPath。 // 返回动作分类(downloaded / skipped / failed)及失败时的 error。 func pullOneFile(ctx context.Context, spaceID string, rf *remoteFile, localPath, ifExists string) (string, error) { + return pullOneFileAtRoot(ctx, spaceID, rf, filepath.Dir(localPath), filepath.Base(localPath), ifExists) +} + +func pullOneFileAtRoot(ctx context.Context, spaceID string, rf *remoteFile, absDir, rel, ifExists string) (string, error) { + root, err := openPinnedPullRoot(absDir) + if err != nil { + return pullActionFailed, err + } + defer root.close() + target, err := root.openTarget(rel) + if err != nil { + return pullActionFailed, err + } + defer target.close() + return pullOneFilePinned(ctx, spaceID, rf, target, ifExists) +} + +func pullOneFilePinned(ctx context.Context, spaceID string, rf *remoteFile, target *pinnedPullTarget, ifExists string) (string, error) { + // 镜像文件的目标若已存在,必须是常规文件。目录、设备、FIFO 等都不能交给 + // download_file / 临时文件写入流程,否则 dry-run 与执行结论会分叉,且目录目标 + // 会在发出远端读请求后才失败。 + initialInfo, err := target.regularTargetInfo() + if err != nil { + return pullActionFailed, err + } // 本地已存在常规文件时,按策略判断是否跳过下载。 - if fi, statErr := os.Stat(localPath); statErr == nil && fi.Mode().IsRegular() { + if initialInfo != nil { switch ifExists { case ifExistsSkip: return pullActionSkipped, nil case ifExistsSmart: // 远端时间可信且本地 mtime 已 ≥ 远端 → 视为已对齐,跳过; // 时间缺失/非法时不盲跳,退回继续下载。 - if rf.ModifiedTimeValid && fi.ModTime().UnixMilli() >= rf.ModifiedTime { + if rf.ModifiedTimeValid && initialInfo.ModTime().UnixMilli() >= rf.ModifiedTime { return pullActionSkipped, nil } case ifExistsOverwrite: @@ -319,39 +474,501 @@ func pullOneFile(ctx context.Context, spaceID string, rf *remoteFile, localPath, return pullActionFailed, err } - dir := filepath.Dir(localPath) - if err := os.MkdirAll(dir, 0o755); err != nil { - return pullActionFailed, fmt.Errorf("创建本地目录失败: %w", err) + // download_file 可执行任意时长的远端读。在任何 HTTP GET 或临时文件写入前, + // 必须确认目标父目录仍是最初固定在本地根下的同一目录。 + if err := target.verifyParent(); err != nil { + return pullActionFailed, err } - // 先下载到同目录下的临时文件,完整落盘后再原子 rename 覆盖目标:任何中途失败 - // (网络中断、超时、写盘错误)都不会截断或破坏已存在的原文件。同目录保证 - // rename 在同一文件系统内、可原子替换;rename 会替换目标符号链接本身而非跟随它。 - tmp, err := pullCreateTemp(dir, ".dws-pull-*") + // 临时文件通过已固定的 os.Root 直接打开,下载直接写已打开的句柄; + // 生产路径不会再按可被换链的绝对路径重新打开临时文件。 + tmp, tmpName, err := pullCreateTemp(target.parentRoot) if err != nil { return pullActionFailed, fmt.Errorf("创建临时文件失败: %w", err) } - tmpPath := tmp.Name() - _ = tmp.Close() // httpGetFile 会自行 os.Create 覆盖该临时文件 // 除非成功 rename,否则始终清理临时文件,不留半成品。 committed := false + tmpInfo, err := tmp.Stat() + if err != nil { + _ = tmp.Close() + _ = target.parentRoot.Remove(tmpName) + return pullActionFailed, fmt.Errorf("读取临时文件身份失败: %w", err) + } defer func() { + _ = tmp.Close() if !committed { - _ = os.Remove(tmpPath) + _ = target.parentRoot.Remove(tmpName) } }() - if err := httpGetFile(ctx, resourceURL, headers, tmpPath); err != nil { + if err := pullDownloadFile(ctx, resourceURL, headers, tmp); err != nil { return pullActionFailed, err } - // 对齐 mtime 放在 rename 之前,保证替换后的目标文件时间戳即为远端时间。 + // 通过仍打开的文件句柄设置 mtime,避免按 tmpName 重新解析路径产生 symlink TOCTOU。 + // 时间对齐延续既有 best-effort 语义,失败不影响已完整下载内容的发布。 if rf.ModifiedTimeValid { - t := time.UnixMilli(rf.ModifiedTime) - _ = os.Chtimes(tmpPath, t, t) + _ = setPullFileTimes(tmp, time.UnixMilli(rf.ModifiedTime)) } - if err := driveReplaceFile(tmpPath, localPath); err != nil { + if err := pullSyncTemp(tmp); err != nil { + return pullActionFailed, fmt.Errorf("同步临时文件失败: %w", err) + } + if err := pullCloseTemp(tmp); err != nil { + return pullActionFailed, fmt.Errorf("关闭临时文件失败: %w", err) + } + currentTmp, err := pullRootLstat(target.parentRoot, tmpName) + if err != nil || !os.SameFile(tmpInfo, currentTmp) { + return pullActionFailed, fmt.Errorf("临时文件在下载期间被替换,已中止发布") + } + // 网络传输期间父目录仍可能被移走或替换。发布前再核对父目录身份和 + // 目标类型;不一致时只删除固定目录内的临时文件,绝不发布。 + if err := target.verifyParent(); err != nil { + return pullActionFailed, err + } + currentTarget, err := target.regularTargetInfo() + if err != nil { + return pullActionFailed, err + } + if ifExists != ifExistsOverwrite { + if currentTarget != nil && (initialInfo == nil || !os.SameFile(initialInfo, currentTarget)) { + return pullActionSkipped, nil + } + if ifExists == ifExistsSmart && currentTarget != nil && rf.ModifiedTimeValid && currentTarget.ModTime().UnixMilli() >= rf.ModifiedTime { + return pullActionSkipped, nil + } + } + if currentTarget == nil && ifExists != ifExistsOverwrite { + if err := pullLink(target.parentRoot, tmpName, target.name); err != nil { + if concurrent, statErr := target.regularTargetInfo(); statErr == nil && concurrent != nil { + return pullActionSkipped, nil + } + return pullActionFailed, fmt.Errorf("发布目标文件失败: %w", err) + } + if err := pullRemove(target.parentRoot, tmpName); err != nil { + return pullActionFailed, fmt.Errorf("清理临时文件失败: %w", err) + } + } else if err := pullRename(target.parentRoot, tmpName, target.name); err != nil { return pullActionFailed, fmt.Errorf("替换目标文件失败: %w", err) } + finalInfo, err := pullRootLstat(target.parentRoot, target.name) + if err != nil || !os.SameFile(tmpInfo, finalInfo) { + // 身份不匹配意味着发布后 terminal entry 可能已被并发替换;保留未知对象并报错, + // 不做 Lstat→Remove 的第二次 check/use,以免误删用户并发创建的文件。 + return pullActionFailed, fmt.Errorf("发布后的目标文件身份不一致") + } + if err := target.verifyParent(); err != nil { + // 固定目录已从命令根路径移走时,保留句柄内已完成的结果并报失败;不能无条件 + // Remove,因为发布后同名 terminal entry 仍可能被并发替换。 + return pullActionFailed, err + } committed = true return pullActionDownloaded, nil } + +type pinnedPullRoot struct { + absDir string + root *os.Root + rootInfo os.FileInfo + pathInfo os.FileInfo +} + +// pinnedPullRootPlan 在远端清单读取前固定 pull 的本地根状态。existing 非空表示 +// absDir 当时已存在;否则 ancestor 固定最近既存祖先,missing 保存从祖先到 absDir +// 的逐层目录名。missing 只有在完整远端清单和本地预检通过后才允许物化。 +type pinnedPullRootPlan struct { + absDir string + existing *pinnedPullRoot + ancestor *pinnedPullRoot + missing []string +} + +type pinnedPullTarget struct { + base *pinnedPullRoot + parentRoot *os.Root + parentChain []pinnedPullDirIdentity + name string + ownsBase bool +} + +type pinnedPullDirIdentity struct { + rel string + info os.FileInfo +} + +func planPinnedPullRoot(absDir string) (*pinnedPullRootPlan, error) { + info, err := pullPathStat(absDir) + if err == nil { + if !info.IsDir() { + return nil, fmt.Errorf("创建本地目录失败: %s 已存在且不是目录", absDir) + } + root, openErr := openExistingPinnedPullRoot(absDir) + if openErr != nil { + return nil, openErr + } + return &pinnedPullRootPlan{absDir: absDir, existing: root}, nil + } + if !os.IsNotExist(err) { + return nil, fmt.Errorf("检查本地目录失败: %w", err) + } + + ancestorPath := absDir + missing := make([]string, 0, 4) + for { + parent := filepath.Dir(ancestorPath) + // absDir 已是验证过的绝对路径;文件系统根目录始终存在, + // 因此循环必然在到达 parent == ancestorPath 之前以 Stat 成功结束。 + name := filepath.Base(ancestorPath) + missing = append([]string{name}, missing...) + ancestorPath = parent + info, statErr := pullPathStat(ancestorPath) + if statErr == nil { + if !info.IsDir() { + return nil, fmt.Errorf("创建本地目录失败: %s 已存在且不是目录", ancestorPath) + } + break + } + if !os.IsNotExist(statErr) { + return nil, fmt.Errorf("检查本地目录失败: %w", statErr) + } + } + ancestor, openErr := openExistingPinnedPullRoot(ancestorPath) + if openErr != nil { + return nil, openErr + } + plan := &pinnedPullRootPlan{absDir: absDir, ancestor: ancestor, missing: missing} + if verifyErr := plan.verify(); verifyErr != nil { + plan.close() + return nil, verifyErr + } + return plan, nil +} + +func (plan *pinnedPullRootPlan) verify() error { + if plan.existing != nil { + return plan.existing.verify() + } + if plan.ancestor == nil { + return fmt.Errorf("本地根目录计划无效") + } + if err := plan.ancestor.verify(); err != nil { + return err + } + prefix := "" + for _, segment := range plan.missing { + prefix = filepath.Join(prefix, segment) + _, err := pullRootLstat(plan.ancestor.root, prefix) + if err == nil { + return fmt.Errorf("本地根目录 %q 在远端读取期间被占用,已中止写入", plan.absDir) + } + if !os.IsNotExist(err) { + return fmt.Errorf("检查本地根目录计划失败: %w", err) + } + // 第一层缺失后更深的路径不可能存在;物化时每层 Mkdir 仍以 no-clobber + // 语义拒绝 verify 与创建之间的并发抢占。 + break + } + return nil +} + +func (plan *pinnedPullRootPlan) materialize() (*pinnedPullRoot, error) { + if err := plan.verify(); err != nil { + return nil, err + } + if plan.existing != nil { + root := plan.existing + plan.existing = nil + return root, nil + } + + current, err := pullOpenParentRoot(plan.ancestor.root, ".") + if err != nil { + return nil, fmt.Errorf("固定本地根祖先失败: %w", err) + } + for _, segment := range plan.missing { + if err := pullRootMkdir(current, segment, 0o755); err != nil { + _ = current.Close() + return nil, fmt.Errorf("创建本地目录失败: %w", err) + } + createdInfo, err := pullRootLstat(current, segment) + if err != nil || !createdInfo.IsDir() { + _ = current.Close() + return nil, fmt.Errorf("读取新建本地目录身份失败") + } + next, err := pullOpenParentRoot(current, segment) + if err != nil { + _ = current.Close() + return nil, fmt.Errorf("固定新建本地目录失败: %w", err) + } + nextInfo, statErr := pullRootStat(next, ".") + if statErr != nil || !os.SameFile(createdInfo, nextInfo) { + _ = next.Close() + _ = current.Close() + return nil, fmt.Errorf("新建本地目录在固定期间被替换,已中止写入") + } + _ = current.Close() + current = next + } + rootInfo, err := pullRootStat(current, ".") + if err != nil { + _ = current.Close() + return nil, fmt.Errorf("读取本地根目录身份失败: %w", err) + } + pathInfo, err := pullPathLstat(plan.absDir) + if err != nil { + _ = current.Close() + return nil, fmt.Errorf("读取本地根目录路径身份失败: %w", err) + } + root := &pinnedPullRoot{absDir: plan.absDir, root: current, rootInfo: rootInfo, pathInfo: pathInfo} + if err := root.verify(); err != nil { + root.close() + return nil, err + } + return root, nil +} + +func (plan *pinnedPullRootPlan) close() { + if plan.existing != nil { + plan.existing.close() + plan.existing = nil + } + if plan.ancestor != nil { + plan.ancestor.close() + plan.ancestor = nil + } +} + +func openPinnedPullRoot(absDir string) (*pinnedPullRoot, error) { + if err := pullMkdirAll(absDir, 0o755); err != nil { + return nil, fmt.Errorf("创建本地目录失败: %w", err) + } + return openExistingPinnedPullRoot(absDir) +} + +// openExistingPinnedPullRoot 只固定已经存在的本地目录。push/sync 必须用这一入口: +// 本地源根不存在时直接失败,不能为了扫描或上传隐式创建一个空源目录。 +func openExistingPinnedPullRoot(absDir string) (*pinnedPullRoot, error) { + baseRoot, err := pullOpenRoot(absDir) + if err != nil { + return nil, fmt.Errorf("打开本地根目录失败: %w", err) + } + rootInfo, err := pullRootStat(baseRoot, ".") + if err != nil { + _ = baseRoot.Close() + return nil, fmt.Errorf("读取本地根目录身份失败: %w", err) + } + pathInfo, err := pullPathLstat(absDir) + if err != nil { + _ = baseRoot.Close() + return nil, fmt.Errorf("读取本地根目录路径身份失败: %w", err) + } + root := &pinnedPullRoot{absDir: absDir, root: baseRoot, rootInfo: rootInfo, pathInfo: pathInfo} + if err := root.verify(); err != nil { + root.close() + return nil, err + } + return root, nil +} + +func openPinnedPullTarget(absDir, rel string) (*pinnedPullTarget, error) { + root, err := openPinnedPullRoot(absDir) + if err != nil { + return nil, err + } + target, err := root.openTarget(rel) + if err != nil { + root.close() + return nil, err + } + target.ownsBase = true + return target, nil +} + +func (root *pinnedPullRoot) openTarget(rel string) (*pinnedPullTarget, error) { + if err := root.verify(); err != nil { + return nil, err + } + localPath := filepath.Join(root.absDir, filepath.FromSlash(rel)) + localRel, err := pullRelPath(root.absDir, localPath) + if err != nil || localRel == ".." || strings.HasPrefix(localRel, ".."+string(filepath.Separator)) { + return nil, fmt.Errorf("远端路径 %q 逃逸出本地根目录", rel) + } + parentRel := filepath.Dir(localRel) + parentRoot, parentChain, err := root.openTargetParent(parentRel) + if err != nil { + return nil, err + } + target := &pinnedPullTarget{ + base: root, parentRoot: parentRoot, parentChain: parentChain, + name: filepath.Base(localRel), + } + if err := target.verifyParent(); err != nil { + target.close() + return nil, err + } + return target, nil +} + +// openTargetParent 从固定根开始逐层创建并打开目标父目录。os.Root 会安全地阻止 +// 软链接逃逸根外,但允许跟随仍指向根内的相对软链接;镜像语义不能接受这种重定向, +// 否则 remote sub/a.txt 可能覆盖同一根下 victim/a.txt。因此每一层都必须: +// +// 1. 以 Lstat 拒绝软链接 / junction 及非目录; +// 2. 通过上一层已固定的 Root 打开下一层; +// 3. 再以 Lstat + SameFile 证明打开的就是目录项本身,而不是竞态中换入的链接目标。 +// +// parentChain 保存每一层目录项身份,后续每次 verifyParent 都从命令根重新复核。 +func (root *pinnedPullRoot) openTargetParent(parentRel string) (*os.Root, []pinnedPullDirIdentity, error) { + current, err := pullOpenParentRoot(root.root, ".") + if err != nil { + return nil, nil, fmt.Errorf("固定本地目标目录失败: %w", err) + } + chain := make([]pinnedPullDirIdentity, 0, 4) + if parentRel == "." { + info, err := pullRootLstat(root.root, ".") + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + _ = current.Close() + return nil, nil, fmt.Errorf("本地目标目录不是可固定的真实目录") + } + openedInfo, statErr := pullRootStat(current, ".") + if statErr != nil || !os.SameFile(info, openedInfo) { + _ = current.Close() + return nil, nil, fmt.Errorf("本地目标目录在固定期间被替换,已中止写入") + } + chain = append(chain, pinnedPullDirIdentity{rel: ".", info: info}) + return current, chain, nil + } + + prefix := "" + for _, segment := range strings.Split(parentRel, string(filepath.Separator)) { + info, lstatErr := pullRootLstat(current, segment) + if os.IsNotExist(lstatErr) { + if mkdirErr := pullRootMkdir(current, segment, 0o755); mkdirErr != nil { + _ = current.Close() + return nil, nil, fmt.Errorf("创建本地目录失败: %w", mkdirErr) + } + info, lstatErr = pullRootLstat(current, segment) + } + if lstatErr != nil { + _ = current.Close() + return nil, nil, fmt.Errorf("检查本地目标目录失败: %w", lstatErr) + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + _ = current.Close() + return nil, nil, fmt.Errorf("本地目标目录 %q 已存在且不是可固定的真实目录,已拒绝镜像", filepath.Join(prefix, segment)) + } + + next, openErr := pullOpenParentRoot(current, segment) + if openErr != nil { + _ = current.Close() + return nil, nil, fmt.Errorf("固定本地目标目录失败: %w", openErr) + } + openedInfo, statErr := pullRootStat(next, ".") + currentInfo, currentErr := pullRootLstat(current, segment) + if statErr != nil || currentErr != nil || !currentInfo.IsDir() || currentInfo.Mode()&os.ModeSymlink != 0 || + !os.SameFile(info, currentInfo) || !os.SameFile(currentInfo, openedInfo) { + _ = next.Close() + _ = current.Close() + return nil, nil, fmt.Errorf("本地目标目录在固定期间被替换,已中止写入") + } + prefix = filepath.Join(prefix, segment) + chain = append(chain, pinnedPullDirIdentity{rel: prefix, info: currentInfo}) + _ = current.Close() + current = next + } + return current, chain, nil +} + +func (root *pinnedPullRoot) verify() error { + current, err := pullPathStat(root.absDir) + currentPath, pathErr := pullPathLstat(root.absDir) + if err != nil || pathErr != nil || !os.SameFile(root.rootInfo, current) || !os.SameFile(root.pathInfo, currentPath) { + return fmt.Errorf("本地根目录在同步期间被替换,已中止写入") + } + return nil +} + +func (root *pinnedPullRoot) close() { _ = root.root.Close() } + +func (target *pinnedPullTarget) close() { + _ = target.parentRoot.Close() + if target.ownsBase { + target.base.close() + } +} + +func (target *pinnedPullTarget) verifyParent() error { + if err := target.base.verify(); err != nil { + return err + } + for _, expected := range target.parentChain { + current, err := pullRootLstat(target.base.root, expected.rel) + if err != nil || !current.IsDir() || current.Mode()&os.ModeSymlink != 0 || !os.SameFile(expected.info, current) { + return fmt.Errorf("本地目标目录在下载期间被替换,已中止写入") + } + } + return nil +} + +func (target *pinnedPullTarget) regularTargetInfo() (os.FileInfo, error) { + info, err := pullRootLstat(target.parentRoot, target.name) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("检查本地目标失败: %w", err) + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("本地目标 %q 已存在且不是常规文件,已拒绝覆盖", target.name) + } + return info, nil +} + +func createPinnedPullTemp(root *os.Root) (*os.File, string, error) { + for i := 0; i < 100; i++ { + name := ".dws-pull-" + pullTempName() + file, err := root.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600) + if err == nil { + return file, name, nil + } + if !os.IsExist(err) { + return nil, "", err + } + } + return nil, "", fmt.Errorf("创建不重名临时文件失败") +} + +func defaultPullDownloadFile(ctx context.Context, url string, headers map[string]string, destination *os.File) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + for key, value := range headers { + req.Header.Set(key, value) + } + resp, err := pullHTTPDo(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)} + } + _, err = io.Copy(destination, resp.Body) + return err +} + +// rejectNonRegularLocalTarget 在任何下载工具调用或临时文件创建之前,拒绝已存在但 +// 不是常规文件的镜像目标。不存在的目标可由后续流程安全创建。 +func rejectNonRegularLocalTarget(localPath string) error { + info, err := pullTargetLstat(localPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("检查本地目标失败: %w", err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("本地目标 %q 已存在且不是常规文件,已拒绝覆盖", localPath) + } + return nil +} diff --git a/internal/helpers/drive_pull_coverage_test.go b/internal/helpers/drive_pull_coverage_test.go index 8809f9b5..6d1c63ed 100644 --- a/internal/helpers/drive_pull_coverage_test.go +++ b/internal/helpers/drive_pull_coverage_test.go @@ -9,6 +9,8 @@ import ( "strconv" "strings" "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" ) // ────────────────────────────────────────────────────────── @@ -38,10 +40,9 @@ func TestCrossPlatformCoverageDrivePull_endToEndDownloadsAll(t *testing.T) { `{"name":"a.txt","type":"file","fileId":"A","modifyTime":1000},` + `{"name":"b.txt","type":"file","fileId":"B","modifyTime":2000}`) - SetHTTPGetFile(func(_ context.Context, _ string, _ map[string]string, dest string) error { + swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error { return os.WriteFile(dest, []byte("remote"), 0o644) }) - t.Cleanup(func() { SetHTTPGetFile(nil) }) if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT"); err != nil { t.Fatalf("unexpected error: %v", err) @@ -59,11 +60,10 @@ func TestCrossPlatformCoverageDrivePull_ifExistsSkipKeepsLocal(t *testing.T) { mustWrite(t, filepath.Join(root, "a.txt"), "local-original") caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":9000}`) - SetHTTPGetFile(func(context.Context, string, map[string]string, string) error { + swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error { t.Error("skip must not download") return nil }) - t.Cleanup(func() { SetHTTPGetFile(nil) }) if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT", "--if-exists", "skip"); err != nil { t.Fatalf("unexpected error: %v", err) @@ -82,11 +82,10 @@ func TestCrossPlatformCoverageDrivePull_ifExistsSmart(t *testing.T) { local := readFileMTimeMillis(t, p) caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":` + strconv.FormatInt(local-5000, 10) + `}`) - SetHTTPGetFile(func(context.Context, string, map[string]string, string) error { + swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error { t.Error("smart must not download when local is newer") return nil }) - t.Cleanup(func() { SetHTTPGetFile(nil) }) if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT", "--if-exists", "smart"); err != nil { t.Fatalf("unexpected error: %v", err) @@ -103,10 +102,9 @@ func TestCrossPlatformCoverageDrivePull_ifExistsSmart(t *testing.T) { local := readFileMTimeMillis(t, p) caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":` + strconv.FormatInt(local+60000, 10) + `}`) - SetHTTPGetFile(func(_ context.Context, _ string, _ map[string]string, dest string) error { + swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error { return os.WriteFile(dest, []byte("fresh"), 0o644) }) - t.Cleanup(func() { SetHTTPGetFile(nil) }) if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT", "--if-exists", "smart"); err != nil { t.Fatalf("unexpected error: %v", err) @@ -130,10 +128,9 @@ func TestCrossPlatformCoverageDrivePull_invalidIfExistsRejected(t *testing.T) { func TestCrossPlatformCoverageDrivePull_passesSpaceIDToDownload(t *testing.T) { root := t.TempDir() caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":1}`) - SetHTTPGetFile(func(_ context.Context, _ string, _ map[string]string, dest string) error { + swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error { return os.WriteFile(dest, []byte("x"), 0o644) }) - t.Cleanup(func() { SetHTTPGetFile(nil) }) if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT", "--space-id", "SP9"); err != nil { t.Fatalf("unexpected error: %v", err) @@ -147,14 +144,11 @@ func TestCrossPlatformCoverageDrivePull_passesSpaceIDToDownload(t *testing.T) { func TestCrossPlatformCoverageDrivePull_downloadFailureIsPartialFailure(t *testing.T) { root := t.TempDir() caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":1}`) - SetHTTPGetFile(func(context.Context, string, map[string]string, string) error { return errTestDownload }) - t.Cleanup(func() { SetHTTPGetFile(nil) }) + swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error { return errTestDownload }) var out strings.Builder - prevDeps, prevArgs := deps, os.Args - deps = &Deps{Caller: caller, Out: &Formatter{w: &out}} - os.Args = []string{"dws", "drive", "pull"} - t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs }) + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive", "pull"}) cmd := findDriveSubcommand(t, "pull") mustSetFlags(t, cmd, map[string]string{"local-folder": root, "remote-folder": "ROOT"}) @@ -229,10 +223,9 @@ func TestCrossPlatformCoverageDrivePull_escapingRelPathIsFailed(t *testing.T) { } return `{"result":{"items":[],"nextToken":""}}`, nil }} - SetHTTPGetFile(func(_ context.Context, _ string, _ map[string]string, dest string) error { + swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error { return os.WriteFile(dest, []byte("leak"), 0o644) }) - t.Cleanup(func() { SetHTTPGetFile(nil) }) if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT"); err == nil { t.Fatal("expected symlink escape to be reported as failure") @@ -248,14 +241,8 @@ func TestCrossPlatformCoveragePullOneFile_mkdirFailureIsFailed(t *testing.T) { // "sub" 是普通文件,MkdirAll("sub") 必然失败。 mustWrite(t, filepath.Join(root, "sub"), "occupied") - prevDeps := deps - prevArgs := os.Args - deps = &Deps{Caller: pullListingCaller(""), Out: &Formatter{w: io.Discard}} - os.Args = []string{"dws", "drive"} - t.Cleanup(func() { - deps = prevDeps - os.Args = prevArgs - }) + testseam.Swap(t, &deps, &Deps{Caller: pullListingCaller(""), Out: &Formatter{w: io.Discard}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive"}) rf := &remoteFile{RelPath: "sub/x.txt", FileID: "X"} action, err := pullOneFile(context.Background(), "", rf, filepath.Join(root, "sub", "x.txt"), ifExistsOverwrite) diff --git a/internal/helpers/drive_pull_plan_coverage_test.go b/internal/helpers/drive_pull_plan_coverage_test.go new file mode 100644 index 00000000..0c155338 --- /dev/null +++ b/internal/helpers/drive_pull_plan_coverage_test.go @@ -0,0 +1,507 @@ +package helpers + +import ( + "context" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func newPinnedPullRootForCoverage(t *testing.T, path string) *pinnedPullRoot { + t.Helper() + root, err := openExistingPinnedPullRoot(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(root.close) + return root +} + +func newMissingPinnedPullPlanForCoverage(t *testing.T) *pinnedPullRootPlan { + t.Helper() + root := filepath.Join(t.TempDir(), "missing", "mirror") + plan, err := planPinnedPullRoot(root) + if err != nil { + t.Fatal(err) + } + t.Cleanup(plan.close) + return plan +} + +func TestCrossPlatformCoverageDrivePullRootPlanFailures(t *testing.T) { + t.Run("existing root open", func(t *testing.T) { + testseam.Swap(t, &pullOpenRoot, func(string) (*os.Root, error) { return nil, errTestDownload }) + if _, err := planPinnedPullRoot(t.TempDir()); err == nil { + t.Fatal("expected existing-root open failure") + } + }) + + t.Run("ancestor is a file", func(t *testing.T) { + parent := t.TempDir() + file := filepath.Join(parent, "file") + mustWrite(t, file, "x") + if _, err := planPinnedPullRoot(filepath.Join(file, "child")); err == nil || + (!strings.Contains(err.Error(), "不是目录") && !strings.Contains(err.Error(), "not a directory")) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("ancestor discovered as file", func(t *testing.T) { + file := filepath.Join(t.TempDir(), "file") + mustWrite(t, file, "x") + calls := 0 + testseam.Swap(t, &pullPathStat, func(string) (os.FileInfo, error) { + calls++ + if calls == 1 { + return nil, os.ErrNotExist + } + return os.Stat(file) + }) + if _, err := planPinnedPullRoot(filepath.Join(t.TempDir(), "missing")); err == nil || !strings.Contains(err.Error(), "不是目录") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("ancestor open", func(t *testing.T) { + testseam.Swap(t, &pullOpenRoot, func(string) (*os.Root, error) { return nil, errTestDownload }) + if _, err := planPinnedPullRoot(filepath.Join(t.TempDir(), "missing")); err == nil { + t.Fatal("expected ancestor open failure") + } + }) + + t.Run("path stat errors", func(t *testing.T) { + for _, initial := range []bool{true, false} { + t.Run(map[bool]string{true: "initial", false: "ancestor"}[initial], func(t *testing.T) { + calls := 0 + testseam.Swap(t, &pullPathStat, func(path string) (os.FileInfo, error) { + calls++ + if initial || calls > 1 { + return nil, errTestDownload + } + return nil, os.ErrNotExist + }) + if _, err := planPinnedPullRoot(filepath.Join(t.TempDir(), "missing")); err == nil { + t.Fatal("expected path stat failure") + } + }) + } + }) + + t.Run("initial verification", func(t *testing.T) { + calls := 0 + testseam.Swap(t, &pullRootLstat, func(root *os.Root, name string) (os.FileInfo, error) { + calls++ + if calls == 1 { + return nil, errTestDownload + } + return root.Lstat(name) + }) + if _, err := planPinnedPullRoot(filepath.Join(t.TempDir(), "missing")); err == nil { + t.Fatal("expected initial plan verification failure") + } + }) + + t.Run("invalid and changed plan", func(t *testing.T) { + if err := (&pinnedPullRootPlan{}).verify(); err == nil { + t.Fatal("expected invalid plan failure") + } + root := newPinnedPullRootForCoverage(t, t.TempDir()) + bad := *root + bad.absDir = filepath.Join(t.TempDir(), "gone") + if err := (&pinnedPullRootPlan{ancestor: &bad}).verify(); err == nil { + t.Fatal("expected changed ancestor failure") + } + }) + + t.Run("unexpected missing-path lstat", func(t *testing.T) { + plan := newMissingPinnedPullPlanForCoverage(t) + testseam.Swap(t, &pullRootLstat, func(*os.Root, string) (os.FileInfo, error) { return nil, errTestDownload }) + if err := plan.verify(); err == nil { + t.Fatal("expected missing-path lstat failure") + } + }) +} + +func TestCrossPlatformCoverageDrivePullRootMaterializeFailures(t *testing.T) { + t.Run("verification", func(t *testing.T) { + if _, err := (&pinnedPullRootPlan{}).materialize(); err == nil { + t.Fatal("expected materialize verification failure") + } + }) + + for _, tc := range []struct { + name string + stub func(*testing.T) + }{ + {"open ancestor", func(t *testing.T) { + testseam.Swap(t, &pullOpenParentRoot, func(*os.Root, string) (*os.Root, error) { return nil, errTestDownload }) + }}, + {"mkdir", func(t *testing.T) { + testseam.Swap(t, &pullRootMkdir, func(*os.Root, string, os.FileMode) error { return errTestDownload }) + }}, + {"created lstat", func(t *testing.T) { + testseam.Swap(t, &pullRootLstat, func(*os.Root, string) (os.FileInfo, error) { return nil, errTestDownload }) + }}, + {"created not directory", func(t *testing.T) { + file := filepath.Join(t.TempDir(), "file") + mustWrite(t, file, "x") + info, err := os.Lstat(file) + if err != nil { + t.Fatal(err) + } + testseam.Swap(t, &pullRootLstat, func(*os.Root, string) (os.FileInfo, error) { return info, nil }) + }}, + {"created changes before root identity", func(t *testing.T) { + calls := 0 + testseam.Swap(t, &pullRootLstat, func(root *os.Root, name string) (os.FileInfo, error) { + calls++ + if calls == 2 { + return nil, errTestDownload + } + return root.Lstat(name) + }) + }}, + {"open created", func(t *testing.T) { + calls := 0 + testseam.Swap(t, &pullOpenParentRoot, func(root *os.Root, name string) (*os.Root, error) { + calls++ + if calls > 1 { + return nil, errTestDownload + } + return root.OpenRoot(name) + }) + }}, + {"created identity", func(t *testing.T) { + calls := 0 + testseam.Swap(t, &pullRootStat, func(root *os.Root, name string) (os.FileInfo, error) { + calls++ + if calls == 1 { + return nil, errTestDownload + } + return root.Stat(name) + }) + }}, + {"final root stat", func(t *testing.T) { + calls := 0 + testseam.Swap(t, &pullRootStat, func(root *os.Root, name string) (os.FileInfo, error) { + calls++ + if calls == 3 { + return nil, errTestDownload + } + return root.Stat(name) + }) + }}, + {"final verification", func(t *testing.T) { + calls := 0 + testseam.Swap(t, &pullPathLstat, func(path string) (os.FileInfo, error) { + calls++ + if calls >= 2 { + return nil, errTestDownload + } + return os.Lstat(path) + }) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + plan := newMissingPinnedPullPlanForCoverage(t) + tc.stub(t) + if root, err := plan.materialize(); err == nil { + root.close() + t.Fatal("expected materialize failure") + } + }) + } + + t.Run("final lstat and verify", func(t *testing.T) { + for _, failAt := range []int{1, 2} { + t.Run(string(rune('0'+failAt)), func(t *testing.T) { + plan := newMissingPinnedPullPlanForCoverage(t) + calls := 0 + if failAt == 1 { + testseam.Swap(t, &pullPathLstat, func(string) (os.FileInfo, error) { return nil, errTestDownload }) + } else { + testseam.Swap(t, &pullPathStat, func(path string) (os.FileInfo, error) { + calls++ + if calls >= 2 { + return nil, errTestDownload + } + return os.Stat(path) + }) + } + if root, err := plan.materialize(); err == nil { + root.close() + t.Fatal("expected final materialize failure") + } + }) + } + }) + + t.Run("open existing path identity and verification", func(t *testing.T) { + for _, fail := range []string{"lstat", "verify"} { + t.Run(fail, func(t *testing.T) { + dir := t.TempDir() + if fail == "lstat" { + testseam.Swap(t, &pullPathLstat, func(string) (os.FileInfo, error) { return nil, errTestDownload }) + } else { + pathCalls := 0 + testseam.Swap(t, &pullPathLstat, func(path string) (os.FileInfo, error) { + pathCalls++ + if pathCalls >= 2 { + return nil, errTestDownload + } + return os.Lstat(path) + }) + } + if _, err := openExistingPinnedPullRoot(dir); err == nil { + t.Fatal("expected existing-root identity failure") + } + }) + } + }) +} + +func TestCrossPlatformCoverageDrivePullPublishRaces(t *testing.T) { + t.Run("smart concurrent target", func(t *testing.T) { + root := t.TempDir() + installPinnedPullSuccess(t) + mustWrite(t, filepath.Join(root, "a.txt"), "older") + testseam.Swap(t, &pullDownloadFile, func(_ context.Context, _ string, _ map[string]string, destination *os.File) error { + if _, err := destination.WriteString("remote"); err != nil { + return err + } + return os.Chtimes(filepath.Join(root, "a.txt"), time.Now(), time.Now().Add(time.Hour)) + }) + action, err := pullOneFileAtRoot(context.Background(), "", &remoteFile{FileID: "F", ModifiedTimeValid: true}, root, "a.txt", ifExistsSmart) + if err != nil || action != pullActionSkipped { + t.Fatalf("action=%q err=%v", action, err) + } + }) + + t.Run("link loses to regular target", func(t *testing.T) { + root := t.TempDir() + installPinnedPullSuccess(t) + testseam.Swap(t, &pullLink, func(parent *os.Root, _, target string) error { + file, err := parent.OpenFile(target, os.O_CREATE|os.O_WRONLY, 0o600) + if err == nil { + _ = file.Close() + } + return os.ErrExist + }) + action, err := pullOneFileAtRoot(context.Background(), "", &remoteFile{FileID: "F"}, root, "a.txt", ifExistsSkip) + if err != nil || action != pullActionSkipped { + t.Fatalf("action=%q err=%v", action, err) + } + }) + + t.Run("link cleanup", func(t *testing.T) { + root := t.TempDir() + installPinnedPullSuccess(t) + testseam.Swap(t, &pullRemove, func(*os.Root, string) error { return errTestDownload }) + action, err := pullOneFileAtRoot(context.Background(), "", &remoteFile{FileID: "F"}, root, "a.txt", ifExistsSkip) + if err == nil || action != pullActionFailed || !strings.Contains(err.Error(), "清理临时文件") { + t.Fatalf("action=%q err=%v", action, err) + } + }) + + t.Run("post publish parent", func(t *testing.T) { + root := t.TempDir() + installPinnedPullSuccess(t) + statCalls := 0 + testseam.Swap(t, &pullPathStat, func(path string) (os.FileInfo, error) { + statCalls++ + if statCalls >= 5 { + return nil, errTestDownload + } + return os.Stat(path) + }) + action, err := pullOneFileAtRoot(context.Background(), "", &remoteFile{FileID: "F"}, root, "a.txt", ifExistsOverwrite) + if err == nil || action != pullActionFailed { + t.Fatalf("action=%q err=%v", action, err) + } + }) + + t.Run("overwrite rename", func(t *testing.T) { + root := t.TempDir() + installPinnedPullSuccess(t) + testseam.Swap(t, &pullRename, func(*os.Root, string, string) error { return errTestDownload }) + action, err := pullOneFileAtRoot(context.Background(), "", &remoteFile{FileID: "F"}, root, "a.txt", ifExistsOverwrite) + if err == nil || action != pullActionFailed { + t.Fatalf("action=%q err=%v", action, err) + } + }) +} + +func TestCrossPlatformCoverageDrivePullPlanCallers(t *testing.T) { + t.Run("second preflight verification", func(t *testing.T) { + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "a.txt"), "local") + root := newPinnedPullRootForCoverage(t, dir) + plan := &pinnedPullRootPlan{absDir: root.absDir, existing: root} + moved := false + testseam.Swap(t, &pullRootLstat, func(parent *os.Root, name string) (os.FileInfo, error) { + info, err := parent.Lstat(name) + 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 { + t.Fatal(mkdirErr) + } + } + return info, err + }) + if _, _, err := buildDrivePullPreflightFromPlan(plan, map[string]*remoteFile{"a.txt": {RelPath: "a.txt"}}); err == nil { + t.Fatal("expected second verification failure") + } + }) + + t.Run("command materialize", func(t *testing.T) { + root := filepath.Join(t.TempDir(), "missing") + caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A"}`) + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive"}) + testseam.Swap(t, &pullOpenParentRoot, func(*os.Root, string) (*os.Root, error) { return nil, errTestDownload }) + cmd := findDriveSubcommand(t, "pull") + mustSetFlags(t, cmd, map[string]string{"local-folder": root, "remote-folder": "ROOT"}) + if err := runDrivePull(cmd, nil); err == nil || !errors.Is(err, errTestDownload) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("preflight output", func(t *testing.T) { + root := t.TempDir() + caller := pullListingCaller(`{"name":"A.txt","type":"file","fileId":"A"},{"name":"a.txt","type":"file","fileId":"B"}`) + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: failingWriter{}}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive"}) + cmd := findDriveSubcommand(t, "pull") + mustSetFlags(t, cmd, map[string]string{"local-folder": root, "remote-folder": "ROOT"}) + if err := runDrivePull(cmd, nil); err == nil { + t.Fatal("expected preflight output failure") + } + }) + + t.Run("remote helper", func(t *testing.T) { + root := t.TempDir() + installPinnedPullSuccess(t) + action, err := pullRemoteFile(context.Background(), "", &remoteFile{FileID: "F"}, root, "a.txt", ifExistsOverwrite) + if err != nil || action != pullActionDownloaded { + t.Fatalf("action=%q err=%v", action, err) + } + }) + + t.Run("target construction final verification", func(t *testing.T) { + root := newPinnedPullRootForCoverage(t, t.TempDir()) + calls := 0 + testseam.Swap(t, &pullRootLstat, func(parent *os.Root, name string) (os.FileInfo, error) { + calls++ + if calls >= 2 && name == "." { + return nil, errTestDownload + } + return parent.Lstat(name) + }) + if _, err := root.openTarget("a.txt"); err == nil { + t.Fatal("expected target verification failure") + } + }) +} + +func TestCrossPlatformCoverageDrivePullDefaultTransportWithoutListener(t *testing.T) { + destination, err := os.Create(filepath.Join(t.TempDir(), "payload")) + if err != nil { + t.Fatal(err) + } + defer destination.Close() + + if err := defaultPullDownloadFile(context.Background(), ":", nil, destination); err == nil { + t.Fatal("expected invalid request URL") + } + + t.Run("transport error", func(t *testing.T) { + testseam.Swap(t, &pullHTTPDo, func(*http.Request) (*http.Response, error) { return nil, errTestDownload }) + if err := defaultPullDownloadFile(context.Background(), "https://example.invalid", nil, destination); !errors.Is(err, errTestDownload) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("status", func(t *testing.T) { + testseam.Swap(t, &pullHTTPDo, func(request *http.Request) (*http.Response, error) { + if request.Header.Get("X-Test") != "yes" { + t.Fatalf("header = %q", request.Header.Get("X-Test")) + } + return &http.Response{StatusCode: http.StatusTeapot, Body: io.NopCloser(strings.NewReader("denied"))}, nil + }) + if err := defaultPullDownloadFile(context.Background(), "https://example.invalid", map[string]string{"X-Test": "yes"}, destination); err == nil { + t.Fatal("expected status failure") + } + }) + + t.Run("success and copy", func(t *testing.T) { + testseam.Swap(t, &pullHTTPDo, func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("payload"))}, nil + }) + if err := defaultPullDownloadFile(context.Background(), "https://example.invalid", nil, destination); err != nil { + t.Fatal(err) + } + closed, createErr := os.Create(filepath.Join(t.TempDir(), "closed")) + if createErr != nil { + t.Fatal(createErr) + } + _ = closed.Close() + if err := defaultPullDownloadFile(context.Background(), "https://example.invalid", nil, closed); err == nil { + t.Fatal("expected copy failure") + } + }) +} + +func TestCrossPlatformCoverageDrivePullMTimeHandle(t *testing.T) { + file, err := os.Create(filepath.Join(t.TempDir(), "mtime")) + if err != nil { + t.Fatal(err) + } + defer file.Close() + if err := setPullFileTimes(file, time.Unix(123, 0)); err != nil { + t.Fatal(err) + } +} + +func TestCrossPlatformCoverageDrivePullDryRunLocalPolicies(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "a.txt") + mustWrite(t, path, "local") + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + testseam.Swap(t, &deps, &Deps{Out: &Formatter{w: io.Discard}}) + remote := map[string]*remoteFile{"a.txt": {RelPath: "a.txt", ModifiedTimeValid: true, ModifiedTime: info.ModTime().UnixMilli()}} + for _, policy := range []string{ifExistsSkip, ifExistsSmart, ifExistsOverwrite} { + if err := printDrivePullDryRunWithLocalInfo(policy, remote, []string{"a.txt"}, nil, map[string]os.FileInfo{"a.txt": info}); err != nil { + t.Fatalf("policy %s: %v", policy, err) + } + } + if err := printDrivePullDryRunWithLocalInfo(ifExistsSkip, remote, []string{"missing.txt"}, nil, nil); err != nil { + t.Fatal(err) + } +} + +func TestCrossPlatformCoverageDrivePullRejectNonRegular(t *testing.T) { + if err := rejectNonRegularLocalTarget(filepath.Join(t.TempDir(), "missing")); err != nil { + t.Fatal(err) + } + dir := t.TempDir() + if err := rejectNonRegularLocalTarget(dir); err == nil { + t.Fatal("expected directory rejection") + } + testseam.Swap(t, &pullTargetLstat, func(string) (os.FileInfo, error) { return nil, errTestDownload }) + if err := rejectNonRegularLocalTarget("target"); err == nil { + t.Fatal("expected lstat failure") + } +} diff --git a/internal/helpers/drive_pull_test.go b/internal/helpers/drive_pull_test.go index f7543bab..e2d70cc8 100644 --- a/internal/helpers/drive_pull_test.go +++ b/internal/helpers/drive_pull_test.go @@ -8,6 +8,7 @@ import ( "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" ) @@ -128,17 +129,10 @@ func TestCrossPlatformCoverageDrivePull_nonexistentLocalRoot(t *testing.T) { listJSON: `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"F1","modifyTime":1000}],"nextToken":""}}`, } - prevDeps := deps - prevArgs := os.Args - deps = &Deps{Caller: caller, Out: &Formatter{w: io.Discard}} - SetHTTPGetFile(func(_ context.Context, _ string, _ map[string]string, dest string) error { + 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) }) - t.Cleanup(func() { - deps = prevDeps - os.Args = prevArgs - SetHTTPGetFile(nil) - }) root := &cobra.Command{Use: "dws"} root.PersistentFlags().BoolP("yes", "y", false, "") @@ -147,7 +141,7 @@ func TestCrossPlatformCoverageDrivePull_nonexistentLocalRoot(t *testing.T) { // pull/push 是 user_required 叶子,非交互环境需 --yes 才能越过统一确认门。 full := []string{"pull", "--local-folder", absDir, "--remote-folder", "ROOT", "--yes"} - os.Args = append([]string{"dws", "drive"}, full...) + testseam.Swap(t, &os.Args, append([]string{"dws", "drive"}, full...)) root.SetArgs(append([]string{"drive"}, full...)) // 全部下载成功时 runDrivePull 返回 nil;若误判逃逸则会有 failed 并返回 partial_failure。 @@ -240,21 +234,14 @@ func TestCrossPlatformCoveragePullOneFile_downloadFailureKeepsOriginal(t *testin const original = "ORIGINAL-CONTENT-DO-NOT-LOSE" mustWrite(t, target, original) - prevDeps := deps - prevArgs := os.Args // download_file 的 MCP 调用走 mock;httpGetFile 注入为“写入部分内容后报错”。 - deps = &Deps{Caller: &drivePullMockCaller{}, Out: &Formatter{w: io.Discard}} - os.Args = []string{"dws", "drive"} - SetHTTPGetFile(func(_ context.Context, _ string, _ map[string]string, dest string) error { + 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") }) - t.Cleanup(func() { - deps = prevDeps - os.Args = prevArgs - SetHTTPGetFile(nil) - }) rf := &remoteFile{RelPath: "a.txt", FileID: "F1"} action, err := pullOneFile(context.Background(), "", rf, target, ifExistsOverwrite) @@ -286,18 +273,11 @@ func TestCrossPlatformCoveragePullOneFile_downloadSuccessReplacesTarget(t *testi target := filepath.Join(root, "a.txt") mustWrite(t, target, "OLD") - prevDeps := deps - prevArgs := os.Args - deps = &Deps{Caller: &drivePullMockCaller{}, Out: &Formatter{w: io.Discard}} - os.Args = []string{"dws", "drive"} - SetHTTPGetFile(func(_ context.Context, _ string, _ map[string]string, dest string) error { + 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) }) - t.Cleanup(func() { - deps = prevDeps - os.Args = prevArgs - SetHTTPGetFile(nil) - }) rf := &remoteFile{RelPath: "a.txt", FileID: "F1"} action, err := pullOneFile(context.Background(), "", rf, target, ifExistsOverwrite) diff --git a/internal/helpers/drive_pull_times_unix.go b/internal/helpers/drive_pull_times_unix.go new file mode 100644 index 00000000..23d9efd9 --- /dev/null +++ b/internal/helpers/drive_pull_times_unix.go @@ -0,0 +1,16 @@ +//go:build !windows + +package helpers + +import ( + "os" + "time" + + "golang.org/x/sys/unix" +) + +// setPullFileTimes 只通过已打开文件描述符更新访问/修改时间,不重新解析文件名。 +func setPullFileTimes(file *os.File, modified time.Time) error { + stamp := unix.NsecToTimeval(modified.UnixNano()) + return unix.Futimes(int(file.Fd()), []unix.Timeval{stamp, stamp}) +} diff --git a/internal/helpers/drive_pull_times_windows.go b/internal/helpers/drive_pull_times_windows.go new file mode 100644 index 00000000..a1b9e69d --- /dev/null +++ b/internal/helpers/drive_pull_times_windows.go @@ -0,0 +1,16 @@ +//go:build windows + +package helpers + +import ( + "os" + "time" + + "golang.org/x/sys/windows" +) + +// setPullFileTimes 只通过已打开文件句柄更新访问/修改时间,不重新解析文件名。 +func setPullFileTimes(file *os.File, modified time.Time) error { + stamp := windows.NsecToFiletime(modified.UnixNano()) + return windows.SetFileTime(windows.Handle(file.Fd()), nil, &stamp, &stamp) +} diff --git a/internal/helpers/drive_pull_toctou_test.go b/internal/helpers/drive_pull_toctou_test.go new file mode 100644 index 00000000..d32f4a6b --- /dev/null +++ b/internal/helpers/drive_pull_toctou_test.go @@ -0,0 +1,915 @@ +package helpers + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func installPinnedPullSuccess(t *testing.T) { + t.Helper() + testseam.Swap(t, &deps, &Deps{Caller: &driveSyncMockCaller{}, Out: &Formatter{w: io.Discard}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive"}) + testseam.Swap(t, &pullDownloadFile, func(_ context.Context, _ string, _ map[string]string, destination *os.File) error { + _, err := destination.WriteString("remote") + return err + }) +} + +// replacePullAncestorWithOutsideLink 把已被 pull 固定的 sub 目录移走, +// 再在原名处放置指向根外的符号链接,确定性复现远端调用/下载期间的换链。 +func replacePullAncestorWithOutsideLink(t *testing.T, root, outside string) string { + t.Helper() + sub := filepath.Join(root, "sub") + parked := filepath.Join(root, "sub-pinned") + if err := os.Rename(sub, parked); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, sub); err != nil { + t.Skipf("平台不支持创建符号链接: %v", err) + } + return parked +} + +func runPullTOCTOUCase(t *testing.T, command string, root string) error { + t.Helper() + rf := &remoteFile{RelPath: "sub/a.txt", FileID: "F"} + if command == "pull" { + _, err := pullOneFileAtRoot(context.Background(), "", rf, root, rf.RelPath, ifExistsOverwrite) + return err + } + res := &driveSyncResult{} + syncPullFile(res, context.Background(), "", rf, root, rf.RelPath, syncDirectionPull) + if res.Summary.Failed != 1 || len(res.Items) != 1 { + t.Fatalf("sync TOCTOU result = %#v", res) + } + if res.Items[0].Error == "" { + t.Fatal("sync TOCTOU failure must retain the cause") + } + return &drivePullTestError{message: res.Items[0].Error} +} + +type drivePullTestError struct{ message string } + +func (e *drivePullTestError) Error() string { return e.message } + +// download_file 返回期间父目录被换成指向根外的软链时,pull/sync +// 必须在创建临时文件和发出 HTTP GET 前失败。 +func TestCrossPlatformCoverageDrivePullSyncRejectAncestorSwapBeforeTransfer(t *testing.T) { + for _, command := range []string{"pull", "sync"} { + t.Run(command, func(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "sub"), 0o755); err != nil { + t.Fatal(err) + } + + var parked string + caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, _ int) (string, error) { + if tool != "download_file" { + t.Fatalf("unexpected MCP tool %q", tool) + } + parked = replacePullAncestorWithOutsideLink(t, root, outside) + return `{"result":{"downloadUrl":"https://oss.example.com/get"}}`, nil + }} + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive"}) + + tempCalls, getCalls := 0, 0 + testseam.Swap(t, &pullCreateTemp, func(root *os.Root) (*os.File, string, error) { + tempCalls++ + return createPinnedPullTemp(root) + }) + testseam.Swap(t, &pullDownloadFile, func(context.Context, string, map[string]string, *os.File) error { + getCalls++ + return nil + }) + + err := runPullTOCTOUCase(t, command, root) + if err == nil { + t.Fatal("换链后不应继续下载") + } + if tempCalls != 0 || getCalls != 0 { + t.Fatalf("换链后发生了落盘: temp=%d HTTP_GET=%d", tempCalls, getCalls) + } + if entries, readErr := os.ReadDir(outside); readErr != nil || len(entries) != 0 { + t.Fatalf("根外目录被写入: entries=%v err=%v", entries, readErr) + } + if entries, readErr := os.ReadDir(parked); readErr != nil || len(entries) != 0 { + t.Fatalf("已移走的固定目录被写入: entries=%v err=%v", entries, readErr) + } + }) + } +} + +// HTTP 传输完成前父目录被换链时,已打开临时文件可以安全清理, +// 但最终发布必须在再次核对父目录身份后失败,不得沿新软链写到根外。 +func TestCrossPlatformCoverageDrivePullSyncRechecksAncestorBeforePublish(t *testing.T) { + for _, command := range []string{"pull", "sync"} { + t.Run(command, func(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "sub"), 0o755); err != nil { + t.Fatal(err) + } + caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, _ int) (string, error) { + return `{"result":{"downloadUrl":"https://oss.example.com/get"}}`, nil + }} + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive"}) + + parked := "" + testseam.Swap(t, &pullDownloadFile, func(_ context.Context, _ string, _ map[string]string, destination *os.File) error { + if _, err := destination.WriteString("remote"); err != nil { + return err + } + parked = replacePullAncestorWithOutsideLink(t, root, outside) + return nil + }) + + err := runPullTOCTOUCase(t, command, root) + if err == nil { + t.Fatal("发布前换链未被拒绝") + } + if command == "pull" && !strings.Contains(err.Error(), "目标目录在下载期间被替换") { + t.Fatalf("pull error = %v", err) + } + if _, statErr := os.Stat(filepath.Join(outside, "a.txt")); !os.IsNotExist(statErr) { + t.Fatalf("根外目标被发布: %v", statErr) + } + if entries, readErr := os.ReadDir(parked); readErr != nil || len(entries) != 0 { + t.Fatalf("失败后应清理临时文件: entries=%v err=%v", entries, readErr) + } + }) + } +} + +func TestCrossPlatformCoverageDriveSyncKeepBothRejectsAncestorSwapWithoutRollback(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + sub := filepath.Join(root, "sub") + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sub, "a.txt"), []byte("local"), 0o600); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &deps, &Deps{Caller: &driveSyncMockCaller{}, Out: &Formatter{w: io.Discard}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive"}) + + parked := "" + testseam.Swap(t, &pullDownloadFile, func(_ context.Context, _ string, _ map[string]string, destination *os.File) error { + if _, err := destination.WriteString("remote"); err != nil { + return err + } + parked = replacePullAncestorWithOutsideLink(t, root, outside) + return nil + }) + + res := &driveSyncResult{} + syncKeepBoth(res, context.Background(), "", &remoteFile{FileID: "FID"}, root, "sub/a.txt", map[string]bool{}) + + if res.Summary.Failed != 1 || len(res.Items) != 1 { + t.Fatalf("keep-both TOCTOU result = %#v", res) + } + if !strings.Contains(res.Items[0].Error, "目标目录在下载期间被替换") { + t.Fatalf("error = %q", res.Items[0].Error) + } + if entries, err := os.ReadDir(outside); err != nil || len(entries) != 0 { + t.Fatalf("不得沿换链路径触碰根外目录: entries=%v err=%v", entries, err) + } + kept := filepath.Join(parked, "a.conflict-FID.txt") + if content, err := os.ReadFile(kept); err != nil || string(content) != "local" { + t.Fatalf("本地保留硬链接应留在原固定目录: content=%q err=%v", content, err) + } +} + +func TestCrossPlatformCoveragePinnedPullFilesystemErrorBranches(t *testing.T) { + t.Run("root mkdir", func(t *testing.T) { + testseam.Swap(t, &pullMkdirAll, func(string, os.FileMode) error { return errTestDownload }) + if _, err := openPinnedPullTarget(filepath.Join(t.TempDir(), "root"), "a.txt"); err == nil { + t.Fatal("expected root mkdir failure") + } + }) + + t.Run("open root", func(t *testing.T) { + root := t.TempDir() + testseam.Swap(t, &pullOpenRoot, func(string) (*os.Root, error) { return nil, errTestDownload }) + if _, err := openPinnedPullTarget(root, "a.txt"); err == nil { + t.Fatal("expected root open failure") + } + }) + + t.Run("relative path", func(t *testing.T) { + root := t.TempDir() + for name, relFn := range map[string]func(string, string) (string, error){ + "error": func(string, string) (string, error) { return "", errTestDownload }, + "escape": func(string, string) (string, error) { return "../escape", nil }, + } { + t.Run(name, func(t *testing.T) { + testseam.Swap(t, &pullRelPath, relFn) + if _, err := openPinnedPullTarget(root, "a.txt"); err == nil { + t.Fatal("expected relative-path failure") + } + }) + } + }) + + t.Run("parent mkdir", func(t *testing.T) { + root := t.TempDir() + testseam.Swap(t, &pullRootMkdir, func(*os.Root, string, os.FileMode) error { return errTestDownload }) + if _, err := openPinnedPullTarget(root, "sub/a.txt"); err == nil { + t.Fatal("expected parent mkdir failure") + } + }) + + t.Run("parent mkdir success", func(t *testing.T) { + root := t.TempDir() + target, err := openPinnedPullTarget(root, "sub/a.txt") + if err != nil { + t.Fatal(err) + } + target.close() + if info, err := os.Lstat(filepath.Join(root, "sub")); err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + t.Fatalf("created parent is not a real directory: info=%v err=%v", info, err) + } + }) + + t.Run("open root clone", func(t *testing.T) { + root := t.TempDir() + testseam.Swap(t, &pullOpenParentRoot, func(*os.Root, string) (*os.Root, error) { return nil, errTestDownload }) + if _, err := openPinnedPullTarget(root, "sub/a.txt"); err == nil { + t.Fatal("expected root clone failure") + } + }) + + t.Run("open parent segment", func(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "sub"), 0o755); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &pullOpenParentRoot, func(root *os.Root, name string) (*os.Root, error) { + if name == "sub" { + return nil, errTestDownload + } + return root.OpenRoot(name) + }) + if _, err := openPinnedPullTarget(root, "sub/a.txt"); err == nil { + t.Fatal("expected parent segment open failure") + } + }) + + t.Run("lstat root parent", func(t *testing.T) { + root := t.TempDir() + testseam.Swap(t, &pullRootLstat, func(*os.Root, string) (os.FileInfo, error) { return nil, errTestDownload }) + if _, err := openPinnedPullTarget(root, "a.txt"); err == nil { + t.Fatal("expected root parent lstat failure") + } + }) + + t.Run("lstat parent segment", func(t *testing.T) { + root := t.TempDir() + testseam.Swap(t, &pullRootLstat, func(root *os.Root, name string) (os.FileInfo, error) { + if name == "sub" { + return nil, errTestDownload + } + return root.Lstat(name) + }) + if _, err := openPinnedPullTarget(root, "sub/a.txt"); err == nil { + t.Fatal("expected parent segment lstat failure") + } + }) + + t.Run("stat parent", func(t *testing.T) { + root := t.TempDir() + testseam.Swap(t, &pullRootStat, func(*os.Root, string) (os.FileInfo, error) { return nil, errTestDownload }) + if _, err := openPinnedPullTarget(root, "a.txt"); err == nil { + t.Fatal("expected parent stat failure") + } + }) + + t.Run("parent changes while target opens", func(t *testing.T) { + root := t.TempDir() + calls := 0 + testseam.Swap(t, &pullRootStat, func(root *os.Root, name string) (os.FileInfo, error) { + calls++ + if calls == 2 { + return nil, errTestDownload + } + return root.Stat(name) + }) + if _, err := openPinnedPullTarget(root, "a.txt"); err == nil { + t.Fatal("expected construction-time parent identity failure") + } + }) + + t.Run("lstat target", func(t *testing.T) { + root := t.TempDir() + target, err := openPinnedPullTarget(root, "a.txt") + if err != nil { + t.Fatal(err) + } + defer target.close() + testseam.Swap(t, &pullRootLstat, func(*os.Root, string) (os.FileInfo, error) { return nil, errTestDownload }) + if _, err := target.regularTargetInfo(); err == nil { + t.Fatal("expected target lstat failure") + } + }) + + t.Run("create temp root error and collision exhaustion", func(t *testing.T) { + closed, err := os.OpenRoot(t.TempDir()) + if err != nil { + t.Fatal(err) + } + _ = closed.Close() + if _, _, err := createPinnedPullTemp(closed); err == nil { + t.Fatal("expected closed root failure") + } + + rootDir := t.TempDir() + root, err := os.OpenRoot(rootDir) + if err != nil { + t.Fatal(err) + } + defer root.Close() + const collision = "collision" + if err := os.WriteFile(filepath.Join(rootDir, ".dws-pull-"+collision), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &pullTempName, func() string { return collision }) + if _, _, err := createPinnedPullTemp(root); err == nil { + t.Fatal("expected collision exhaustion") + } + }) +} + +func TestCrossPlatformCoveragePinnedPullPublicationErrorBranches(t *testing.T) { + call := func(t *testing.T, root string) error { + t.Helper() + _, err := pullOneFileAtRoot(context.Background(), "", &remoteFile{FileID: "F"}, root, "a.txt", ifExistsOverwrite) + return err + } + + t.Run("temp stat", func(t *testing.T) { + root := t.TempDir() + installPinnedPullSuccess(t) + testseam.Swap(t, &pullCreateTemp, func(root *os.Root) (*os.File, string, error) { + file, name, err := createPinnedPullTemp(root) + if err == nil { + _ = file.Close() + } + return file, name, err + }) + if err := call(t, root); err == nil || !strings.Contains(err.Error(), "读取临时文件身份失败") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("temp sync", func(t *testing.T) { + root := t.TempDir() + installPinnedPullSuccess(t) + testseam.Swap(t, &pullSyncTemp, func(*os.File) error { return errTestDownload }) + if err := call(t, root); err == nil || !strings.Contains(err.Error(), "同步临时文件失败") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("temp close", func(t *testing.T) { + root := t.TempDir() + installPinnedPullSuccess(t) + testseam.Swap(t, &pullCloseTemp, func(*os.File) error { return errTestDownload }) + if err := call(t, root); err == nil || !strings.Contains(err.Error(), "关闭临时文件失败") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("temp identity", func(t *testing.T) { + root := t.TempDir() + installPinnedPullSuccess(t) + other := filepath.Join(t.TempDir(), "other") + if err := os.WriteFile(other, []byte("other"), 0o600); err != nil { + t.Fatal(err) + } + otherInfo, err := os.Lstat(other) + if err != nil { + t.Fatal(err) + } + downloaded := false + testseam.Swap(t, &pullDownloadFile, func(_ context.Context, _ string, _ map[string]string, destination *os.File) error { + downloaded = true + _, err := destination.WriteString("remote") + return err + }) + testseam.Swap(t, &pullRootLstat, func(root *os.Root, name string) (os.FileInfo, error) { + if downloaded && strings.HasPrefix(name, ".dws-pull-") { + return otherInfo, nil + } + return root.Lstat(name) + }) + if err := call(t, root); err == nil || !strings.Contains(err.Error(), "临时文件在下载期间被替换") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("target becomes directory", func(t *testing.T) { + root := t.TempDir() + installPinnedPullSuccess(t) + testseam.Swap(t, &pullDownloadFile, func(_ context.Context, _ string, _ map[string]string, destination *os.File) error { + if _, err := destination.WriteString("remote"); err != nil { + return err + } + return os.Mkdir(filepath.Join(root, "a.txt"), 0o755) + }) + if err := call(t, root); err == nil || !strings.Contains(err.Error(), "不是常规文件") { + t.Fatalf("error = %v", err) + } + }) +} + +func TestCrossPlatformCoverageDefaultPullDownloadFileBranches(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if request.Header.Get("X-Test") != "yes" { + t.Errorf("header = %q", request.Header.Get("X-Test")) + } + if request.URL.Path == "/status" { + http.Error(w, "denied", http.StatusTeapot) + return + } + _, _ = io.WriteString(w, "payload") + })) + defer server.Close() + + destination, err := os.Create(filepath.Join(t.TempDir(), "payload")) + if err != nil { + t.Fatal(err) + } + defer destination.Close() + if err := defaultPullDownloadFile(context.Background(), ":", nil, destination); err == nil { + t.Fatal("expected invalid request URL") + } + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + if err := defaultPullDownloadFile(cancelled, server.URL, nil, destination); !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled error = %v", err) + } + if err := defaultPullDownloadFile(context.Background(), server.URL+"/status", map[string]string{"X-Test": "yes"}, destination); err == nil { + t.Fatal("expected HTTP status failure") + } + if err := defaultPullDownloadFile(context.Background(), server.URL+"/ok", map[string]string{"X-Test": "yes"}, destination); err != nil { + t.Fatal(err) + } + + closed, err := os.Create(filepath.Join(t.TempDir(), "closed")) + if err != nil { + t.Fatal(err) + } + _ = closed.Close() + if err := defaultPullDownloadFile(context.Background(), server.URL+"/ok", map[string]string{"X-Test": "yes"}, closed); err == nil { + t.Fatal("expected destination copy failure") + } +} + +func TestCrossPlatformCoverageDriveSyncKeepBothLinkRemainsPinnedWhenParentMoves(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + sub := filepath.Join(root, "sub") + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sub, "a.txt"), []byte("local"), 0o600); err != nil { + t.Fatal(err) + } + for name, content := range map[string]string{ + "a.txt": "attacker-destination", + "a.conflict-FID.txt": "attacker-source", + } { + if err := os.WriteFile(filepath.Join(outside, name), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + testseam.Swap(t, &deps, &Deps{Caller: &driveSyncMockCaller{}, Out: &Formatter{w: io.Discard}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive"}) + + links := 0 + parked := "" + testseam.Swap(t, &syncRootLink, func(parent *os.Root, oldName, newName string) error { + links++ + // reserve 已完成 verifyParent;在真正 no-clobber Link 前换链,验证操作 + // 仍只落在已固定的 parentRoot,而不会沿新软链进入根外目录。 + parked = replacePullAncestorWithOutsideLink(t, root, outside) + return parent.Link(oldName, newName) + }) + + res := &driveSyncResult{} + syncKeepBoth(res, context.Background(), "", &remoteFile{FileID: "FID"}, root, "sub/a.txt", map[string]bool{}) + if res.Summary.Failed != 1 || links != 1 { + t.Fatalf("result=%#v links=%d", res, links) + } + for _, name := range []string{"a.txt", "a.conflict-FID.txt"} { + if content, err := os.ReadFile(filepath.Join(parked, name)); err != nil || string(content) != "local" { + t.Fatalf("固定目录内的 %s 未安全保留: content=%q err=%v", name, content, err) + } + } + for name, want := range map[string]string{ + "a.txt": "attacker-destination", + "a.conflict-FID.txt": "attacker-source", + } { + if content, err := os.ReadFile(filepath.Join(outside, name)); err != nil || string(content) != want { + t.Fatalf("根外 %s 被硬链接触碰: content=%q err=%v", name, content, err) + } + } +} + +func TestCrossPlatformCoverageDriveSyncKeepBothDetectsCandidateMutationDuringTransfer(t *testing.T) { + for _, mutation := range []string{"deleted", "replaced", "modified"} { + t.Run(mutation, func(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("local"), 0o600); err != nil { + t.Fatal(err) + } + candidate := filepath.Join(root, "a.conflict-FID.txt") + testseam.Swap(t, &deps, &Deps{Caller: &driveSyncMockCaller{}, Out: &Formatter{w: io.Discard}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive"}) + testseam.Swap(t, &pullDownloadFile, func(_ context.Context, _ string, _ map[string]string, destination *os.File) error { + if _, err := destination.WriteString("remote"); err != nil { + return err + } + switch mutation { + case "deleted": + return os.Remove(candidate) + case "replaced": + if err := os.Remove(candidate); err != nil { + return err + } + return os.WriteFile(candidate, []byte("attacker"), 0o600) + default: + return os.WriteFile(candidate, []byte("modified-local-version"), 0o600) + } + }) + + res := &driveSyncResult{} + syncKeepBoth(res, context.Background(), "", &remoteFile{FileID: "FID"}, root, "a.txt", map[string]bool{}) + if res.Summary.Failed != 1 || res.Summary.Pulled != 0 || len(res.Items) != 1 { + t.Fatalf("result=%#v", res) + } + if res.Items[0].Action != syncActionFailed || !strings.Contains(res.Items[0].Error, "本地保留版本在传输期间") { + t.Fatalf("candidate mutation must be failed, got %#v", res.Items) + } + if content, err := os.ReadFile(filepath.Join(root, "a.txt")); err != nil || string(content) != "remote" { + t.Fatalf("remote publish result changed: content=%q err=%v", content, err) + } + }) + } +} + +func TestCrossPlatformCoverageDrivePullCommandPinsRootAcrossAllFiles(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + caller := pullListingCaller( + `{"name":"a.txt","type":"file","fileId":"A"},` + + `{"name":"b.txt","type":"file","fileId":"B"}`) + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive", "pull"}) + + calls := 0 + parked := filepath.Join(t.TempDir(), "original-root") + testseam.Swap(t, &pullDownloadFile, func(_ context.Context, _ string, _ map[string]string, destination *os.File) error { + calls++ + if _, err := destination.WriteString("remote"); err != nil { + 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) + } + } + return nil + }) + + cmd := findDriveSubcommand(t, "pull") + mustSetFlags(t, cmd, map[string]string{"local-folder": root, "remote-folder": "ROOT", "if-exists": "overwrite"}) + if err := runDrivePull(cmd, nil); err == nil { + t.Fatal("root replacement must fail the command") + } + if calls != 1 { + t.Fatalf("second file must fail before transfer when command root changed: calls=%d", calls) + } + if entries, err := os.ReadDir(outside); err != nil || len(entries) != 0 { + t.Fatalf("outside root was touched: entries=%v err=%v", entries, err) + } +} + +func TestCrossPlatformCoverageDrivePullPlansExistingRootBeforeRemoteRead(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "mirror") + outside := t.TempDir() + parked := filepath.Join(parent, "mirror-pinned") + if err := os.Mkdir(root, 0o755); err != nil { + t.Fatal(err) + } + caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, _ int) (string, error) { + 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) + } + return `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A"}]}}`, nil + }} + getCalls := 0 + testseam.Swap(t, &pullDownloadFile, func(context.Context, string, map[string]string, *os.File) error { + getCalls++ + return nil + }) + + err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT", "--if-exists", "overwrite") + if err == nil || !strings.Contains(err.Error(), "本地根目录在同步期间被替换") { + t.Fatalf("error=%v", err) + } + if len(caller.callsFor("download_file")) != 0 || getCalls != 0 { + t.Fatalf("root replacement reached transfer: MCP=%d GET=%d", len(caller.callsFor("download_file")), getCalls) + } + if entries, readErr := os.ReadDir(outside); readErr != nil || len(entries) != 0 { + t.Fatalf("outside root was touched: entries=%v err=%v", entries, readErr) + } +} + +func TestCrossPlatformCoverageDrivePullPlansMissingRootBeforeRemoteRead(t *testing.T) { + for _, takeover := range []string{"directory", "symlink"} { + t.Run(takeover, func(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "missing", "mirror") + outside := t.TempDir() + caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, _ int) (string, error) { + if tool != "list_files" { + t.Fatalf("missing-root takeover must stop before %s", tool) + } + if takeover == "directory" { + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + } else { + if err := os.MkdirAll(filepath.Dir(root), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, root); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + } + return `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A"}]}}`, nil + }} + getCalls := 0 + testseam.Swap(t, &pullDownloadFile, func(context.Context, string, map[string]string, *os.File) error { + getCalls++ + return nil + }) + + err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT", "--if-exists", "overwrite") + if err == nil || !strings.Contains(err.Error(), "在远端读取期间被占用") { + t.Fatalf("error=%v", err) + } + if len(caller.callsFor("download_file")) != 0 || getCalls != 0 { + t.Fatalf("missing-root takeover reached transfer: MCP=%d GET=%d", len(caller.callsFor("download_file")), getCalls) + } + if entries, readErr := os.ReadDir(outside); readErr != nil || len(entries) != 0 { + t.Fatalf("outside root was touched: entries=%v err=%v", entries, readErr) + } + }) + } +} + +func TestCrossPlatformCoverageDrivePullDryRunDoesNotMaterializeMissingRoot(t *testing.T) { + root := filepath.Join(t.TempDir(), "missing", "mirror") + caller := &driveScriptCaller{dryRun: true, reply: func(tool string, _ map[string]any, _ int) (string, error) { + if tool != "list_files" { + t.Fatalf("dry-run must stop before %s", tool) + } + return `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A"}]}}`, nil + }} + if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT", "--dry-run"); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(root); !os.IsNotExist(err) { + t.Fatalf("dry-run materialized missing root: %v", err) + } +} + +func TestCrossPlatformCoverageDriveSyncCommandPinsRootAcrossAllFiles(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + caller := &driveSyncMockCaller{listJSON: `{"result":{"items":[` + + `{"name":"a.txt","type":"file","fileId":"A"},` + + `{"name":"b.txt","type":"file","fileId":"B"}` + + `],"nextToken":""}}`} + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive", "sync"}) + testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { return nil }) + + calls := 0 + parked := filepath.Join(t.TempDir(), "original-root") + testseam.Swap(t, &pullDownloadFile, func(_ context.Context, _ string, _ map[string]string, destination *os.File) error { + calls++ + if _, err := destination.WriteString("remote"); err != nil { + 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) + } + } + return nil + }) + + cmd := findDriveSubcommand(t, "sync") + mustSetFlags(t, cmd, map[string]string{"local-folder": root, "remote-folder": "ROOT"}) + if err := runDriveSync(cmd, nil); err == nil { + t.Fatal("root replacement must fail sync") + } + if calls != 1 { + t.Fatalf("second sync file must fail before transfer when command root changed: calls=%d", calls) + } + if entries, err := os.ReadDir(outside); err != nil || len(entries) != 0 { + t.Fatalf("outside root was touched: entries=%v err=%v", entries, err) + } +} + +func TestCrossPlatformCoverageDrivePullReappliesSkipAndSmartAtPublish(t *testing.T) { + for _, policy := range []string{ifExistsSkip, ifExistsSmart} { + t.Run(policy, func(t *testing.T) { + root := t.TempDir() + installPinnedPullSuccess(t) + testseam.Swap(t, &pullDownloadFile, func(_ context.Context, _ string, _ map[string]string, destination *os.File) error { + if _, err := destination.WriteString("remote"); err != nil { + return err + } + return os.WriteFile(filepath.Join(root, "a.txt"), []byte("concurrent"), 0o600) + }) + action, err := pullOneFileAtRoot(context.Background(), "", &remoteFile{FileID: "F"}, root, "a.txt", policy) + if err != nil || action != pullActionSkipped { + t.Fatalf("action=%q err=%v", action, err) + } + if content, err := os.ReadFile(filepath.Join(root, "a.txt")); err != nil || string(content) != "concurrent" { + t.Fatalf("concurrent target was overwritten: content=%q err=%v", content, err) + } + }) + } +} + +func TestCrossPlatformCoverageDrivePullPublicationIdentityAndLinkFailures(t *testing.T) { + t.Run("link failure without target is not skipped", func(t *testing.T) { + root := t.TempDir() + installPinnedPullSuccess(t) + testseam.Swap(t, &pullLink, func(*os.Root, string, string) error { return errTestDownload }) + action, err := pullOneFileAtRoot(context.Background(), "", &remoteFile{FileID: "F"}, root, "a.txt", ifExistsSkip) + if err == nil || action != pullActionFailed || !strings.Contains(err.Error(), "发布目标文件失败") { + t.Fatalf("action=%q err=%v", action, err) + } + }) + + t.Run("post publish mismatch preserves unknown terminal", func(t *testing.T) { + root := t.TempDir() + installPinnedPullSuccess(t) + other := filepath.Join(t.TempDir(), "other") + if err := os.WriteFile(other, []byte("other"), 0o600); err != nil { + t.Fatal(err) + } + otherInfo, _ := os.Lstat(other) + published := false + testseam.Swap(t, &pullRename, func(root *os.Root, oldName, newName string) error { + if err := root.Rename(oldName, newName); err != nil { + return err + } + published = true + return nil + }) + testseam.Swap(t, &pullRootLstat, func(root *os.Root, name string) (os.FileInfo, error) { + if published && name == "a.txt" { + return otherInfo, nil + } + return root.Lstat(name) + }) + action, err := pullOneFileAtRoot(context.Background(), "", &remoteFile{FileID: "F"}, root, "a.txt", ifExistsOverwrite) + if err == nil || action != pullActionFailed { + t.Fatalf("action=%q err=%v", action, err) + } + if content, err := os.ReadFile(filepath.Join(root, "a.txt")); err != nil || string(content) != "remote" { + t.Fatalf("未知 terminal entry 应保留: content=%q err=%v", content, err) + } + }) +} + +// os.Root 只保证路径不能逃逸根目录,仍会跟随指向根内目录的相对软链接。若 sub 在 +// Lstat 与 OpenRoot 之间被换成 sub -> victim,pull/sync/keep-both 都必须在任何远端 +// 读取或本地写入前拒绝,不能把 remote sub/a.txt 重定向到 victim/a.txt。 +func TestCrossPlatformCoverageDrivePullSyncKeepBothRejectRootInternalRedirect(t *testing.T) { + for _, redirect := range []string{"symlink", "directory"} { + for _, command := range []string{"pull", "sync", "keep-both"} { + t.Run(redirect+"/"+command, func(t *testing.T) { + root := t.TempDir() + sub := filepath.Join(root, "sub") + parked := filepath.Join(root, "sub-pinned") + victim := filepath.Join(root, "victim") + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(victim, 0o755); err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(sub, "a.txt"), "local") + mustWrite(t, filepath.Join(victim, "a.txt"), "victim") + + caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, _ int) (string, error) { + if tool != "download_file" { + t.Fatalf("unexpected tool %q", tool) + } + return `{"result":{"downloadUrl":"https://oss.example.com/get"}}`, nil + }} + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive"}) + testseam.Swap(t, &pullDownloadFile, func(_ context.Context, _ string, _ map[string]string, destination *os.File) error { + _, err := destination.WriteString("remote") + return err + }) + + redirectedVictim := filepath.Join(victim, "a.txt") + swapped := false + testseam.Swap(t, &pullOpenParentRoot, func(parent *os.Root, name string) (*os.Root, error) { + if name == "sub" && !swapped { + swapped = true + if err := os.Rename(sub, parked); err != nil { + t.Fatal(err) + } + if redirect == "symlink" { + if err := os.Symlink("victim", sub); err != nil { + t.Skipf("平台不支持创建符号链接: %v", err) + } + } else { + if err := os.Rename(victim, sub); err != nil { + t.Fatal(err) + } + redirectedVictim = filepath.Join(sub, "a.txt") + } + } + return parent.OpenRoot(name) + }) + + rf := &remoteFile{RelPath: "sub/a.txt", FileID: "F"} + switch command { + case "pull": + action, err := pullOneFileAtRoot(context.Background(), "", rf, root, rf.RelPath, ifExistsOverwrite) + if err == nil || action != pullActionFailed { + t.Fatalf("action=%q err=%v", action, err) + } + case "sync": + res := &driveSyncResult{} + syncPullFile(res, context.Background(), "", rf, root, rf.RelPath, syncDirectionPull) + if res.Summary.Failed != 1 || res.Summary.Pulled != 0 { + t.Fatalf("sync result=%#v", res) + } + default: + res := &driveSyncResult{} + syncKeepBoth(res, context.Background(), "", rf, root, rf.RelPath, map[string]bool{}) + if res.Summary.Failed != 1 || res.Summary.Pulled != 0 { + t.Fatalf("keep-both result=%#v", res) + } + } + + if !swapped { + t.Fatal("test did not install the root-internal redirect") + } + if got := len(caller.callsFor("download_file")); got != 0 { + t.Fatalf("root-internal redirect reached download_file: %d", got) + } + for path, want := range map[string]string{ + filepath.Join(parked, "a.txt"): "local", + redirectedVictim: "victim", + } { + content, err := os.ReadFile(path) + if err != nil || string(content) != want { + t.Fatalf("%s changed: content=%q err=%v", path, content, err) + } + } + if matches, err := filepath.Glob(filepath.Join(filepath.Dir(redirectedVictim), "a.conflict-*.txt")); err != nil || len(matches) != 0 { + t.Fatalf("keep-both wrote into redirected victim: matches=%v err=%v", matches, err) + } + }) + } + } +} diff --git a/internal/helpers/drive_push.go b/internal/helpers/drive_push.go index ff617007..37eaec56 100644 --- a/internal/helpers/drive_push.go +++ b/internal/helpers/drive_push.go @@ -2,14 +2,21 @@ package helpers import ( "context" + "crypto/md5" + "encoding/base64" "encoding/json" "fmt" + "io" "io/fs" + "net/http" + "os" "path/filepath" "sort" "strings" + "time" "github.com/spf13/cobra" + "golang.org/x/text/unicode/norm" ) // ========================================================== @@ -40,8 +47,28 @@ type localPushFile struct { AbsPath string ModTimeMillis int64 Size int64 + scanInfo os.FileInfo } +// pushPutOpenedFile 把已由固定 os.Root 打开的本地文件句柄上传到 OSS。生产 push/sync +// 不再把可被并发换链的绝对路径交给 HTTP 层重新打开;测试必须通过 testseam.Swap 替换。 +var ( + pushPutOpenedFile = defaultPushPutOpenedFile + pushStatOpenedFile = func(file *os.File) (os.FileInfo, error) { + return file.Stat() + } + pushVerifyPinnedSourceRoot = func(root *pinnedPullRoot) error { + return root.verify() + } + pushMD5OpenedFile = func(file *os.File) (string, error) { + hash := md5.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(hash.Sum(nil)), nil + } +) + // drivePushItem 是输出 items[] 中每个条目的明细。 type drivePushItem struct { RelPath string `json:"rel_path"` @@ -105,6 +132,16 @@ func runDrivePush(cmd *cobra.Command, _ []string) error { if err != nil { return err } + pinnedRoot, err := openExistingPinnedPullRoot(absDir) + if err != nil { + return fmt.Errorf("扫描本地目录失败: %w", err) + } + defer pinnedRoot.close() + + localDirs, localFiles, err := runDriveMirrorWalkLocalForPushPinned(pinnedRoot) + if err != nil { + return fmt.Errorf("扫描本地目录失败: %w", err) + } ctx := cmd.Context() // 远端现状:已存在的文件(用于 --if-exists 判断)与目录(rel_path → fileId,用于复用/定位父目录)。 @@ -113,23 +150,27 @@ func runDrivePush(cmd *cobra.Command, _ []string) error { return err } - localDirs, localFiles, err := walkLocalForPush(absDir) - if err != nil { - return fmt.Errorf("扫描本地目录失败: %w", err) - } + preflight := buildMirrorPreflight(localDirs, localFiles, remoteFiles, remoteFolders) res := drivePushResult{Items: make([]drivePushItem, 0, len(localDirs)+len(localFiles))} if deps.Caller.DryRun() { - return printDrivePushDryRun(ifExists, remoteFiles, remoteFolders, localDirs, localFiles) + return printDrivePushDryRunWithPreflight(ifExists, remoteFiles, remoteFolders, localDirs, localFiles, preflight) + } + if len(preflight) > 0 { + appendDrivePushPreflightFailures(&res, preflight) + if perr := deps.Out.PrintJSON(res); perr != nil { + return perr + } + return &drivePushFailure{failed: res.Summary.Failed} + } + // 本地扫描与远端读取都可能耗时。首个远端写之前确认命令行路径仍指向最初固定 + // 的根;若根已被移走或替换,整批中止,不能把替代目录的数据当成扫描快照上传。 + if err := pinnedRoot.verify(); err != nil { + return err } // 第一阶段:按需创建远端目录(浅层在前,保证父目录先于子目录存在)。 for _, dir := range localDirs { - if msg := pushTypeConflictError(dir, true, remoteFiles, remoteFolders); msg != "" { - res.Summary.Failed++ - res.Items = append(res.Items, drivePushItem{RelPath: dir, Action: pushActionFailed, Error: msg}) - continue - } if _, ok := remoteFolders[dir]; ok { continue // 远端已存在,复用 fileId,不留痕 } @@ -140,6 +181,9 @@ func runDrivePush(cmd *cobra.Command, _ []string) error { res.Items = append(res.Items, drivePushItem{RelPath: dir, Action: pushActionFailed, Error: "父目录未能创建"}) continue } + if err := pinnedRoot.verify(); err != nil { + return err + } fid, cerr := pushCreateFolder(ctx, spaceID, parentID, name) if cerr != nil || fid == "" { res.Summary.Failed++ @@ -158,11 +202,6 @@ func runDrivePush(cmd *cobra.Command, _ []string) error { for i := range localFiles { lf := localFiles[i] size := lf.Size - if msg := pushTypeConflictError(lf.RelPath, false, remoteFiles, remoteFolders); msg != "" { - res.Summary.Failed++ - res.Items = append(res.Items, drivePushItem{RelPath: lf.RelPath, Action: pushActionFailed, SizeBytes: &size, Error: msg}) - continue - } parentRel, name := splitRel(lf.RelPath) parentID, ok := remoteFolders[parentRel] if !ok || parentID == "" { @@ -198,7 +237,7 @@ func runDrivePush(cmd *cobra.Command, _ []string) error { } } - if err := pushUploadFile(ctx, spaceID, parentID, overwriteID, name, lf.AbsPath, size); err != nil { + if err := pushUploadFilePinned(ctx, spaceID, parentID, overwriteID, name, pinnedRoot, lf); err != nil { res.Summary.Failed++ res.Items = append(res.Items, drivePushItem{RelPath: lf.RelPath, Action: pushActionFailed, SizeBytes: &size, Error: err.Error()}) continue @@ -234,6 +273,7 @@ func walkRemoteForPush(ctx context.Context, spaceID, parentID, relBase string, f return fmt.Errorf("drive 目录层级超过 %d 层,疑似循环引用,已中止", remoteMaxDepth) } nextToken := "" + seenTokens := make(map[string]struct{}) for { args := map[string]any{"maxResults": float64(driveListPageSize)} if spaceID != "" { @@ -285,36 +325,51 @@ func walkRemoteForPush(ctx context.Context, spaceID, parentID, relBase string, f if err := claimRemotePath(occupied, childRel, "文件"); err != nil { return err } + fileID := it.id() + if fileID == "" { + return fmt.Errorf("远端文件 %q 缺少文件 ID,已中止遍历", childRel) + } modMillis, modValid := it.modifiedMillis() files[childRel] = &remoteFile{ RelPath: childRel, - FileID: it.id(), + FileID: fileID, Hash: it.hash(), ModifiedTime: modMillis, ModifiedTimeValid: modValid, } } - if token == "" || token == nextToken { + if token == "" { break } + if _, exists := seenTokens[token]; exists { + return fmt.Errorf("远端目录 %q 的分页 token 重复,疑似分页循环,已中止", relBase) + } + seenTokens[token] = struct{}{} nextToken = token } return nil } func printDrivePushDryRun(ifExists string, remoteFiles map[string]*remoteFile, remoteFolders map[string]string, localDirs []string, localFiles []localPushFile) error { + return printDrivePushDryRunWithPreflight(ifExists, remoteFiles, remoteFolders, localDirs, localFiles, + buildMirrorPreflight(localDirs, localFiles, remoteFiles, remoteFolders)) +} + +func printDrivePushDryRunWithPreflight(ifExists string, remoteFiles map[string]*remoteFile, remoteFolders map[string]string, localDirs []string, localFiles []localPushFile, preflight map[string]string) error { plan := drivePushResult{Items: make([]drivePushItem, 0, len(localDirs)+len(localFiles))} + if len(preflight) > 0 { + appendDrivePushPreflightFailures(&plan, preflight) + return deps.Out.PrintJSON(drivePushDryRunResult{ + DryRun: true, Executed: false, PreviewKind: "plan", Operation: "drive push", + IfExists: ifExists, Plan: plan, + }) + } plannedFolders := make(map[string]string, len(remoteFolders)+len(localDirs)) for rel, fileID := range remoteFolders { plannedFolders[rel] = fileID } for _, dir := range localDirs { - if msg := pushTypeConflictError(dir, true, remoteFiles, remoteFolders); msg != "" { - plan.Summary.Failed++ - plan.Items = append(plan.Items, drivePushItem{RelPath: dir, Action: pushActionFailed, Error: msg}) - continue - } if _, ok := plannedFolders[dir]; ok { continue } @@ -329,11 +384,6 @@ func printDrivePushDryRun(ifExists string, remoteFiles map[string]*remoteFile, r } for _, lf := range localFiles { size := lf.Size - if msg := pushTypeConflictError(lf.RelPath, false, remoteFiles, remoteFolders); msg != "" { - plan.Summary.Failed++ - plan.Items = append(plan.Items, drivePushItem{RelPath: lf.RelPath, Action: pushActionFailed, SizeBytes: &size, Error: msg}) - continue - } parentRel, _ := splitRel(lf.RelPath) if plannedFolders[parentRel] == "" { plan.Summary.Failed++ @@ -368,6 +418,14 @@ func printDrivePushDryRun(ifExists string, remoteFiles map[string]*remoteFile, r }) } +func appendDrivePushPreflightFailures(res *drivePushResult, preflight map[string]string) { + res.Summary.Aborted = true + for _, rel := range mirrorPreflightFailureRels(preflight) { + res.Summary.Failed++ + res.Items = append(res.Items, drivePushItem{RelPath: rel, Action: pushActionFailed, Error: preflight[rel]}) + } +} + // pushTypeConflictError 在任何写操作或 dry-run 成功预览之前,拒绝同一路径下 // “本地目录 ↔ 远端文件”或“本地文件 ↔ 远端目录”的类型冲突。 func pushTypeConflictError(rel string, localIsFolder bool, remoteFiles map[string]*remoteFile, remoteFolders map[string]string) string { @@ -386,19 +444,206 @@ func pushTypeConflictError(rel string, localIsFolder bool, remoteFiles map[strin // walkLocalForPush 遍历本地根目录,返回所有子目录 rel_path(不含根本身,浅层在前) // 与所有常规文件。dirs 升序排序保证父目录先于子目录被创建。 func walkLocalForPush(root string) ([]string, []localPushFile, error) { + pinned, err := openExistingPinnedPullRoot(root) + if err != nil { + return nil, nil, err + } + defer pinned.close() + return walkLocalForPushPinned(pinned) +} + +// 文件系统遍历与命令入口通过显式 seam 暴露仅用于确定性覆盖 I/O 失败;测试必须用 +// testseam.Swap 替换,生产仍直接使用 fs.WalkDir / walkLocalForPushPinned。 +var ( + walkPinnedLocalFS = fs.WalkDir + runDriveMirrorWalkLocalForPushPinned = walkLocalForPushPinned +) + +// walkLocalForPushPinned 从命令开始时固定的同一个 os.Root/FS 扫描本地树。即使调用方 +// 给出的路径随后被替换成软链或另一目录,遍历也只会读取原目录树。 +func walkLocalForPushPinned(root *pinnedPullRoot) ([]string, []localPushFile, error) { + if err := root.verify(); err != nil { + return nil, nil, err + } var dirs []string var files []localPushFile - err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { - return walkLocalForPushEntry(root, path, d, err, &dirs, &files) + err := walkPinnedLocalFS(root.root.FS(), ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if path == "." { + return nil + } + relSlash := filepath.ToSlash(path) + if d.IsDir() { + dirs = append(dirs, relSlash) + return nil + } + info, infoErr := d.Info() + if infoErr != nil { + return infoErr + } + if !info.Mode().IsRegular() { + return nil + } + files = append(files, localPushFile{ + RelPath: relSlash, AbsPath: filepath.Join(root.absDir, filepath.FromSlash(relSlash)), + ModTimeMillis: info.ModTime().UnixMilli(), Size: info.Size(), scanInfo: info, + }) + return nil }) if err != nil { return nil, nil, err } + if err := root.verify(); err != nil { + return nil, nil, err + } // 字典序即浅层在前("a" < "a/b" < "a/b/c"),父目录先于子目录。 sort.Strings(dirs) + sort.Slice(files, func(i, j int) bool { return files[i].RelPath < files[j].RelPath }) return dirs, files, nil } +// judgePinnedPushFileMatch 与 judgeFileMatch 的判定保持一致,但 exact 模式只从固定 +// os.Root 打开的句柄计算 MD5,绝不按可被换链的 AbsPath 重开文件。 +func judgePinnedPushFileMatch(root *pinnedPullRoot, local localPushFile, remote *remoteFile, quick bool) (string, error) { + if quick { + if remote.ModifiedTimeValid && local.ModTimeMillis == remote.ModifiedTime { + return matchUnchanged, nil + } + return matchModified, nil + } + if remote.Hash == "" { + return matchUnknown, nil + } + file, err := root.root.Open(filepath.FromSlash(local.RelPath)) + if err != nil { + return "", fmt.Errorf("计算 %s 的 MD5 失败: %w", local.RelPath, err) + } + defer file.Close() + info, err := pushStatOpenedFile(file) + if err != nil || !info.Mode().IsRegular() || (local.scanInfo != nil && !os.SameFile(local.scanInfo, info)) { + return "", fmt.Errorf("计算 %s 的 MD5 失败: 本地文件在扫描后被替换", local.RelPath) + } + hash, err := pushMD5OpenedFile(file) + if err != nil { + return "", fmt.Errorf("计算 %s 的 MD5 失败: %w", local.RelPath, err) + } + if err := pushVerifyPinnedSourceRoot(root); err != nil { + return "", err + } + if hash == remote.Hash { + return matchUnchanged, nil + } + return matchModified, nil +} + +// mirrorPathKey 表示 Drive 镜像契约中的等价路径:逐段 Unicode NFC + 大小写折叠。 +// 该规则独立于运行命令的本地文件系统,避免 macOS/Windows 与 Drive 间把 a/A 或 +// NFC/NFD 当成“双向新增”,也避免把等价目录前缀错误拼接成另一棵树。 +func mirrorPathKey(rel string) string { + parts := strings.Split(rel, "/") + for i := range parts { + parts[i] = norm.NFC.String(strings.ToLower(parts[i])) + } + return strings.Join(parts, "/") +} + +func mirrorLocalRelError(rel string) string { + for _, segment := range strings.Split(rel, "/") { + if !isSafeRemoteSegment(segment) || strings.IndexByte(segment, 0) >= 0 { + return fmt.Sprintf("本地路径 %q 含无法安全映射到远端的名称段 %q,已中止镜像", rel, segment) + } + } + return "" +} + +// buildMirrorPreflight 在任何远端写之前统一检查本地名称和双端等价路径/类型。 +// 每个条目同时声明全部祖先目录,因而 local A/x 与 remote a/y 会在祖先键上冲突; +// 即便本地为空,remote A/a 也会被完整性门禁识别。调用方必须整体拒绝写入,不能只 +// 跳过冲突项。 +func buildMirrorPreflight(localDirs []string, localFiles []localPushFile, remoteFiles map[string]*remoteFile, remoteFolders map[string]string) map[string]string { + type mirrorKind string + const ( + mirrorFile mirrorKind = "文件" + mirrorDir mirrorKind = "目录" + ) + type entry struct { + rel string + kind mirrorKind + } + local := make(map[string]map[entry]struct{}) + remote := make(map[string]map[entry]struct{}) + failures := make(map[string]string) + addClaims := func(index map[string]map[entry]struct{}, rel string, kind mirrorKind) { + parts := strings.Split(rel, "/") + for i := range parts { + claimKind := mirrorDir + if i == len(parts)-1 { + claimKind = kind + } + claimRel := strings.Join(parts[:i+1], "/") + key := mirrorPathKey(claimRel) + if index[key] == nil { + index[key] = make(map[entry]struct{}) + } + index[key][entry{rel: claimRel, kind: claimKind}] = struct{}{} + } + } + addLocal := func(rel string, kind mirrorKind) { + if msg := mirrorLocalRelError(rel); msg != "" { + failures[rel] = msg + } + addClaims(local, rel, kind) + } + for _, rel := range localDirs { + addLocal(rel, mirrorDir) + } + for _, f := range localFiles { + addLocal(f.RelPath, mirrorFile) + } + for rel := range remoteFolders { + if rel != "" { + addClaims(remote, rel, mirrorDir) + } + } + for rel := range remoteFiles { + addClaims(remote, rel, mirrorFile) + } + keys := make(map[string]struct{}, len(local)+len(remote)) + for key := range local { + keys[key] = struct{}{} + } + for key := range remote { + keys[key] = struct{}{} + } + for key := range keys { + claims := make(map[entry]struct{}, len(local[key])+len(remote[key])) + for claim := range local[key] { + claims[claim] = struct{}{} + } + for claim := range remote[key] { + claims[claim] = struct{}{} + } + if len(claims) <= 1 { + continue + } + for claim := range claims { + failures[claim.rel] = fmt.Sprintf("%s路径 %q 在镜像规则下存在大小写/Unicode NFC 等价路径拼写或类型歧义,已中止镜像", claim.kind, claim.rel) + } + } + return failures +} + +func mirrorPreflightFailureRels(preflight map[string]string) []string { + rels := make([]string, 0, len(preflight)) + for rel := range preflight { + rels = append(rels, rel) + } + sort.Strings(rels) + return rels +} + // walkLocalForPushEntry 是 walkLocalForPush 的单条目处理逻辑。抽成命名函数只为可测: // filepath.Rel / Info() 的失败在真实 WalkDir 下几乎不可复现,单测可直接注入。 func walkLocalForPushEntry(root, path string, d fs.DirEntry, err error, dirs *[]string, files *[]localPushFile) error { @@ -430,6 +675,7 @@ func walkLocalForPushEntry(root, path string, d fs.DirEntry, err error, dirs *[] AbsPath: path, ModTimeMillis: info.ModTime().UnixMilli(), Size: info.Size(), + scanInfo: info, }) return nil } @@ -457,6 +703,51 @@ func pushCreateFolder(ctx context.Context, spaceID, parentID, name string) (stri // commit_upload 两个阶段都传 overwriteFileId、都不传 parentId(服务端据此设置 // conflictStrategy=OVERWRITE,原地覆盖而非在同目录新建重名副本)。 func pushUploadFile(ctx context.Context, spaceID, parentID, overwriteFileID, fileName, filePath string, fileSize int64) error { + return pushUploadWithTransport(ctx, spaceID, parentID, overwriteFileID, fileName, fileSize, + func(resourceURL string, ossHeaders map[string]string) error { + return httpPutFile(ctx, resourceURL, ossHeaders, filePath, fileSize) + }) +} + +// pushUploadFilePinned 从固定本地根打开 rel_path,并在发出 get_upload_info 前核对 +// 文件身份与扫描快照。HTTP PUT 只读取这个已打开句柄,绝不按 AbsPath 二次解析。 +func pushUploadFilePinned(ctx context.Context, spaceID, parentID, overwriteFileID, fileName string, root *pinnedPullRoot, local localPushFile) error { + if err := root.verify(); err != nil { + return err + } + file, err := root.root.Open(filepath.FromSlash(local.RelPath)) + if err != nil { + return fmt.Errorf("打开本地上传文件失败: %w", err) + } + defer file.Close() + info, err := pushStatOpenedFile(file) + if err != nil { + return fmt.Errorf("读取本地上传文件身份失败: %w", err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("本地上传目标 %q 已不再是常规文件", local.RelPath) + } + if local.scanInfo != nil && !os.SameFile(local.scanInfo, info) { + return fmt.Errorf("本地上传目标 %q 在扫描后被替换,已中止上传", local.RelPath) + } + if info.Size() != local.Size || info.ModTime().UnixMilli() != local.ModTimeMillis { + return fmt.Errorf("本地上传目标 %q 在扫描后发生变化,已中止上传", local.RelPath) + } + if err := pushVerifyPinnedSourceRoot(root); err != nil { + return err + } + return pushUploadWithTransport(ctx, spaceID, parentID, overwriteFileID, fileName, local.Size, + func(resourceURL string, ossHeaders map[string]string) error { + if err := pushPutOpenedFile(ctx, resourceURL, ossHeaders, file, local.Size); err != nil { + return err + } + // OSS 传输期间若命令行根已被替换,不提交这个上传会话;已发送的字节仍来自 + // 固定根内的原文件句柄,不可能读取替代路径或根外内容。 + return root.verify() + }) +} + +func pushUploadWithTransport(ctx context.Context, spaceID, parentID, overwriteFileID, fileName string, fileSize int64, put func(string, map[string]string) error) error { step1 := map[string]any{"fileName": fileName, "fileSize": float64(fileSize)} if spaceID != "" { step1["spaceId"] = spaceID @@ -474,7 +765,7 @@ func pushUploadFile(ctx context.Context, spaceID, parentID, overwriteFileID, fil if err != nil { return err } - if err := httpPutFile(ctx, resourceURL, ossHeaders, filePath, fileSize); err != nil { + if err := put(resourceURL, ossHeaders); err != nil { return err } commit := map[string]any{"fileName": fileName, "fileSize": float64(fileSize), "uploadId": uploadID} @@ -486,8 +777,46 @@ func pushUploadFile(ctx context.Context, spaceID, parentID, overwriteFileID, fil } else if parentID != "" { commit["parentId"] = parentID } - _, err = callMCPToolReturnText(ctx, "commit_upload", commit) - return err + commitText, err := callMCPToolReturnText(ctx, "commit_upload", commit) + if err != nil { + return err + } + if strings.TrimSpace(commitText) == "" { + return fmt.Errorf("commit_upload returned no business result; remote effect is unknown") + } + var result map[string]any + if err := json.Unmarshal([]byte(commitText), &result); err != nil { + return fmt.Errorf("parse commit_upload response: %w", err) + } + if len(result) == 0 { + return fmt.Errorf("commit_upload returned an empty JSON object; remote effect is unknown") + } + return nil +} + +func defaultPushPutOpenedFile(ctx context.Context, url string, headers map[string]string, file *os.File, fileSize int64) error { + if _, err := file.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("failed to seek upload file: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, file) + if err != nil { + return fmt.Errorf("failed to create upload request: %w", err) + } + req.ContentLength = fileSize + req.Header.Del("Content-Type") + for key, value := range headers { + req.Header.Set(key, value) + } + resp, err := (&http.Client{Timeout: 5 * time.Minute}).Do(req) + if err != nil { + return fmt.Errorf("file upload failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("OSS upload failed: %w", &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)}) + } + return nil } // parseNodeID 从 create_folder / commit 等返回里抽出节点 fileId(带 fallback)。 diff --git a/internal/helpers/drive_push_coverage_test.go b/internal/helpers/drive_push_coverage_test.go index 494ef24c..56a22960 100644 --- a/internal/helpers/drive_push_coverage_test.go +++ b/internal/helpers/drive_push_coverage_test.go @@ -9,6 +9,8 @@ import ( "strconv" "strings" "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" ) var errTestUpload = errors.New("simulated upload failure") @@ -35,8 +37,7 @@ func pushOKCaller(listing map[string]string) *driveScriptCaller { func withNoopPut(t *testing.T) { t.Helper() - SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil }) - t.Cleanup(func() { SetHTTPPutFile(nil) }) + testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { return nil }) } // ────────────────────────────────────────────────────────── @@ -220,8 +221,7 @@ func TestCrossPlatformCoverageDrivePush_ifExistsSmart(t *testing.T) { func TestCrossPlatformCoverageDrivePush_uploadFailureExitsNonZero(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "a.txt"), "local") - SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return errTestUpload }) - t.Cleanup(func() { SetHTTPPutFile(nil) }) + testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { return errTestUpload }) caller := pushOKCaller(nil) err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT") @@ -306,10 +306,8 @@ func TestCrossPlatformCoverageWalkRemoteForPush_paginationRecursionAndSkips(t *t return `{"result":{"items":[],"nextToken":""}}`, nil }} - prevDeps, prevArgs := deps, os.Args - deps = &Deps{Caller: caller, Out: &Formatter{w: io.Discard}} - os.Args = []string{"dws", "drive"} - t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs }) + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive"}) files, folders, err := fetchRemoteTreeForPush(context.Background(), "", "ROOT") if err != nil { @@ -333,10 +331,8 @@ func TestCrossPlatformCoverageWalkRemoteForPush_depthLimitAborts(t *testing.T) { caller := &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) { return `{"result":{"items":[{"name":"loop","type":"folder","fileId":"LOOP"}],"nextToken":""}}`, nil }} - prevDeps, prevArgs := deps, os.Args - deps = &Deps{Caller: caller, Out: &Formatter{w: io.Discard}} - os.Args = []string{"dws", "drive"} - t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs }) + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive"}) if _, _, err := fetchRemoteTreeForPush(context.Background(), "", "ROOT"); err == nil || !strings.Contains(err.Error(), "循环引用") { @@ -413,18 +409,13 @@ func TestCrossPlatformCoveragePushUploadFile_failureBranches(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - prevDeps, prevArgs := deps, os.Args - deps = &Deps{Caller: &driveScriptCaller{reply: tc.reply}, Out: &Formatter{w: io.Discard}} - os.Args = []string{"dws", "drive"} + testseam.Swap(t, &deps, &Deps{Caller: &driveScriptCaller{reply: tc.reply}, Out: &Formatter{w: io.Discard}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive"}) put := tc.put if put == nil { put = func(context.Context, string, map[string]string, string, int64) error { return nil } } - SetHTTPPutFile(put) - t.Cleanup(func() { - deps, os.Args = prevDeps, prevArgs - SetHTTPPutFile(nil) - }) + testseam.Swap(t, &httpPutFile, put) if err := pushUploadFile(context.Background(), "SP", "PARENT", "", "a.txt", p, 7); err == nil { t.Fatal("expected upload failure") @@ -435,13 +426,11 @@ func TestCrossPlatformCoveragePushUploadFile_failureBranches(t *testing.T) { // pushCreateFolder:MCP 失败上抛,成功时从返回体提取 fileId。 func TestCrossPlatformCoveragePushCreateFolder_errorAndSuccess(t *testing.T) { - prevDeps, prevArgs := deps, os.Args - os.Args = []string{"dws", "drive"} - t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs }) + testseam.Swap(t, &os.Args, []string{"dws", "drive"}) - deps = &Deps{Caller: &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) { + testseam.Swap(t, &deps, &Deps{Caller: &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) { return "", errTestUpload - }}, Out: &Formatter{w: io.Discard}} + }}, Out: &Formatter{w: io.Discard}}) if _, err := pushCreateFolder(context.Background(), "", "P", "n"); err == nil { t.Fatal("expected create_folder error to propagate") } @@ -449,7 +438,7 @@ func TestCrossPlatformCoveragePushCreateFolder_errorAndSuccess(t *testing.T) { ok := &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) { return `{"result":{"fileId":"FID42"},"success":true}`, nil }} - deps = &Deps{Caller: ok, Out: &Formatter{w: io.Discard}} + testseam.Swap(t, &deps, &Deps{Caller: ok, Out: &Formatter{w: io.Discard}}) got, err := pushCreateFolder(context.Background(), "SP", "P", "n") if err != nil || got != "FID42" { t.Fatalf("pushCreateFolder = (%q, %v), want (FID42, nil)", got, err) diff --git a/internal/helpers/drive_push_sync_final_coverage_test.go b/internal/helpers/drive_push_sync_final_coverage_test.go new file mode 100644 index 00000000..ddbe2a8b --- /dev/null +++ b/internal/helpers/drive_push_sync_final_coverage_test.go @@ -0,0 +1,632 @@ +package helpers + +import ( + "context" + "crypto/md5" + "encoding/base64" + "errors" + "io" + "io/fs" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +var errDriveFinalCoverage = errors.New("drive final coverage failure") + +func openDriveFinalCoverageRoot(t *testing.T) (string, *pinnedPullRoot, localPushFile) { + t.Helper() + rootPath := t.TempDir() + path := filepath.Join(rootPath, "a.txt") + mustWrite(t, path, "payload") + info, err := os.Lstat(path) + if err != nil { + t.Fatal(err) + } + root, err := openExistingPinnedPullRoot(rootPath) + if err != nil { + t.Fatal(err) + } + t.Cleanup(root.close) + return rootPath, root, localPushFile{ + RelPath: "a.txt", AbsPath: path, Size: info.Size(), + ModTimeMillis: info.ModTime().UnixMilli(), scanInfo: info, + } +} + +func TestCrossPlatformCoverageDrivePushFinalMD5AndPinnedMatchEdges(t *testing.T) { + t.Run("default md5 success and read failure", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "payload") + mustWrite(t, path, "payload") + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + got, err := pushMD5OpenedFile(file) + _ = file.Close() + wantSum := md5.Sum([]byte("payload")) + want := base64.StdEncoding.EncodeToString(wantSum[:]) + if err != nil || got != want { + t.Fatalf("md5=(%q,%v), want %q", got, err, want) + } + if _, err := pushMD5OpenedFile(file); err == nil { + t.Fatal("closed file must fail MD5 reading") + } + }) + + t.Run("open failure", func(t *testing.T) { + _, root, local := openDriveFinalCoverageRoot(t) + local.RelPath = "missing.txt" + if _, err := judgePinnedPushFileMatch(root, local, &remoteFile{Hash: "x"}, false); err == nil { + t.Fatal("missing file must fail exact matching") + } + }) + + t.Run("stat and identity failures", func(t *testing.T) { + _, root, local := openDriveFinalCoverageRoot(t) + testseam.Swap(t, &pushStatOpenedFile, func(*os.File) (os.FileInfo, error) { + return nil, errDriveFinalCoverage + }) + if _, err := judgePinnedPushFileMatch(root, local, &remoteFile{Hash: "x"}, false); err == nil { + t.Fatal("opened file stat failure must abort matching") + } + }) + + t.Run("directory and replaced scan identity", func(t *testing.T) { + rootPath, root, local := openDriveFinalCoverageRoot(t) + if _, err := judgePinnedPushFileMatch(root, localPushFile{RelPath: "."}, &remoteFile{Hash: "x"}, false); err == nil { + t.Fatal("directory must not be hashed as a regular file") + } + other := filepath.Join(rootPath, "other.txt") + mustWrite(t, other, "other") + otherInfo, _ := os.Lstat(other) + local.scanInfo = otherInfo + if _, err := judgePinnedPushFileMatch(root, local, &remoteFile{Hash: "x"}, false); err == nil { + t.Fatal("scan identity mismatch must abort matching") + } + }) + + t.Run("hash failure and root verification", func(t *testing.T) { + _, root, local := openDriveFinalCoverageRoot(t) + testseam.Swap(t, &pushMD5OpenedFile, func(*os.File) (string, error) { + return "", errDriveFinalCoverage + }) + if _, err := judgePinnedPushFileMatch(root, local, &remoteFile{Hash: "x"}, false); err == nil { + t.Fatal("hash read failure must abort matching") + } + }) + + t.Run("root verification and both verdicts", func(t *testing.T) { + _, root, local := openDriveFinalCoverageRoot(t) + sum := md5.Sum([]byte("payload")) + hash := base64.StdEncoding.EncodeToString(sum[:]) + testseam.Swap(t, &pushVerifyPinnedSourceRoot, func(*pinnedPullRoot) error { + return errDriveFinalCoverage + }) + if _, err := judgePinnedPushFileMatch(root, local, &remoteFile{Hash: hash}, false); !errors.Is(err, errDriveFinalCoverage) { + t.Fatalf("root verification error=%v", err) + } + }) + + t.Run("unchanged and modified", func(t *testing.T) { + _, root, local := openDriveFinalCoverageRoot(t) + sum := md5.Sum([]byte("payload")) + hash := base64.StdEncoding.EncodeToString(sum[:]) + if got, err := judgePinnedPushFileMatch(root, local, &remoteFile{Hash: hash}, false); err != nil || got != matchUnchanged { + t.Fatalf("equal hash=(%q,%v)", got, err) + } + if got, err := judgePinnedPushFileMatch(root, local, &remoteFile{Hash: "different"}, false); err != nil || got != matchModified { + t.Fatalf("different hash=(%q,%v)", got, err) + } + }) +} + +func TestCrossPlatformCoverageDrivePushFinalPinnedUploadGuards(t *testing.T) { + t.Run("initial root replacement", func(t *testing.T) { + rootPath, root, local := openDriveFinalCoverageRoot(t) + moved := filepath.Join(filepath.Dir(rootPath), "moved-root") + replacePinnedMirrorRoot(t, rootPath, moved) + if err := pushUploadFilePinned(context.Background(), "", "P", "", "a.txt", root, local); err == nil { + t.Fatal("replaced root must abort before opening upload") + } + }) + + t.Run("open and stat failures", func(t *testing.T) { + _, root, local := openDriveFinalCoverageRoot(t) + _ = root.root.Close() + if err := pushUploadFilePinned(context.Background(), "", "P", "", "a.txt", root, local); err == nil { + t.Fatal("closed pinned root must fail file open") + } + }) + + t.Run("opened file stat failure", func(t *testing.T) { + _, root, local := openDriveFinalCoverageRoot(t) + testseam.Swap(t, &pushStatOpenedFile, func(*os.File) (os.FileInfo, error) { + return nil, errDriveFinalCoverage + }) + if err := pushUploadFilePinned(context.Background(), "", "P", "", "a.txt", root, local); err == nil { + t.Fatal("stat failure must abort upload") + } + }) + + t.Run("non regular replaced and changed", func(t *testing.T) { + rootPath, root, local := openDriveFinalCoverageRoot(t) + if err := pushUploadFilePinned(context.Background(), "", "P", "", "root", root, localPushFile{RelPath: "."}); err == nil { + t.Fatal("directory upload must fail") + } + other := filepath.Join(rootPath, "other") + mustWrite(t, other, "other") + otherInfo, _ := os.Lstat(other) + replaced := local + replaced.scanInfo = otherInfo + if err := pushUploadFilePinned(context.Background(), "", "P", "", "a.txt", root, replaced); err == nil { + t.Fatal("replaced scan identity must fail") + } + changed := local + changed.Size++ + if err := pushUploadFilePinned(context.Background(), "", "P", "", "a.txt", root, changed); err == nil { + t.Fatal("changed size must fail") + } + }) + + t.Run("second root verification", func(t *testing.T) { + _, root, local := openDriveFinalCoverageRoot(t) + testseam.Swap(t, &pushVerifyPinnedSourceRoot, func(*pinnedPullRoot) error { + return errDriveFinalCoverage + }) + if err := pushUploadFilePinned(context.Background(), "", "P", "", "a.txt", root, local); !errors.Is(err, errDriveFinalCoverage) { + t.Fatalf("second verification error=%v", err) + } + }) +} + +func TestCrossPlatformCoverageDrivePushFinalWalkAndCommandGates(t *testing.T) { + t.Run("command scan errors", func(t *testing.T) { + for _, command := range []string{"push", "sync"} { + t.Run(command, func(t *testing.T) { + testseam.Swap(t, &runDriveMirrorWalkLocalForPushPinned, func(*pinnedPullRoot) ([]string, []localPushFile, error) { + return nil, nil, errDriveFinalCoverage + }) + err := runDriveCmd(t, pushOKCaller(nil), command, "--local-folder", t.TempDir(), "--remote-folder", "ROOT") + if !errors.Is(err, errDriveFinalCoverage) { + t.Fatalf("scan error=%v", err) + } + }) + } + }) + + t.Run("walk initial and callback errors", func(t *testing.T) { + rootPath, root, _ := openDriveFinalCoverageRoot(t) + moved := filepath.Join(filepath.Dir(rootPath), "moved-walk-root") + replacePinnedMirrorRoot(t, rootPath, moved) + if _, _, err := walkLocalForPushPinned(root); err == nil { + t.Fatal("initial root replacement must fail walk") + } + }) + + t.Run("walk entry info error", func(t *testing.T) { + _, root, _ := openDriveFinalCoverageRoot(t) + testseam.Swap(t, &walkPinnedLocalFS, func(_ fs.FS, _ string, fn fs.WalkDirFunc) error { + return fn("bad", errDirEntry{}, nil) + }) + if _, _, err := walkLocalForPushPinned(root); !errors.Is(err, errTestInfo) { + t.Fatalf("walk info error=%v", err) + } + }) + + t.Run("walk final root verification", func(t *testing.T) { + rootPath, root, _ := openDriveFinalCoverageRoot(t) + moved := filepath.Join(filepath.Dir(rootPath), "moved-after-walk") + testseam.Swap(t, &walkPinnedLocalFS, func(_ fs.FS, _ string, _ fs.WalkDirFunc) error { + replacePinnedMirrorRoot(t, rootPath, moved) + return nil + }) + if _, _, err := walkLocalForPushPinned(root); err == nil { + t.Fatal("post-walk root replacement must fail") + } + }) + + t.Run("regular entry", func(t *testing.T) { + var dirs []string + var files []localPushFile + if err := walkLocalForPushEntry("/root", "/root/a", regularDirEntry{}, nil, &dirs, &files); err != nil || len(files) != 1 { + t.Fatalf("files=%v err=%v", files, err) + } + }) + + t.Run("root replacement between folder writes", func(t *testing.T) { + for _, command := range []string{"push", "sync"} { + t.Run(command, func(t *testing.T) { + parent := t.TempDir() + rootPath := filepath.Join(parent, "root") + if err := os.MkdirAll(filepath.Join(rootPath, "a"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(rootPath, "b"), 0o755); err != nil { + t.Fatal(err) + } + moved := filepath.Join(parent, "moved") + caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, nth int) (string, error) { + switch tool { + case "list_files": + return `{"result":{"items":[]}}`, nil + case "create_folder": + if nth == 0 { + replacePinnedMirrorRoot(t, rootPath, moved) + } + return `{"result":{"fileId":"DIR"}}`, nil + default: + return `{"result":{"fileId":"FILE"}}`, nil + } + }} + args := []string{command, "--local-folder", rootPath, "--remote-folder", "ROOT"} + if command == "sync" { + args = append(args, "--quick") + } + if err := runDriveCmd(t, caller, args...); err == nil || !strings.Contains(err.Error(), "本地根目录") { + t.Fatalf("root replacement error=%v", err) + } + }) + } + }) +} + +func TestCrossPlatformCoverageDrivePushFinalDefaultOpenedPUTAndCommit(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if request.Header.Get("X-Test") != "yes" { + t.Errorf("header=%q", request.Header.Get("X-Test")) + } + if request.URL.Path == "/status" { + http.Error(w, "denied", http.StatusTeapot) + return + } + _, _ = io.Copy(io.Discard, request.Body) + })) + defer server.Close() + path := filepath.Join(t.TempDir(), "payload") + mustWrite(t, path, "payload") + put := func(ctx context.Context, url string, headers map[string]string) error { + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + return defaultPushPutOpenedFile(ctx, url, headers, file, 7) + } + if err := put(context.Background(), ":", nil); err == nil { + t.Fatal("invalid PUT URL must fail") + } + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + if err := put(cancelled, server.URL, nil); !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled PUT error=%v", err) + } + if err := put(context.Background(), server.URL+"/status", map[string]string{"X-Test": "yes"}); err == nil { + t.Fatal("non-200 PUT must fail") + } + if err := put(context.Background(), server.URL+"/ok", map[string]string{"X-Test": "yes"}); err != nil { + t.Fatal(err) + } + closed, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + _ = closed.Close() + if err := defaultPushPutOpenedFile(context.Background(), server.URL, nil, closed, 7); err == nil { + t.Fatal("closed upload file must fail seek") + } + + caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, _ int) (string, error) { + if tool == "get_upload_info" { + return `{"result":{"resourceUrls":[{"url":"https://upload.invalid","headers":{}}],"uploadId":"U"}}`, nil + } + return "{", nil + }} + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive"}) + if err := pushUploadWithTransport(context.Background(), "", "P", "", "a.txt", 7, func(string, map[string]string) error { return nil }); err == nil || !strings.Contains(err.Error(), "parse commit_upload") { + t.Fatalf("malformed commit error=%v", err) + } +} + +func TestCrossPlatformCoverageDriveSyncFinalPreflightAndKeepBothErrors(t *testing.T) { + t.Run("local target preflight lstat error and directory", func(t *testing.T) { + _, root, _ := openDriveFinalCoverageRoot(t) + failures := map[string]string{} + testseam.Swap(t, &pullRootLstat, func(*os.Root, string) (os.FileInfo, error) { + return nil, errDriveFinalCoverage + }) + addPinnedLocalTargetPreflightFailures(root, map[string]*remoteFile{"remote.txt": {}}, failures) + if !strings.Contains(failures["remote.txt"], "检查本地目标失败") { + t.Fatalf("failures=%v", failures) + } + }) + + t.Run("local target preflight walks existing directory", func(t *testing.T) { + rootPath, root, _ := openDriveFinalCoverageRoot(t) + if err := os.Mkdir(filepath.Join(rootPath, "sub"), 0o755); err != nil { + t.Fatal(err) + } + failures := map[string]string{} + addPinnedLocalTargetPreflightFailures(root, map[string]*remoteFile{"sub/remote.txt": {}}, failures) + if len(failures) != 0 { + t.Fatalf("failures=%v", failures) + } + addPinnedLocalTargetPreflightFailures(root, map[string]*remoteFile{"sub": {}}, failures) + if failures["sub"] == "" { + t.Fatal("terminal directory must fail file target preflight") + } + }) + + t.Run("wrapper root failures", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "file") + mustWrite(t, path, "x") + pullResult := &driveSyncResult{} + syncPullFile(pullResult, context.Background(), "", &remoteFile{}, path, "a", syncDirectionPull) + if pullResult.Summary.Failed != 1 { + t.Fatalf("pull result=%#v", pullResult) + } + keepResult := &driveSyncResult{} + syncKeepBoth(keepResult, context.Background(), "", &remoteFile{}, path, "a", map[string]bool{}) + if keepResult.Summary.Failed != 1 { + t.Fatalf("keep result=%#v", keepResult) + } + }) + + t.Run("missing and non-regular local versions", func(t *testing.T) { + rootPath := t.TempDir() + missing := &driveSyncResult{} + syncKeepBoth(missing, context.Background(), "", &remoteFile{FileID: "F"}, rootPath, "missing.txt", map[string]bool{}) + if missing.Summary.Failed != 1 || !strings.Contains(missing.Items[0].Error, "不存在") { + t.Fatalf("missing result=%#v", missing) + } + if err := os.Mkdir(filepath.Join(rootPath, "dir"), 0o755); err != nil { + t.Fatal(err) + } + nonRegular := &driveSyncResult{} + syncKeepBoth(nonRegular, context.Background(), "", &remoteFile{FileID: "F"}, rootPath, "dir", map[string]bool{}) + if nonRegular.Summary.Failed != 1 || !strings.Contains(nonRegular.Items[0].Error, "不是常规文件") { + t.Fatalf("non-regular result=%#v", nonRegular) + } + }) + + t.Run("saved identity mismatch", func(t *testing.T) { + rootPath := t.TempDir() + mustWrite(t, filepath.Join(rootPath, "a.txt"), "local") + other := filepath.Join(t.TempDir(), "other") + mustWrite(t, other, "other") + otherInfo, _ := os.Lstat(other) + candidate := filepath.FromSlash(syncKeepBothCandidate("a.txt", "F", 0)) + testseam.Swap(t, &pullRootLstat, func(root *os.Root, name string) (os.FileInfo, error) { + if name == candidate { + return otherInfo, nil + } + return root.Lstat(name) + }) + res := &driveSyncResult{} + syncKeepBoth(res, context.Background(), "", &remoteFile{FileID: "F"}, rootPath, "a.txt", map[string]bool{}) + if res.Summary.Failed != 1 || !strings.Contains(res.Items[0].Error, "身份不一致") { + t.Fatalf("result=%#v", res) + } + }) + + t.Run("nil transfer error fallback", func(t *testing.T) { + rootPath := t.TempDir() + mustWrite(t, filepath.Join(rootPath, "a.txt"), "local") + testseam.Swap(t, &syncPullOneFilePinned, func(context.Context, string, *remoteFile, *pinnedPullTarget, string) (string, error) { + return pullActionFailed, nil + }) + res := &driveSyncResult{} + syncKeepBoth(res, context.Background(), "", &remoteFile{FileID: "F"}, rootPath, "a.txt", map[string]bool{}) + if res.Summary.Failed != 1 || !strings.Contains(res.Items[0].Error, "未能拉取") { + t.Fatalf("result=%#v", res) + } + }) + + t.Run("unsafe candidate and parent verification", func(t *testing.T) { + rootPath := t.TempDir() + mustWrite(t, filepath.Join(rootPath, "a.txt"), "local") + target, err := openPinnedPullTarget(rootPath, "a.txt") + if err != nil { + t.Fatal(err) + } + if _, _, err := reserveSyncKeepBothTargetPinned(target, "\x00", "", map[string]bool{}); err == nil { + t.Fatal("unsafe candidate must fail") + } + target.close() + + target, err = openPinnedPullTarget(rootPath, "a.txt") + if err != nil { + t.Fatal(err) + } + defer target.close() + moved := filepath.Join(filepath.Dir(rootPath), "moved-keep-root") + replacePinnedMirrorRoot(t, rootPath, moved) + if _, _, err := reserveSyncKeepBothTargetPinned(target, "a.txt", "", map[string]bool{}); err == nil { + t.Fatal("replaced parent must fail before link") + } + }) +} + +type driveFinalCallbackReader struct{ callback func() } + +func (reader driveFinalCallbackReader) Read(buffer []byte) (int, error) { + reader.callback() + return copy(buffer, "s\n"), nil +} + +func TestCrossPlatformCoverageDriveSyncFinalPostDecisionRootVerification(t *testing.T) { + parent := t.TempDir() + rootPath := filepath.Join(parent, "root") + if err := os.Mkdir(rootPath, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(rootPath, "a.txt") + mustWrite(t, path, "local") + mtime := readFileMTimeMillis(t, path) + moved := filepath.Join(parent, "moved") + testseam.Swap(t, &syncAskStdin, io.Reader(driveFinalCallbackReader{callback: func() { + replacePinnedMirrorRoot(t, rootPath, moved) + }})) + caller := syncCaller(map[string]string{ + "ROOT": `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"F","modifyTime":` + differentMillis(mtime) + `}]}}`, + }) + err := runDriveCmd(t, caller, "sync", "--local-folder", rootPath, "--remote-folder", "ROOT", "--quick", "--on-conflict", "ask") + if err == nil || !strings.Contains(err.Error(), "本地根目录") { + t.Fatalf("post-decision root verification error=%v", err) + } +} + +func TestCrossPlatformCoverageDrivePushSyncFinalCommandValidationAndPrintErrors(t *testing.T) { + for _, command := range []string{"push", "sync"} { + t.Run(command+" required", func(t *testing.T) { + cmd := findDriveSubcommand(t, command) + var err error + if command == "push" { + err = runDrivePush(cmd, nil) + } else { + err = runDriveSync(cmd, nil) + } + if err == nil || !strings.Contains(err.Error(), "required") { + t.Fatalf("required error=%v", err) + } + }) + + t.Run(command+" relative", func(t *testing.T) { + cmd := findDriveSubcommand(t, command) + mustSetFlags(t, cmd, map[string]string{"local-folder": "relative", "remote-folder": "ROOT"}) + var err error + if command == "push" { + err = runDrivePush(cmd, nil) + } else { + err = runDriveSync(cmd, nil) + } + if err == nil || !strings.Contains(err.Error(), "绝对路径") { + t.Fatalf("relative error=%v", err) + } + }) + + t.Run(command+" preflight print", func(t *testing.T) { + rootPath := t.TempDir() + mustWrite(t, filepath.Join(rootPath, "A.txt"), "local") + caller := pushOKCaller(map[string]string{ + "ROOT": `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"F"}]}}`, + }) + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: failingWriter{}}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive", command}) + cmd := findDriveSubcommand(t, command) + flags := map[string]string{"local-folder": rootPath, "remote-folder": "ROOT"} + if command == "sync" { + flags["quick"] = "true" + } + mustSetFlags(t, cmd, flags) + var err error + if command == "push" { + err = runDrivePush(cmd, nil) + } else { + err = runDriveSync(cmd, nil) + } + if err == nil || !strings.Contains(err.Error(), "write failed") { + t.Fatalf("PrintJSON error=%v", err) + } + }) + } +} + +func TestCrossPlatformCoverageDriveSyncFinalPullWrapperSuccess(t *testing.T) { + rootPath := t.TempDir() + installPinnedPullSuccess(t) + res := &driveSyncResult{} + syncPullFile(res, context.Background(), "", &remoteFile{FileID: "F"}, rootPath, "a.txt", syncDirectionPull) + if res.Summary.Pulled != 1 || res.Summary.Failed != 0 { + t.Fatalf("result=%#v", res) + } +} + +func TestCrossPlatformCoverageDrivePushFinalPureRemainingEdges(t *testing.T) { + if got := mirrorLocalRelError("good/../bad"); got == "" { + t.Fatal("unsafe local mirror segment must fail") + } + failures := buildMirrorPreflight(nil, []localPushFile{{RelPath: "bad\x00name"}}, nil, nil) + if failures["bad\x00name"] == "" { + t.Fatalf("failures=%v", failures) + } + if got := parseNodeID("{"); got != "" { + t.Fatalf("malformed node ID=%q", got) + } +} + +func TestCrossPlatformCoverageDrivePushFinalRemoteWalkerFailClosedEdges(t *testing.T) { + cases := []struct { + name string + reply func(map[string]any) string + want string + }{ + { + name: "unsafe entry", + reply: func(map[string]any) string { + return `{"result":{"items":[{"name":"../escape","type":"file","fileId":"F"}]}}` + }, + want: "无法安全映射", + }, + { + name: "duplicate folder", + reply: func(args map[string]any) string { + if args["parentId"] == "D" { + return `{"result":{"items":[]}}` + } + return `{"result":{"items":[{"name":"dir","type":"folder","fileId":"D"},{"name":"dir","type":"folder","fileId":"D"}]}}` + }, + want: "重复", + }, + { + name: "folder without ID", + reply: func(map[string]any) string { + return `{"result":{"items":[{"name":"dir","type":"folder"}]}}` + }, + want: "缺少目录 ID", + }, + { + name: "duplicate file", + reply: func(map[string]any) string { + return `{"result":{"items":[{"name":"a","type":"file","fileId":"A"},{"name":"a","type":"file","fileId":"A"}]}}` + }, + want: "重复", + }, + { + name: "file without ID", + reply: func(map[string]any) string { + return `{"result":{"items":[{"name":"a","type":"file"}]}}` + }, + want: "缺少文件 ID", + }, + { + name: "pagination cycle", + reply: func(map[string]any) string { + return `{"result":{"items":[],"nextToken":"P"}}` + }, + want: "分页 token 重复", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + caller := &driveScriptCaller{reply: func(tool string, args map[string]any, _ int) (string, error) { + if tool != "list_files" { + t.Fatalf("unexpected tool %s", tool) + } + return tc.reply(args), nil + }} + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive"}) + _, _, err := fetchRemoteTreeForPush(context.Background(), "", "ROOT") + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error=%v, want %q", err, tc.want) + } + }) + } +} diff --git a/internal/helpers/drive_push_test.go b/internal/helpers/drive_push_test.go index c424e75f..6999f407 100644 --- a/internal/helpers/drive_push_test.go +++ b/internal/helpers/drive_push_test.go @@ -1,7 +1,10 @@ package helpers import ( + "bytes" "context" + "encoding/json" + "fmt" "io" "os" "path/filepath" @@ -9,6 +12,7 @@ import ( "strings" "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" ) @@ -140,9 +144,10 @@ type driveMCPCall struct { // driveSyncMockCaller 按工具名分发返回,并记录全部调用,供断言 MCP 参数。 type driveSyncMockCaller struct { - calls []driveMCPCall - listJSON string // list_files 返回体 - dryRun bool // --dry-run:只算差异,不执行任何写操作 + calls []driveMCPCall + listJSON string // list_files 返回体 + commitJSON *string // 非 nil 时覆盖 commit_upload 返回体(包括空响应) + dryRun bool // --dry-run:只算差异,不执行任何写操作 } func (m *driveSyncMockCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) { @@ -157,7 +162,13 @@ func (m *driveSyncMockCaller) CallTool(_ context.Context, _ string, toolName str case "download_file": // 返回可被 parseDriveDownloadInfo 解析的预签名下载链接。 text = `{"result":{"downloadUrl":"https://oss.example.com/get","headers":{}}}` - default: // commit_upload 等 + case "commit_upload": + if m.commitJSON != nil { + text = *m.commitJSON + } else { + text = `{"result":{"fileId":"NEW_FID"},"success":true}` + } + default: text = `{"result":{"fileId":"NEW_FID"},"success":true}` } return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil @@ -183,15 +194,12 @@ func (m *driveSyncMockCaller) callsFor(tool string) []driveMCPCall { } // runDrivePushTest 用 mock caller 执行 `drive push`,返回捕获的调用。 -// 注入 httpPutFile 为 no-op,避免真实 OSS 上传。 +// 注入已打开文件句柄的 PUT seam 为 no-op,避免真实 OSS 上传。 func runDrivePushTest(t *testing.T, caller *driveSyncMockCaller, localDir string, args ...string) error { t.Helper() - prevDeps := deps - prevOSArgs := os.Args - SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil }) - - deps = &Deps{Caller: caller, Out: &Formatter{w: io.Discard}} + testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { return nil }) + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}}) root := &cobra.Command{Use: "dws"} root.PersistentFlags().BoolP("yes", "y", false, "") @@ -200,18 +208,51 @@ func runDrivePushTest(t *testing.T, caller *driveSyncMockCaller, localDir string // pull/push 是 user_required 叶子,非交互环境需 --yes 才能越过统一确认门。 full := append([]string{"push", "--local-folder", localDir, "--remote-folder", "ROOT", "--yes"}, args...) - os.Args = append([]string{"dws", "drive"}, full...) + testseam.Swap(t, &os.Args, append([]string{"dws", "drive"}, full...)) root.SetArgs(append([]string{"drive"}, full...)) - t.Cleanup(func() { - deps = prevDeps - os.Args = prevOSArgs - SetHTTPPutFile(nil) - }) - return root.Execute() } +func runDriveMirrorUploadCommandCapture(t *testing.T, command string, caller *driveSyncMockCaller, localDir string) (error, string, int) { + t.Helper() + putCalls := 0 + testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { + putCalls++ + return nil + }) + var out bytes.Buffer + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}}) + + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().BoolP("yes", "y", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.AddCommand(newDriveCommand()) + full := []string{command, "--local-folder", localDir, "--remote-folder", "ROOT", "--yes"} + testseam.Swap(t, &os.Args, append([]string{"dws", "drive"}, full...)) + root.SetArgs(append([]string{"drive"}, full...)) + return root.Execute(), out.String(), putCalls +} + +func assertMirrorUploadFailedJSON(t *testing.T, output string) { + t.Helper() + var payload struct { + Summary struct { + Failed int `json:"failed"` + } `json:"summary"` + Items []struct { + Action string `json:"action"` + Error string `json:"error"` + } `json:"items"` + } + if err := json.Unmarshal([]byte(output), &payload); err != nil { + t.Fatalf("output is not structured JSON: %q: %v", output, err) + } + if payload.Summary.Failed != 1 || len(payload.Items) != 1 || payload.Items[0].Action != pushActionFailed || payload.Items[0].Error == "" { + t.Fatalf("structured failure=%#v", payload) + } +} + // 覆盖分支:get_upload_info 与 commit_upload 两阶段都必须传 overwriteFileId、都不传 parentId。 func TestCrossPlatformCoverageDrivePush_overwriteUsesOverwriteFileId(t *testing.T) { dir := t.TempDir() @@ -276,6 +317,36 @@ func TestCrossPlatformCoverageDrivePush_newUploadUsesParentId(t *testing.T) { } } +func TestCrossPlatformCoverageDrivePushRejectsUnknownCommitEffect(t *testing.T) { + for _, response := range []string{"", `{}`} { + t.Run(fmt.Sprintf("response-%q", response), func(t *testing.T) { + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "a.txt"), "payload") + caller := &driveSyncMockCaller{ + listJSON: `{"result":{"items":[]}}`, + commitJSON: &response, + } + err, output, putCalls := runDriveMirrorUploadCommandCapture(t, "push", caller, dir) + if err == nil { + t.Fatal("empty commit result must make push exit non-zero") + } + if failure, ok := err.(*drivePushFailure); !ok || failure.ExitCode() != 1 { + t.Fatalf("error=%T %v", err, err) + } + assertMirrorUploadFailedJSON(t, output) + if putCalls != 1 { + t.Fatalf("OSS PUT calls=%d, want exactly 1", putCalls) + } + if got := len(caller.callsFor("get_upload_info")); got != 1 { + t.Fatalf("get_upload_info calls=%d, want 1", got) + } + if got := len(caller.callsFor("commit_upload")); got != 1 { + t.Fatalf("commit_upload calls=%d, want 1", got) + } + }) + } +} + func equalStrings(a, b []string) bool { if len(a) != len(b) { return false diff --git a/internal/helpers/drive_push_toctou_test.go b/internal/helpers/drive_push_toctou_test.go new file mode 100644 index 00000000..cfe0ccd3 --- /dev/null +++ b/internal/helpers/drive_push_toctou_test.go @@ -0,0 +1,136 @@ +package helpers + +import ( + "context" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func replacePinnedMirrorRoot(t *testing.T, root, moved string) { + t.Helper() + if err := os.Rename(root, moved); err != nil { + t.Fatalf("move pinned root: %v", err) + } + if err := os.Mkdir(root, 0o755); err != nil { + t.Fatalf("create replacement root: %v", err) + } + mustWrite(t, filepath.Join(root, "a.txt"), "outside-replacement") +} + +// 本地扫描完成后、任何 create_folder/get_upload_info/commit_upload 前若命令行根已 +// 被另一目录替换,push 与 sync 都必须整批失败,不能把替代目录当成扫描快照继续写远端。 +func TestCrossPlatformCoverageDrivePushSyncRejectRootReplacementBeforeRemoteWrite(t *testing.T) { + for _, command := range []string{"push", "sync"} { + t.Run(command, func(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "root") + moved := filepath.Join(parent, "moved-original") + if err := os.Mkdir(root, 0o755); err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(root, "a.txt"), "pinned-original") + + replaced := false + caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, _ int) (string, error) { + if tool == "list_files" { + if !replaced { + replacePinnedMirrorRoot(t, root, moved) + replaced = true + } + return `{"result":{"items":[],"nextToken":""}}`, nil + } + return `{"result":{"fileId":"UNEXPECTED"},"success":true}`, nil + }} + putCalls := 0 + testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { + putCalls++ + return nil + }) + + args := []string{command, "--local-folder", root, "--remote-folder", "ROOT"} + if command == "sync" { + args = append(args, "--quick") + } + err := runDriveCmd(t, caller, args...) + if err == nil || !strings.Contains(err.Error(), "本地根目录在同步期间被替换") { + t.Fatalf("expected root replacement rejection, got %v", err) + } + for _, tool := range []string{"create_folder", "get_upload_info", "commit_upload"} { + if got := caller.callsFor(tool); len(got) != 0 { + t.Errorf("%s must not run after root replacement: %v", tool, got) + } + } + 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" { + t.Fatalf("replacement tree changed: %q err=%v", string(b), err) + } + }) + } +} + +// 即使根在 get_upload_info 之后、OSS PUT 读取前被替换,PUT 也只能读取扫描时固定根 +// 打开的文件句柄。传输后身份复核会拒绝 commit,替代目录内容绝不能泄漏到上传流。 +func TestCrossPlatformCoverageDrivePushSyncUploadReadsPinnedFileHandle(t *testing.T) { + for _, command := range []string{"push", "sync"} { + t.Run(command, func(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "root") + moved := filepath.Join(parent, "moved-original") + if err := os.Mkdir(root, 0o755); err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(root, "a.txt"), "pinned-original") + + caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, _ int) (string, error) { + switch tool { + case "list_files": + return `{"result":{"items":[],"nextToken":""}}`, nil + case "get_upload_info": + return `{"result":{"resourceUrls":[{"url":"https://oss.example.com/put","headers":{}}],"uploadId":"U1"}}`, nil + } + return `{"result":{"fileId":"NEW"},"success":true}`, nil + }} + + var uploaded string + putCalls := 0 + testseam.Swap(t, &pushPutOpenedFile, func(_ context.Context, _ string, _ map[string]string, file *os.File, _ int64) error { + putCalls++ + replacePinnedMirrorRoot(t, root, moved) + body, err := io.ReadAll(file) + if err != nil { + return err + } + uploaded = string(body) + return nil + }) + + args := []string{command, "--local-folder", root, "--remote-folder", "ROOT"} + if command == "sync" { + args = append(args, "--quick") + } + err := runDriveCmd(t, caller, args...) + if err == nil { + t.Fatal("root replacement during PUT must prevent commit") + } + if putCalls != 1 || uploaded != "pinned-original" { + t.Fatalf("PUT read %q in %d call(s), want pinned-original once", uploaded, putCalls) + } + 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" { + t.Fatalf("pinned original changed: %q err=%v", string(b), readErr) + } + }) + } +} diff --git a/internal/helpers/drive_push_type_conflict_test.go b/internal/helpers/drive_push_type_conflict_test.go index e6e294fb..d2d56435 100644 --- a/internal/helpers/drive_push_type_conflict_test.go +++ b/internal/helpers/drive_push_type_conflict_test.go @@ -7,13 +7,13 @@ import ( "os" "path/filepath" "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" ) func TestCrossPlatformCoverageDrivePushDryRun_typeConflictsFail(t *testing.T) { var out bytes.Buffer - prevDeps := deps - deps = &Deps{Out: &Formatter{w: &out}} - t.Cleanup(func() { deps = prevDeps }) + testseam.Swap(t, &deps, &Deps{Out: &Formatter{w: &out}}) tests := []struct { name string diff --git a/internal/helpers/drive_remote_duplicate_test.go b/internal/helpers/drive_remote_duplicate_test.go index d8b25830..b027575b 100644 --- a/internal/helpers/drive_remote_duplicate_test.go +++ b/internal/helpers/drive_remote_duplicate_test.go @@ -6,6 +6,8 @@ import ( "os" "strings" "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" ) func TestCrossPlatformCoverageDriveRemoteTree_duplicatePathsFail(t *testing.T) { @@ -70,10 +72,8 @@ func TestCrossPlatformCoverageDriveRemoteTree_duplicatePathsFail(t *testing.T) { for _, fetcher := range fetchers { t.Run(fetcher.name, func(t *testing.T) { caller := &driveScriptCaller{reply: tt.reply} - prevDeps, prevArgs := deps, os.Args - deps = &Deps{Caller: caller, Out: &Formatter{w: io.Discard}} - os.Args = []string{"dws", "drive"} - t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs }) + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive"}) err := fetcher.fetch(context.Background()) if err == nil || !strings.Contains(err.Error(), "重复远端路径") || !strings.Contains(err.Error(), tt.path) { diff --git a/internal/helpers/drive_remote_folder_id_test.go b/internal/helpers/drive_remote_folder_id_test.go index 364a6429..a791b010 100644 --- a/internal/helpers/drive_remote_folder_id_test.go +++ b/internal/helpers/drive_remote_folder_id_test.go @@ -6,6 +6,8 @@ import ( "os" "strings" "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" ) func TestCrossPlatformCoverageDriveRemoteTree_folderIDFallbacks(t *testing.T) { @@ -20,10 +22,8 @@ func TestCrossPlatformCoverageDriveRemoteTree_folderIDFallbacks(t *testing.T) { } return `{"result":{"items":[],"nextToken":""}}`, nil }} - prevDeps, prevArgs := deps, os.Args - deps = &Deps{Caller: caller, Out: &Formatter{w: io.Discard}} - os.Args = []string{"dws", "drive"} - t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs }) + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive"}) if _, err := fetchRemoteDriveTree(context.Background(), "", "ROOT", true); err != nil { t.Fatalf("fetch with %s: %v", key, err) @@ -62,10 +62,8 @@ func TestCrossPlatformCoverageDriveRemoteTree_missingFolderIDFailsClosed(t *test } return `{"result":{"items":[{"name":"sub","type":"folder"}],"nextToken":""}}`, nil }} - prevDeps, prevArgs := deps, os.Args - deps = &Deps{Caller: caller, Out: &Formatter{w: io.Discard}} - os.Args = []string{"dws", "drive"} - t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs }) + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive"}) err := fetcher.fetch(context.Background()) if err == nil || !strings.Contains(err.Error(), "目录 ID") || !strings.Contains(err.Error(), "sub") { diff --git a/internal/helpers/drive_remote_tree_integrity_test.go b/internal/helpers/drive_remote_tree_integrity_test.go new file mode 100644 index 00000000..7f7e2437 --- /dev/null +++ b/internal/helpers/drive_remote_tree_integrity_test.go @@ -0,0 +1,309 @@ +package helpers + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestCrossPlatformCoverageParseDriveList_requiresAuthoritativeResult(t *testing.T) { + tests := []struct { + name string + body string + wantError string + wantItems int + }{ + {name: "missing result", body: `{}`, wantError: "缺少 result"}, + {name: "null result", body: `{"result":null}`, wantError: "result 为 null"}, + {name: "empty result object", body: `{"result":{}}`, wantError: "缺少 items"}, + {name: "null item", body: `{"result":{"items":[null]}}`, wantError: "缺少有效 type"}, + {name: "empty item object", body: `{"result":{"items":[{}]}}`, wantError: "缺少有效 type"}, + {name: "item missing type", body: `{"result":{"items":[{"name":"unknown"}]}}`, wantError: "缺少有效 type"}, + {name: "item empty type", body: `{"result":{"items":[{"name":"unknown","type":" "}]}}`, wantError: "缺少有效 type"}, + {name: "has more without token", body: `{"result":{"items":[],"hasMore":true,"nextToken":""}}`, wantError: "hasMore=true"}, + {name: "empty items", body: `{"result":{"items":[]}}`}, + {name: "empty result array", body: `{"result":[]}`}, + {name: "explicit unsupported type remains parseable", body: `{"result":{"items":[{"name":"online.adoc","type":"document"}]}}`, wantItems: 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + items, token, err := parseDriveList(tt.body) + if tt.wantError != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantError) { + t.Fatalf("parseDriveList error = %v, want containing %q", err, tt.wantError) + } + return + } + if err != nil { + t.Fatalf("parseDriveList(%s): %v", tt.body, err) + } + if len(items) != tt.wantItems || token != "" { + t.Fatalf("parseDriveList(%s) = (%v, %q), want %d authoritative item(s) with no token", tt.body, items, token, tt.wantItems) + } + }) + } +} + +func TestCrossPlatformCoverageDriveRemoteTree_integrityFailuresStopFourCommandsBeforeWrites(t *testing.T) { + failures := []struct { + name string + wantError string + wantListCalls int + reply func(int) string + }{ + { + name: "missing result", + wantError: "缺少 result", + wantListCalls: 1, + reply: func(int) string { return `{}` }, + }, + { + name: "null result", + wantError: "result 为 null", + wantListCalls: 1, + reply: func(int) string { return `{"result":null}` }, + }, + { + name: "empty result object", + wantError: "缺少 items", + wantListCalls: 1, + reply: func(int) string { return `{"result":{}}` }, + }, + { + name: "null item", + wantError: "缺少有效 type", + wantListCalls: 1, + reply: func(int) string { return `{"result":{"items":[null],"nextToken":""}}` }, + }, + { + name: "empty item object", + wantError: "缺少有效 type", + wantListCalls: 1, + reply: func(int) string { return `{"result":{"items":[{}],"nextToken":""}}` }, + }, + { + name: "item missing type", + wantError: "缺少有效 type", + wantListCalls: 1, + reply: func(int) string { + return `{"result":{"items":[{"name":"unknown"}],"nextToken":""}}` + }, + }, + { + name: "item empty type", + wantError: "缺少有效 type", + wantListCalls: 1, + reply: func(int) string { + return `{"result":{"items":[{"name":"unknown","type":""}],"nextToken":""}}` + }, + }, + { + name: "has more without token", + wantError: "hasMore=true", + wantListCalls: 1, + reply: func(int) string { + return `{"result":{"items":[],"hasMore":true,"nextToken":""}}` + }, + }, + { + name: "file without an ID", + wantError: "缺少文件 ID", + wantListCalls: 1, + reply: func(int) string { + return `{"result":{"items":[{"name":"remote.txt","type":"file"}],"nextToken":""}}` + }, + }, + { + name: "file name with an ASCII control", + wantError: "无法安全映射到本地路径", + wantListCalls: 1, + reply: func(int) string { + return `{"result":{"items":[{"name":"remote\u0000.txt","type":"file","fileId":"REMOTE"}],"nextToken":""}}` + }, + }, + { + name: "pagination token cycle", + wantError: "分页 token", + wantListCalls: 3, + reply: func(nth int) string { + tokens := []string{"PAGE_A", "PAGE_B", "PAGE_A"} + if nth >= len(tokens) { + return `{"result":{"items":[],"nextToken":""}}` + } + return fmt.Sprintf(`{"result":{"items":[],"nextToken":%q}}`, tokens[nth]) + }, + }, + } + + commands := []struct { + name string + args func(string) []string + prepare func(*testing.T, string) + }{ + { + name: "status", + args: func(localRoot string) []string { + return []string{"status", "--local-folder", localRoot, "--remote-folder", "ROOT", "--quick"} + }, + prepare: prepareRemoteTreeReadRoot, + }, + { + name: "pull", + args: func(localRoot string) []string { + return []string{"pull", "--local-folder", localRoot, "--remote-folder", "ROOT"} + }, + }, + { + name: "push", + args: func(localRoot string) []string { + return []string{"push", "--local-folder", localRoot, "--remote-folder", "ROOT"} + }, + prepare: prepareRemoteTreeWriteSource, + }, + { + name: "sync", + args: func(localRoot string) []string { + return []string{"sync", "--local-folder", localRoot, "--remote-folder", "ROOT", "--quick"} + }, + prepare: prepareRemoteTreeWriteSource, + }, + } + + for _, failure := range failures { + t.Run(failure.name, func(t *testing.T) { + for _, command := range commands { + t.Run(command.name, func(t *testing.T) { + localRoot := filepath.Join(t.TempDir(), "mirror") + if command.prepare != nil { + command.prepare(t, localRoot) + } + + caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, nth int) (string, error) { + if tool != "list_files" { + return "", fmt.Errorf("远端清单预检完成前调用了写工具 %s", tool) + } + return failure.reply(nth), nil + }} + getCalls, putCalls := swapRemoteTreeTransferCounters(t) + + err := runDriveCmd(t, caller, command.args(localRoot)...) + if err == nil || !strings.Contains(err.Error(), failure.wantError) { + t.Fatalf("error = %v, want remote tree integrity failure containing %q", err, failure.wantError) + } + if got := len(caller.callsFor("list_files")); got != failure.wantListCalls { + t.Fatalf("list_files calls = %d, want %d", got, failure.wantListCalls) + } + for _, call := range caller.calls { + if call.toolName != "list_files" { + t.Fatalf("remote tree integrity failure must stop before MCP writes, got %s", call.toolName) + } + } + if *getCalls != 0 || *putCalls != 0 { + t.Fatalf("remote tree integrity failure must stop before transfers: GET=%d PUT=%d", *getCalls, *putCalls) + } + if command.name == "pull" { + if _, statErr := os.Stat(localRoot); !os.IsNotExist(statErr) { + t.Fatalf("pull must fail before creating its local root: %v", statErr) + } + } + }) + } + }) + } +} + +func TestCrossPlatformCoverageDriveRemoteTree_validEmptyListsRemainSupported(t *testing.T) { + for _, listing := range []struct { + name string + body string + }{ + {name: "items object", body: `{"result":{"items":[]}}`}, + {name: "result array", body: `{"result":[]}`}, + } { + t.Run(listing.name, func(t *testing.T) { + for _, fetcher := range []struct { + name string + fetch func(context.Context) error + }{ + { + name: "status and pull walker", + fetch: func(ctx context.Context) error { + files, err := fetchRemoteDriveTree(ctx, "", "ROOT", true) + if len(files) != 0 { + return fmt.Errorf("remote files = %v, want empty", files) + } + return err + }, + }, + { + name: "push and sync walker", + fetch: func(ctx context.Context) error { + files, folders, err := fetchRemoteTreeForPush(ctx, "", "ROOT") + if len(files) != 0 || len(folders) != 1 || folders[""] != "ROOT" { + return fmt.Errorf("remote tree = files %v folders %v, want empty root", files, folders) + } + return err + }, + }, + } { + t.Run(fetcher.name, func(t *testing.T) { + caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, _ int) (string, error) { + if tool != "list_files" { + t.Fatalf("unexpected tool %s", tool) + } + return listing.body, nil + }} + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive"}) + if err := fetcher.fetch(context.Background()); err != nil { + t.Fatalf("valid empty listing: %v", err) + } + if got := len(caller.callsFor("list_files")); got != 1 { + t.Fatalf("list_files calls = %d, want 1", got) + } + }) + } + }) + } +} + +func TestCrossPlatformCoverageIsSafeRemoteSegment_rejectsASCIIControls(t *testing.T) { + for _, name := range []string{"nul\x00name", "unit\x1fseparator", "delete\x7fcharacter"} { + if isSafeRemoteSegment(name) { + t.Errorf("isSafeRemoteSegment(%q) = true, want false", name) + } + } +} + +func prepareRemoteTreeReadRoot(t *testing.T, localRoot string) { + t.Helper() + if err := os.MkdirAll(localRoot, 0o755); err != nil { + t.Fatal(err) + } +} + +func prepareRemoteTreeWriteSource(t *testing.T, localRoot string) { + t.Helper() + mustWrite(t, filepath.Join(localRoot, "local.txt"), "local") +} + +func swapRemoteTreeTransferCounters(t *testing.T) (*int, *int) { + t.Helper() + getCalls, putCalls := 0, 0 + swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error { + getCalls++ + return nil + }) + testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { + putCalls++ + return nil + }) + return &getCalls, &putCalls +} diff --git a/internal/helpers/drive_remote_unsafe_name_test.go b/internal/helpers/drive_remote_unsafe_name_test.go index 393f2fb7..42737e11 100644 --- a/internal/helpers/drive_remote_unsafe_name_test.go +++ b/internal/helpers/drive_remote_unsafe_name_test.go @@ -83,11 +83,11 @@ func TestCrossPlatformCoverageDriveRemoteTree_unsafeNamesFailClosed(t *testing.T return tt.listing, nil }} getCalls, putCalls := 0, 0 - testseam.Swap(t, &httpGetFile, func(context.Context, string, map[string]string, string) error { + swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error { getCalls++ return nil }) - testseam.Swap(t, &httpPutFile, func(context.Context, string, map[string]string, string, int64) error { + testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { putCalls++ return nil }) diff --git a/internal/helpers/drive_status.go b/internal/helpers/drive_status.go index 09e9319b..4955dccf 100644 --- a/internal/helpers/drive_status.go +++ b/internal/helpers/drive_status.go @@ -1,6 +1,7 @@ package helpers import ( + "bytes" "context" "crypto/md5" "encoding/base64" @@ -107,6 +108,7 @@ func walkRemoteDir(ctx context.Context, spaceID, parentID, relBase string, out m } nextToken := "" + seenTokens := make(map[string]struct{}) for { args := map[string]any{"maxResults": float64(driveListPageSize)} // space-id 为空则省略,交给 MCP Server 默认「我的文件」。 @@ -163,11 +165,15 @@ func walkRemoteDir(ctx context.Context, spaceID, parentID, relBase string, out m if err := claimRemotePath(occupied, childRel, "文件"); err != nil { return err } + fileID := it.id() + if fileID == "" { + return fmt.Errorf("远端文件 %q 缺少文件 ID,已中止遍历", childRel) + } modMillis, modValid := it.modifiedMillis() out[childRel] = &remoteFile{ RelPath: childRel, - FileID: it.id(), + FileID: fileID, Hash: it.hash(), Size: it.size(), ModifiedTime: modMillis, @@ -175,10 +181,13 @@ func walkRemoteDir(ctx context.Context, spaceID, parentID, relBase string, out m } } - // 无下一页,或 token 未推进(防御性,避免服务端异常导致死循环)→ 结束。 - if token == "" || token == nextToken { + if token == "" { break } + if _, exists := seenTokens[token]; exists { + return fmt.Errorf("远端目录 %q 的分页 token 重复,疑似分页循环,已中止", relBase) + } + seenTokens[token] = struct{}{} nextToken = token } return nil @@ -280,37 +289,72 @@ func (d driveItem) modifiedMillis() (int64, bool) { // - {"result":{"items":[...],"nextToken":"..."}} // - {"result":[...]}(result 直接是条目数组,无分页) func parseDriveList(text string) ([]driveItem, string, error) { - var envelope struct { - Result json.RawMessage `json:"result"` - } + var envelope map[string]json.RawMessage if err := json.Unmarshal([]byte(text), &envelope); err != nil { return nil, "", fmt.Errorf("解析 list_files 返回失败: %w", err) } - if len(envelope.Result) == 0 { - return nil, "", nil // 无 result → 空列表 + result, exists := envelope["result"] + if !exists { + return nil, "", fmt.Errorf("解析 list_files 返回失败: 缺少 result") + } + result = json.RawMessage(bytes.TrimSpace(result)) + if bytes.Equal(result, []byte("null")) { + return nil, "", fmt.Errorf("解析 list_files 返回失败: result 为 null") } - toItems := func(ms []map[string]any) []driveItem { + toItems := func(ms []map[string]any) ([]driveItem, error) { items := make([]driveItem, 0, len(ms)) - for _, m := range ms { - items = append(items, driveItem(m)) + for index, m := range ms { + item := driveItem(m) + // FILE / FOLDER 之外的显式类型(在线文档、快捷方式等)可以由镜像 + // walker 跳过;但 null、空对象或缺少有效 type 的条目不是一个可判定的 + // 业务对象。静默跳过会把不完整清单伪装成权威目录,必须失败关闭。 + if m == nil || strings.TrimSpace(item.typ()) == "" { + return nil, fmt.Errorf("解析 list_files 返回失败: result.items[%d] 缺少有效 type", index) + } + items = append(items, item) } - return items + return items, nil } - // 形态一:result 是对象 {items, nextToken} - var obj struct { - Items []map[string]any `json:"items"` - NextToken string `json:"nextToken"` - } - if err := json.Unmarshal(envelope.Result, &obj); err == nil { - return toItems(obj.Items), obj.NextToken, nil - } - - // 形态二:result 直接是条目数组 - var arr []map[string]any - if err := json.Unmarshal(envelope.Result, &arr); err == nil { - return toItems(arr), "", nil + switch { + case len(result) > 0 && result[0] == '{': + // 形态一:result 是对象 {items, nextToken}。items 必须显式存在,不能把 + // 缺失/null 的非权威响应误判为空目录。 + var obj struct { + Items json.RawMessage `json:"items"` + NextToken string `json:"nextToken"` + HasMore *bool `json:"hasMore"` + } + if err := json.Unmarshal(result, &obj); err != nil { + return nil, "", fmt.Errorf("解析 list_files 返回失败: %w", err) + } + if len(obj.Items) == 0 { + return nil, "", fmt.Errorf("解析 list_files 返回失败: result 缺少 items") + } + var items []map[string]any + if err := json.Unmarshal(obj.Items, &items); err != nil || items == nil { + return nil, "", fmt.Errorf("解析 list_files 返回失败: result.items 不是数组") + } + if obj.HasMore != nil && *obj.HasMore && obj.NextToken == "" { + return nil, "", fmt.Errorf("解析 list_files 返回失败: hasMore=true 但 nextToken 为空,远端清单可能被截断") + } + parsed, err := toItems(items) + if err != nil { + return nil, "", err + } + return parsed, obj.NextToken, nil + case len(result) > 0 && result[0] == '[': + // 形态二:result 直接是条目数组。 + var arr []map[string]any + if err := json.Unmarshal(result, &arr); err != nil { + return nil, "", fmt.Errorf("解析 list_files 返回失败: result 数组无效: %w", err) + } + parsed, err := toItems(arr) + if err != nil { + return nil, "", err + } + return parsed, "", nil } return nil, "", fmt.Errorf("解析 list_files 返回失败: result 既不是 {items:[]} 也不是数组") @@ -328,12 +372,18 @@ func validateLocalDirAbs(localDir string) (string, error) { } // isSafeRemoteSegment 校验单个远端条目名称是否可安全用作一层相对路径段。 -// 拒绝空串、"."、".."、含卷标(如 Windows 的 "C:")以及任意平台的路径分隔符 -// (/ 与 \)——这些成分拼进 rel_path 后可能让下载目标逃逸出本地根目录。 +// 拒绝空串、"."、".."、ASCII 控制字符、含卷标(如 Windows 的 "C:")以及任意平台 +// 的路径分隔符(/ 与 \)——这些成分无法安全表示为本地文件名,或可能让下载目标逃逸 +// 出本地根目录。 func isSafeRemoteSegment(name string) bool { if name == "" || name == "." || name == ".." { return false } + for i := 0; i < len(name); i++ { + if name[i] <= 0x1f || name[i] == 0x7f { + return false + } + } // 任意平台分隔符都不允许出现在单层名称里。 if strings.ContainsAny(name, `/\`) { return false diff --git a/internal/helpers/drive_status_coverage_test.go b/internal/helpers/drive_status_coverage_test.go index fdfed660..3e9a3869 100644 --- a/internal/helpers/drive_status_coverage_test.go +++ b/internal/helpers/drive_status_coverage_test.go @@ -82,22 +82,15 @@ func runDriveCmd(t *testing.T, caller edition.ToolCaller, args ...string) error func runDriveCmdWithoutConfirm(t *testing.T, caller edition.ToolCaller, args ...string) error { t.Helper() - prevDeps := deps - prevOSArgs := os.Args - deps = &Deps{Caller: caller, Out: &Formatter{w: io.Discard}} + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}}) root := &cobra.Command{Use: "dws"} root.PersistentFlags().BoolP("yes", "y", false, "") root.PersistentFlags().Bool("dry-run", false, "") root.AddCommand(newDriveCommand()) - os.Args = append([]string{"dws", "drive"}, args...) + testseam.Swap(t, &os.Args, append([]string{"dws", "drive"}, args...)) root.SetArgs(append([]string{"drive"}, args...)) - - t.Cleanup(func() { - deps = prevDeps - os.Args = prevOSArgs - }) return root.Execute() } @@ -138,8 +131,8 @@ func TestCrossPlatformCoverageDriveStatus_endToEndNestedTree(t *testing.T) { } } -// 分页:首页返回 nextToken,第二页取完;token 未推进时必须终止而非死循环。 -func TestCrossPlatformCoverageDriveStatus_paginationAndStalledToken(t *testing.T) { +// 分页 token 未推进意味着远端清单可能被截断,必须失败而不是把当前页当作 EOF。 +func TestCrossPlatformCoverageDriveStatus_stalledPaginationTokenFailsClosed(t *testing.T) { dir := t.TempDir() caller := &driveScriptCaller{reply: func(_ string, args map[string]any, nth int) (string, error) { @@ -153,17 +146,18 @@ func TestCrossPlatformCoverageDriveStatus_paginationAndStalledToken(t *testing.T if args["nextToken"] != "T1" { t.Errorf("second page nextToken = %v, want T1", args["nextToken"]) } - // 返回同一个 token → 未推进,必须 break。 + // 返回同一个 token → 未推进,必须 fail closed。 return `{"result":{"items":[{"name":"b.txt","type":"file","fileId":"B"}],"nextToken":"T1"}}`, nil } return `{"result":{"items":[],"nextToken":""}}`, nil }} - if err := runDriveCmd(t, caller, "status", "--local-folder", dir, "--remote-folder", "ROOT", "--quick"); err != nil { - t.Fatalf("unexpected error: %v", err) + err := runDriveCmd(t, caller, "status", "--local-folder", dir, "--remote-folder", "ROOT", "--quick") + if err == nil || !strings.Contains(err.Error(), "分页 token") { + t.Fatalf("error = %v, want stalled pagination failure", err) } if got := len(caller.callsFor("list_files")); got != 2 { - t.Fatalf("stalled token must stop paging after 2 calls, got %d", got) + t.Fatalf("stalled token must fail after 2 calls, got %d", got) } } @@ -256,14 +250,8 @@ func TestCrossPlatformCoverageWalkRemoteDir_depthLimitAborts(t *testing.T) { // 每层都返回一个同名子文件夹 → 无限深。 return `{"result":{"items":[{"name":"loop","type":"folder","fileId":"LOOP"}],"nextToken":""}}`, nil }} - prevDeps := deps - prevOSArgs := os.Args - deps = &Deps{Caller: caller, Out: &Formatter{w: io.Discard}} - os.Args = []string{"dws", "drive"} // 工具路由需要命令上下文 - t.Cleanup(func() { - deps = prevDeps - os.Args = prevOSArgs - }) + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive"}) // 工具路由需要命令上下文 _, err := fetchRemoteDriveTree(context.Background(), "", "ROOT", true) if err == nil || !strings.Contains(err.Error(), "循环引用") { diff --git a/internal/helpers/drive_status_windows.go b/internal/helpers/drive_status_windows.go index b9ef2de4..61241551 100644 --- a/internal/helpers/drive_status_windows.go +++ b/internal/helpers/drive_status_windows.go @@ -2,13 +2,16 @@ package helpers -import "path/filepath" +import ( + "path/filepath" + "strings" +) // isSafeRemoteSegmentPlatform 承载 Windows 专有的单层名称校验。 // -// 拒绝含盘符的名称("C:"、"C:foo")——Windows 上 filepath.VolumeName 会识别它们, -// 拼进 rel_path 后可让落盘目标逃逸出本地根目录;并在 Clean 改写名称时拒绝,兜住 -// 分隔符过滤之外的非规范形式。 +// 除盘符与 Clean 改写外,还拒绝 Win32 非法字符、尾随点/空格以及设备保留名。 +// 这些名称即使不造成词法逃逸,也可能无法落盘、落到 ADS,或与另一个名称映射到 +// 同一 Win32 对象;文件夹镜像必须在构建权威远端树时统一 fail closed。 func isSafeRemoteSegmentPlatform(name string) bool { if filepath.VolumeName(name) != "" { return false @@ -16,5 +19,34 @@ func isSafeRemoteSegmentPlatform(name string) bool { if filepath.Clean(name) != name { return false } + if strings.TrimRight(name, " .") != name { + return false + } + for _, char := range name { + if char < 0x20 || char == 0x7f || strings.ContainsRune(`<>:"|?*`, char) { + return false + } + } + if isWindowsReservedMirrorName(name) { + return false + } return true } + +func isWindowsReservedMirrorName(name string) bool { + base := name + if index := strings.IndexAny(base, ".:"); index >= 0 { + base = base[:index] + } + base = strings.ToUpper(strings.TrimRight(base, " .")) + switch base { + case "CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$": + return true + } + if len(base) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) && base[3] >= '1' && base[3] <= '9' { + return true + } + runes := []rune(base) + return len(runes) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) && + strings.ContainsRune("¹²³", runes[3]) +} diff --git a/internal/helpers/drive_sync.go b/internal/helpers/drive_sync.go index d0baefb0..dad77166 100644 --- a/internal/helpers/drive_sync.go +++ b/internal/helpers/drive_sync.go @@ -14,11 +14,11 @@ import ( "github.com/spf13/cobra" ) -// syncRename 与 syncOpenFile 仅作为文件系统失败分支的确定性测试 seam;测试必须 -// 通过 testseam.Swap 替换并自动恢复。 +// keep-both 的本地保留副本通过同一个固定父目录 os.Root 原子建立硬链接;以下 seam +// 仅用于文件系统失败与并发替换的确定性测试,测试必须通过 testseam.Swap 替换。 var ( - syncRename = driveReplaceFile - syncOpenFile = os.OpenFile + syncRootLink = func(root *os.Root, oldName, newName string) error { return root.Link(oldName, newName) } + syncPullOneFilePinned = pullOneFilePinned ) // ========================================================== @@ -100,6 +100,14 @@ type driveSyncResult struct { Items []driveSyncItem `json:"items"` } +type driveSyncDryRunResult struct { + DryRun bool `json:"dry_run"` + Executed bool `json:"executed"` + PreviewKind string `json:"preview_kind"` + Operation string `json:"operation"` + Plan driveSyncResult `json:"plan"` +} + // driveSyncFailure 在 summary.failed > 0 时返回:结构化结果已打印到 stdout, // 这里只负责以 exit=1 退出并向 stderr 输出一行简短说明(与 drivePushFailure 一致)。 type driveSyncFailure struct{ failed int } @@ -140,6 +148,16 @@ func runDriveSync(cmd *cobra.Command, _ []string) error { if err != nil { return err } + pinnedRoot, err := openExistingPinnedPullRoot(absDir) + if err != nil { + return fmt.Errorf("扫描本地目录失败: %w", err) + } + defer pinnedRoot.close() + // sync 的本地快照与后续 hash / upload / pull 全部绑定到同一个固定根。 + localDirs, localFiles, err := runDriveMirrorWalkLocalForPushPinned(pinnedRoot) + if err != nil { + return fmt.Errorf("扫描本地目录失败: %w", err) + } ctx := cmd.Context() @@ -148,20 +166,20 @@ func runDriveSync(cmd *cobra.Command, _ []string) error { if err != nil { return err } - // 本地现状:子目录(含空目录,父目录先于子目录)与常规文件。 - localDirs, localFiles, err := walkLocalForPush(absDir) - if err != nil { - return fmt.Errorf("扫描本地目录失败: %w", err) - } localByRel := make(map[string]localPushFile, len(localFiles)) for _, f := range localFiles { localByRel[f.RelPath] = f } + preflight := buildMirrorPreflight(localDirs, localFiles, remoteFiles, remoteFolders) + addPinnedLocalTargetPreflightFailures(pinnedRoot, remoteFiles, preflight) // 在计算 diff 前先识别文件/目录同路径冲突。冲突根及其全部后代都不能再进入 // new_local / new_remote 或后续执行阶段,否则会在远端制造同名异类对象,或尝试 // 把远端目录内容下载到本地文件之下。 typeConflicts := make(map[string]string) + for rel, msg := range preflight { + typeConflicts[rel] = msg + } for _, dir := range localDirs { if msg := pushTypeConflictError(dir, true, remoteFiles, remoteFolders); msg != "" { typeConflicts[dir] = msg @@ -191,8 +209,7 @@ func runDriveSync(cmd *cobra.Command, _ []string) error { newLocal = append(newLocal, rel) continue } - lf := &localFile{RelPath: pf.RelPath, AbsPath: pf.AbsPath, Size: pf.Size, ModTimeMillis: pf.ModTimeMillis} - verdict, jerr := judgeFileMatch(lf, rf, quick) + verdict, jerr := judgePinnedPushFileMatch(pinnedRoot, pf, rf, quick) if jerr != nil { return jerr } @@ -244,11 +261,23 @@ func runDriveSync(cmd *cobra.Command, _ []string) error { Error: typeConflicts[rel], }) } + if err := pinnedRoot.verify(); err != nil { + return err + } // --dry-run:只算差异、不执行任何同步动作。 if deps.Caller.DryRun() { - deps.Out.PrintInfo("dry-run: 仅计算差异,未执行任何同步操作") - return deps.Out.PrintJSON(res) + return deps.Out.PrintJSON(driveSyncDryRunResult{ + DryRun: true, Executed: false, PreviewKind: "plan", Operation: "drive sync", Plan: res, + }) + } + // 镜像前置检查是整批门禁:发现任一本地非法名称或跨端等价路径歧义后, + // sync 不得执行 create/download/upload 等任一写动作。 + if len(preflight) > 0 { + if perr := deps.Out.PrintJSON(res); perr != nil { + return perr + } + return &driveSyncFailure{failed: res.Summary.Failed} } // unknown:exact 模式下远端无可靠 md5、内容无法核对,不擅自覆盖任何一侧,记 skipped。 @@ -275,20 +304,10 @@ func runDriveSync(cmd *cobra.Command, _ []string) error { } resolutions[rel] = strategy } - - // 落盘前先确保本地根存在(供大小写探测与 pull 落盘),并探测目标文件系统大小写敏感性。 - if err := os.MkdirAll(absDir, 0o755); err != nil { - return fmt.Errorf("创建本地目录失败: %w", err) + // 交互决策期间本地根也可能被替换;首个双端写动作前再次核对固定身份。 + if err := pinnedRoot.verify(); err != nil { + return err } - caseInsensitive := isCaseInsensitiveFS(absDir) - - // 远端 → 本地的大小写/规范化冲突:多个远端条目在当前文件系统上映射到同一本地路径时, - // 命中的路径一律标记 failed、都不落盘,避免静默覆盖丢文件(与 pull 同策略)。 - remoteRels := make([]string, 0, len(remoteFiles)) - for rel := range remoteFiles { - remoteRels = append(remoteRels, rel) - } - collided := detectTargetCollisions(absDir, remoteRels, caseInsensitive) // occupied:keep-both 生成不冲突的本地重命名目标时用,覆盖两侧全部已知路径。 occupied := make(map[string]bool) @@ -309,9 +328,6 @@ func runDriveSync(cmd *cobra.Command, _ []string) error { // 阶段 1:镜像本地目录结构到远端(缺失则创建),保证空目录与 push 目标的父目录先存在。 for _, dir := range localDirs { - if syncPathBlockedByTypeConflict(dir, typeConflicts) { - continue - } if _, ok := remoteFolders[dir]; ok { continue // 远端已存在,复用 fileId } @@ -322,6 +338,9 @@ func runDriveSync(cmd *cobra.Command, _ []string) error { res.Items = append(res.Items, driveSyncItem{RelPath: dir, Action: syncActionFailed, Direction: syncDirectionPush, Error: "父目录未能创建"}) continue } + if err := pinnedRoot.verify(); err != nil { + return err + } fid, cerr := pushCreateFolder(ctx, spaceID, parentID, name) if cerr != nil || fid == "" { res.Summary.Failed++ @@ -339,13 +358,13 @@ func runDriveSync(cmd *cobra.Command, _ []string) error { // 阶段 2:new_remote 下载到本地。 for _, rel := range newRemote { - syncPullFile(&res, ctx, spaceID, remoteFiles[rel], absDir, rel, collided, syncDirectionPull) + syncPullFilePinned(&res, ctx, spaceID, remoteFiles[rel], pinnedRoot, rel, syncDirectionPull) } // 阶段 3:new_local 上传到远端。 for _, rel := range newLocal { pf := localByRel[rel] - syncPushFile(&res, ctx, spaceID, remoteFolders, pf, rel, "") + syncPushFile(&res, ctx, spaceID, remoteFolders, pinnedRoot, pf, rel, "") } // 阶段 4:modified 按 --on-conflict 解决。 @@ -354,11 +373,11 @@ func runDriveSync(cmd *cobra.Command, _ []string) error { rf := remoteFiles[rel] switch resolutions[rel] { case syncConflictRemoteWins: - syncPullFile(&res, ctx, spaceID, rf, absDir, rel, collided, syncDirectionPull) + syncPullFilePinned(&res, ctx, spaceID, rf, pinnedRoot, rel, syncDirectionPull) case syncConflictLocalWins: - syncPushFile(&res, ctx, spaceID, remoteFolders, pf, rel, rf.FileID) + syncPushFile(&res, ctx, spaceID, remoteFolders, pinnedRoot, pf, rel, rf.FileID) case syncConflictKeepBoth: - syncKeepBoth(&res, ctx, spaceID, rf, absDir, rel, collided, occupied) + syncKeepBothPinned(&res, ctx, spaceID, rf, pinnedRoot, rel, occupied) default: // "" — ask 选择跳过 res.Summary.Skipped++ res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionSkipped, Direction: syncDirectionConflict}) @@ -387,24 +406,54 @@ func syncPathBlockedByTypeConflict(rel string, conflicts map[string]string) bool return false } +// addPinnedLocalTargetPreflightFailures 从 sync 已固定的本地根核对所有远端落盘点。 +// 每一层既有祖先都必须是真实目录,末段若存在则必须是常规文件;软链即使仍指向根内 +// 也不属于可移植镜像树,必须在任何双端写之前整批拒绝。 +func addPinnedLocalTargetPreflightFailures(root *pinnedPullRoot, remote map[string]*remoteFile, failures map[string]string) { + for rel := range remote { + if mirrorPreflightFailureForRel(rel, failures) != "" { + continue + } + parts := strings.Split(filepath.FromSlash(rel), string(filepath.Separator)) + for i := range parts { + name := filepath.Join(parts[:i+1]...) + info, err := pullRootLstat(root.root, name) + if err != nil { + if os.IsNotExist(err) { + break + } + failures[rel] = fmt.Sprintf("检查本地目标失败: %v", err) + break + } + if i < len(parts)-1 { + if !info.IsDir() { + failures[rel] = fmt.Sprintf("本地目标 %q 的祖先 %q 已存在且不是目录,已拒绝镜像", rel, filepath.ToSlash(name)) + break + } + continue + } + if !info.Mode().IsRegular() { + failures[rel] = fmt.Sprintf("本地目标 %q 已存在且不是常规文件,已拒绝覆盖", rel) + } + } + } +} + // syncPullFile 下载单个远端文件到本地 rel 对应路径(总是覆盖,Drive 为该项权威源), // 并把结果计入 res。命中大小写/规范化冲突或逃逸的路径记 failed、不落盘。 -func syncPullFile(res *driveSyncResult, ctx context.Context, spaceID string, rf *remoteFile, absDir, rel string, collided map[string]bool, direction string) { - if collided[rel] { +func syncPullFile(res *driveSyncResult, ctx context.Context, spaceID string, rf *remoteFile, absDir, rel, direction string) { + root, err := openPinnedPullRoot(absDir) + if err != nil { res.Summary.Failed++ - res.Items = append(res.Items, driveSyncItem{ - RelPath: rel, Action: syncActionFailed, Direction: direction, - Error: "多个远端条目在当前文件系统上映射到同一本地路径(大小写/规范化冲突),已跳过以避免覆盖丢失", - }) + res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: direction, Error: err.Error()}) return } - localPath, terr := resolveLocalTarget(absDir, rel) - if terr != nil { - res.Summary.Failed++ - res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: direction, Error: terr.Error()}) - return - } - action, perr := pullOneFile(ctx, spaceID, rf, localPath, ifExistsOverwrite) + defer root.close() + syncPullFilePinned(res, ctx, spaceID, rf, root, rel, direction) +} + +func syncPullFilePinned(res *driveSyncResult, ctx context.Context, spaceID string, rf *remoteFile, root *pinnedPullRoot, rel string, direction string) { + action, perr := pullRemoteFilePinned(ctx, spaceID, rf, root, rel, ifExistsOverwrite) if action == pullActionDownloaded { res.Summary.Pulled++ res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionDownloaded, Direction: direction}) @@ -419,7 +468,7 @@ func syncPullFile(res *driveSyncResult, ctx context.Context, spaceID string, rf } // syncPushFile 上传单个本地文件到远端;overwriteID 非空时走覆盖上传(原地覆盖同名远端文件)。 -func syncPushFile(res *driveSyncResult, ctx context.Context, spaceID string, remoteFolders map[string]string, pf localPushFile, rel, overwriteID string) { +func syncPushFile(res *driveSyncResult, ctx context.Context, spaceID string, remoteFolders map[string]string, root *pinnedPullRoot, pf localPushFile, rel, overwriteID string) { parentRel, name := splitRel(rel) parentID, ok := remoteFolders[parentRel] if !ok || parentID == "" { @@ -427,7 +476,7 @@ func syncPushFile(res *driveSyncResult, ctx context.Context, spaceID string, rem res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionPush, Error: "父目录未能创建"}) return } - if err := pushUploadFile(ctx, spaceID, parentID, overwriteID, name, pf.AbsPath, pf.Size); err != nil { + if err := pushUploadFilePinned(ctx, spaceID, parentID, overwriteID, name, root, pf); err != nil { res.Summary.Failed++ res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionPush, Error: err.Error()}) return @@ -442,62 +491,74 @@ func syncPushFile(res *driveSyncResult, ctx context.Context, spaceID string, rem res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: action, Direction: direction}) } -// syncKeepBoth 解决 modified 冲突的 keep-both 策略:先把本地文件改名保留(追加基于 -// 远端 fileId 的不冲突后缀),再把远端文件拉取到原 rel 路径。拉取失败则回滚改名。 -func syncKeepBoth(res *driveSyncResult, ctx context.Context, spaceID string, rf *remoteFile, absDir, rel string, collided, occupied map[string]bool) { - if collided[rel] { +// syncKeepBoth 解决 modified 冲突的 keep-both 策略:先为本地文件原子建立带冲突后缀 +// 的 no-clobber 硬链接,再把远端文件拉取到原 rel 路径。拉取失败时保留两个本地名字, +// 不执行可能误删或覆盖并发文件的回滚。 +func syncKeepBoth(res *driveSyncResult, ctx context.Context, spaceID string, rf *remoteFile, absDir, rel string, occupied map[string]bool) { + root, err := openPinnedPullRoot(absDir) + if err != nil { res.Summary.Failed++ - res.Items = append(res.Items, driveSyncItem{ - RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, - Error: "多个远端条目在当前文件系统上映射到同一本地路径(大小写/规范化冲突),已跳过以避免覆盖丢失", - }) + res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, Error: err.Error()}) return } - oldAbs, e1 := resolveLocalTarget(absDir, rel) + defer root.close() + syncKeepBothPinned(res, ctx, spaceID, rf, root, rel, occupied) +} + +func syncKeepBothPinned(res *driveSyncResult, ctx context.Context, spaceID string, rf *remoteFile, root *pinnedPullRoot, rel string, occupied map[string]bool) { + pinned, e1 := root.openTarget(rel) if e1 != nil { res.Summary.Failed++ res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, Error: e1.Error()}) return } - // 以「不覆盖」语义原子占用一个本地重命名目标。occupied 的精确字符串查重覆盖不到 - // 大小写/NFC 等价的既有文件;用 O_CREATE|O_EXCL 让 OS 兜底判等价性,命中则换下一个 - // 后缀,杜绝 os.Rename 静默覆盖等价既有文件导致的数据丢失。 - suffixedRel, newAbs, e2 := reserveSyncKeepBothTarget(absDir, rel, rf.FileID, occupied) + defer pinned.close() + localInfo, err := pinned.regularTargetInfo() + if err != nil { + res.Summary.Failed++ + res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, Error: err.Error()}) + return + } + if localInfo == nil { + res.Summary.Failed++ + res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, Error: "本地保留版本不存在"}) + return + } + // Root.Link(original,candidate) 在同一固定父目录中原子 no-clobber 地保留本地版本。 + // occupied 的精确字符串查重覆盖不到的文件系统等价名称,由 Link 的 EEXIST 兜底。 + suffixedRel, newName, e2 := reserveSyncKeepBothTargetPinned(pinned, rel, rf.FileID, occupied) if e2 != nil { res.Summary.Failed++ res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, Error: e2.Error()}) return } - // newAbs 此刻是我们刚用 O_EXCL 新建的空占位文件,覆盖它不会丢用户数据;且已确保它 - // 不与任何等价既有文件冲突。 - if err := syncRename(oldAbs, newAbs); err != nil { - _ = os.Remove(newAbs) // 清理空占位,避免残留 + savedInfo, err := pullRootLstat(pinned.parentRoot, newName) + if err != nil || !sameKeepBothVersion(localInfo, savedInfo) { res.Summary.Failed++ - res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, Error: fmt.Sprintf("本地改名保留失败: %v", err)}) + res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, Error: "本地保留版本身份不一致"}) return } occupied[suffixedRel] = true - action, perr := pullOneFile(ctx, spaceID, rf, oldAbs, ifExistsOverwrite) + action, perr := syncPullOneFilePinned(ctx, spaceID, rf, pinned, ifExistsOverwrite) if action != pullActionDownloaded { - // 拉取失败:回滚改名,把本地文件恢复回原名。pullOneFile 是原子的(下载写临时文件、 - // 成功才 rename,失败时绝不触碰 oldAbs),故此处 oldAbs 必不存在,rename 可直接复原、 - // 无需像参考实现那样先清残留。回滚本身失败时如实上报——本地版本仍以改名后的名字保留、 - // 未丢数据,但需让用户知道文件名已变。 res.Summary.Failed++ msg := "" if perr != nil { msg = perr.Error() } - if rbErr := syncRename(newAbs, oldAbs); rbErr != nil { - if msg != "" { - msg += "; " - } - msg += fmt.Sprintf("回滚改名失败,本地版本保留为 %s: %v", suffixedRel, rbErr) + if msg == "" { + msg = "远端版本未能拉取,本地保留版本已保留" } res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, Error: msg}) return } + currentSaved, err := pullRootLstat(pinned.parentRoot, newName) + if err != nil || !sameKeepBothVersion(savedInfo, currentSaved) { + res.Summary.Failed++ + res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, Error: "本地保留版本在传输期间被删除、替换或修改"}) + return + } res.Summary.Pulled++ res.Items = append(res.Items, driveSyncItem{RelPath: suffixedRel, Action: syncActionRenamedLocal, Direction: syncDirectionConflict}, @@ -505,6 +566,12 @@ func syncKeepBoth(res *driveSyncResult, ctx context.Context, spaceID string, rf ) } +func sameKeepBothVersion(expected, current os.FileInfo) bool { + return expected != nil && current != nil && current.Mode().IsRegular() && + os.SameFile(expected, current) && expected.Size() == current.Size() && + expected.ModTime().Equal(current.ModTime()) +} + // syncKeepBothCandidate 生成 keep-both 的第 n 个候选相对路径(n=0 为首选):在扩展名前 // 插入基于远端 fileId 末段的后缀(缺失时用 conflict),n>0 再追加序号消歧。 func syncKeepBothCandidate(rel, fileID string, n int) string { @@ -539,24 +606,38 @@ func driveSyncSuffixedRel(rel, fileID string, occupied map[string]bool) string { } } -// reserveSyncKeepBothTarget 生成并以「不覆盖」语义原子占用一个 keep-both 本地目标。 -// 逐个候选用 O_CREATE|O_EXCL 尝试创建:成功即占住该名,返回其相对路径与绝对路径(调用方 -// 随后可安全 os.Rename 覆盖这个空占位);EEXIST(含大小写/NFC 等价的既有文件,由 OS 判定) -// 则记入 occupied 并试下一个后缀,绝不覆盖任何既有文件。 +// reserveSyncKeepBothTarget 生成候选并通过 Root.Link(original,candidate) 原子建立 +// no-clobber 本地保留版本。EEXIST(含文件系统等价名称)时换下一个候选。 func reserveSyncKeepBothTarget(absDir, rel, fileID string, occupied map[string]bool) (string, string, error) { + target, err := openPinnedPullTarget(absDir, rel) + if err != nil { + return "", "", err + } + defer target.close() + candidate, _, err := reserveSyncKeepBothTargetPinned(target, rel, fileID, occupied) + if err != nil { + return "", "", err + } + return candidate, filepath.Join(absDir, filepath.FromSlash(candidate)), nil +} + +func reserveSyncKeepBothTargetPinned(target *pinnedPullTarget, rel, fileID string, occupied map[string]bool) (string, string, error) { + parentRel, _ := splitRel(rel) for n := 0; ; n++ { cand := syncKeepBothCandidate(rel, fileID, n) if occupied[cand] { continue // 本次运行内已知占用,快速跳过 } - abs, err := resolveLocalTarget(absDir, cand) - if err != nil { - return "", "", err // 逃逸/符号链接等,直接失败 + candidateParent, candidateName := splitRel(cand) + if candidateParent != parentRel || !isSafeRemoteSegment(candidateName) { + return "", "", fmt.Errorf("keep-both 候选路径 %q 不在原目标目录内", cand) } - f, oerr := syncOpenFile(abs, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + if err := target.verifyParent(); err != nil { + return "", "", err + } + oerr := syncRootLink(target.parentRoot, target.name, filepath.FromSlash(candidateName)) if oerr == nil { - _ = f.Close() - return cand, abs, nil + return cand, filepath.FromSlash(candidateName), nil } if os.IsExist(oerr) { occupied[cand] = true // 等价目标已存在,记下避免重试,换下一个后缀 diff --git a/internal/helpers/drive_sync_confirmation_test.go b/internal/helpers/drive_sync_confirmation_test.go index 73a18c03..8c22e83b 100644 --- a/internal/helpers/drive_sync_confirmation_test.go +++ b/internal/helpers/drive_sync_confirmation_test.go @@ -7,6 +7,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" ) // pull / push / sync 声明 Safety.Confirmation = user_required。本文件断言两件事: @@ -66,18 +68,14 @@ func TestCrossPlatformCoverageDriveSyncFamily_refusesWithoutConfirmation(t *test } return `{"result":{"fileId":"NEW"},"success":true}`, nil }} - SetHTTPGetFile(func(_ context.Context, _ string, _ map[string]string, dest string) error { + swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error { t.Error("no download may happen before confirmation") return os.WriteFile(dest, []byte("leaked"), 0o644) }) - SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { + testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { t.Error("no upload may happen before confirmation") return nil }) - t.Cleanup(func() { - SetHTTPGetFile(nil) - SetHTTPPutFile(nil) - }) args := append(append([]string{}, tc.args...), "--local-folder", dir, "--remote-folder", "ROOT") err := runDriveCmdWithoutConfirm(t, caller, args...) @@ -113,18 +111,14 @@ func TestCrossPlatformCoverageDriveSyncFamily_dryRunNeedsNoConfirmation(t *testi "ROOT": `{"result":{"items":[{"name":"remote.txt","type":"file","fileId":"R","modifyTime":2}],"nextToken":""}}`, }) caller.dryRun = true - SetHTTPGetFile(func(context.Context, string, map[string]string, string) error { + swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error { t.Error("dry-run must not download") return nil }) - SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { + testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { t.Error("dry-run must not upload") return nil }) - t.Cleanup(func() { - SetHTTPGetFile(nil) - SetHTTPPutFile(nil) - }) if err := runDriveCmdWithoutConfirm(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT", "--quick"); err != nil { @@ -140,17 +134,14 @@ func TestCrossPlatformCoverageDrivePull_dryRunPlansWithoutLocalWrites(t *testing target := filepath.Join(parent, "not-created") caller := pullListingCaller(`{"name":"remote.txt","type":"file","fileId":"R","modifyTime":2}`) caller.dryRun = true - SetHTTPGetFile(func(context.Context, string, map[string]string, string) error { + swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error { t.Error("dry-run must not download") return nil }) - t.Cleanup(func() { SetHTTPGetFile(nil) }) var out bytes.Buffer - prevDeps, prevArgs := deps, os.Args - deps = &Deps{Caller: caller, Out: &Formatter{w: &out}} - os.Args = []string{"dws", "drive", "pull"} - t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs }) + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive", "pull"}) cmd := findDriveSubcommand(t, "pull") mustSetFlags(t, cmd, map[string]string{"local-folder": target, "remote-folder": "ROOT"}) if err := cmd.RunE(cmd, nil); err != nil { @@ -175,17 +166,14 @@ func TestCrossPlatformCoverageDrivePush_dryRunPlansWithoutRemoteWrites(t *testin mustWrite(t, filepath.Join(dir, "sub", "local.txt"), "local") caller := syncCaller(nil) caller.dryRun = true - SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { + testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { t.Error("dry-run must not upload") return nil }) - t.Cleanup(func() { SetHTTPPutFile(nil) }) var out bytes.Buffer - prevDeps, prevArgs := deps, os.Args - deps = &Deps{Caller: caller, Out: &Formatter{w: &out}} - os.Args = []string{"dws", "drive", "push"} - t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs }) + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive", "push"}) cmd := findDriveSubcommand(t, "push") mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"}) if err := cmd.RunE(cmd, nil); err != nil { @@ -214,9 +202,7 @@ func TestCrossPlatformCoverageDrivePull_dryRunPlanBranches(t *testing.T) { "new.txt": {RelPath: "new.txt"}, } var out bytes.Buffer - prevDeps := deps - deps = &Deps{Out: &Formatter{w: &out}} - t.Cleanup(func() { deps = prevDeps }) + testseam.Swap(t, &deps, &Deps{Out: &Formatter{w: &out}}) if err := printDrivePullDryRun(root, ifExistsSkip, remote, []string{"A.txt", "a.txt", "../escape", "skip.txt", "new.txt"}, true); err != nil { @@ -236,9 +222,7 @@ func TestCrossPlatformCoverageDrivePull_dryRunPlanBranches(t *testing.T) { func TestCrossPlatformCoverageDrivePush_dryRunPlanBranches(t *testing.T) { var out bytes.Buffer - prevDeps := deps - deps = &Deps{Out: &Formatter{w: &out}} - t.Cleanup(func() { deps = prevDeps }) + testseam.Swap(t, &deps, &Deps{Out: &Formatter{w: &out}}) remoteFolders := map[string]string{"": "ROOT", "existing": "EXISTING"} remoteFiles := map[string]*remoteFile{ "skip.txt": {RelPath: "skip.txt"}, @@ -317,10 +301,9 @@ func TestCrossPlatformCoverageDrivePull_confirmedOverwriteIssuesExactCalls(t *te mustWrite(t, p, "local-old") caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":9}`) - SetHTTPGetFile(func(_ context.Context, _ string, _ map[string]string, dest string) error { + swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error { return os.WriteFile(dest, []byte("remote-new"), 0o644) }) - t.Cleanup(func() { SetHTTPGetFile(nil) }) if err := runDriveCmdWithoutConfirm(t, caller, "pull", "--local-folder", dir, "--remote-folder", "ROOT", "--if-exists", "overwrite", "--yes"); err != nil { @@ -419,11 +402,10 @@ func TestCrossPlatformCoverageDrivePull_defaultSkipsExistingLocalFiles(t *testin mustWrite(t, p, "local-old") caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":9}`) - SetHTTPGetFile(func(context.Context, string, map[string]string, string) error { + swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error { t.Error("default skip must not download over an existing local file") return nil }) - t.Cleanup(func() { SetHTTPGetFile(nil) }) if err := runDriveCmd(t, caller, "pull", "--local-folder", dir, "--remote-folder", "ROOT"); err != nil { t.Fatalf("unexpected error: %v", err) diff --git a/internal/helpers/drive_sync_conflict_test.go b/internal/helpers/drive_sync_conflict_test.go index b3a98bb6..962e20e9 100644 --- a/internal/helpers/drive_sync_conflict_test.go +++ b/internal/helpers/drive_sync_conflict_test.go @@ -6,7 +6,6 @@ import ( "io" "os" "path/filepath" - "strings" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" @@ -115,118 +114,95 @@ func TestCrossPlatformCoverageSyncKeepBoth_escapingRelIsFailed(t *testing.T) { } res := &driveSyncResult{} syncKeepBoth(res, context.Background(), "", &remoteFile{RelPath: "sub/f.txt", FileID: "FID12345678"}, - root, "sub/f.txt", map[string]bool{}, map[string]bool{}) + root, "sub/f.txt", map[string]bool{}) if res.Summary.Failed != 1 { t.Fatalf("failed = %d, want 1", res.Summary.Failed) } } -// collided 命中时 keep-both 直接 failed,不做任何改名。 -func TestCrossPlatformCoverageSyncKeepBoth_collidedIsFailed(t *testing.T) { - root := t.TempDir() - mustWrite(t, filepath.Join(root, "f.txt"), "local") - res := &driveSyncResult{} - syncKeepBoth(res, context.Background(), "", &remoteFile{RelPath: "f.txt", FileID: "FID12345678"}, - root, "f.txt", map[string]bool{"f.txt": true}, map[string]bool{}) - if res.Summary.Failed != 1 { - t.Fatalf("failed = %d, want 1", res.Summary.Failed) - } - if b, _ := os.ReadFile(filepath.Join(root, "f.txt")); string(b) != "local" { - t.Errorf("collided keep-both must not touch the local file, got %q", string(b)) - } -} - -// 占用改名目标失败(目录不可写)→ failed。 +// 原子建立本地保留硬链接失败 → failed。 func TestCrossPlatformCoverageSyncKeepBoth_reserveFailureIsFailed(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, "f.txt"), "local") - testseam.Swap(t, &syncOpenFile, func(string, int, os.FileMode) (*os.File, error) { - return nil, errors.New("open file boom") + testseam.Swap(t, &syncRootLink, func(*os.Root, string, string) error { + return errors.New("link boom") }) res := &driveSyncResult{} syncKeepBoth(res, context.Background(), "", &remoteFile{RelPath: "f.txt", FileID: "FID12345678"}, - root, "f.txt", map[string]bool{}, map[string]bool{}) + root, "f.txt", map[string]bool{}) if res.Summary.Failed != 1 { t.Fatalf("failed = %d, want 1", res.Summary.Failed) } } -// 改名本身失败(本地原文件其实是目录)→ failed,并清理刚建的空占位。 -func TestCrossPlatformCoverageSyncKeepBoth_renameFailureCleansPlaceholder(t *testing.T) { +// Link 失败不得留下空占位,也不得移动或修改原文件。 +func TestCrossPlatformCoverageSyncKeepBoth_linkFailureLeavesOriginal(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, "f.txt"), "local") - testseam.Swap(t, &syncRename, func(string, string) error { - return errors.New("rename boom") + testseam.Swap(t, &syncRootLink, func(*os.Root, string, string) error { + return errors.New("link boom") }) res := &driveSyncResult{} occupied := map[string]bool{} syncKeepBoth(res, context.Background(), "", &remoteFile{RelPath: "f.txt", FileID: "FID12345678"}, - root, "f.txt", map[string]bool{}, occupied) + root, "f.txt", occupied) if res.Summary.Failed != 1 { t.Fatalf("failed = %d, want 1", res.Summary.Failed) } - // 空占位必须被清理,不留残留。 if _, err := os.Stat(filepath.Join(root, syncKeepBothCandidate("f.txt", "FID12345678", 0))); !os.IsNotExist(err) { - t.Error("empty placeholder must be removed after a failed rename") + t.Error("failed link must not leave a placeholder") + } + if content, err := os.ReadFile(filepath.Join(root, "f.txt")); err != nil || string(content) != "local" { + t.Fatalf("original changed after failed link: content=%q err=%v", content, err) } } -// 拉取失败 → 回滚改名,本地文件恢复原名与原内容。 -func TestCrossPlatformCoverageSyncKeepBoth_pullFailureRollsBackRename(t *testing.T) { +// 拉取失败时不做回滚:原名尚未发布,候选硬链接也保留,避免误删并发对象。 +func TestCrossPlatformCoverageSyncKeepBoth_pullFailurePreservesCandidate(t *testing.T) { root := t.TempDir() p := filepath.Join(root, "f.txt") mustWrite(t, p, "local-version") - prevDeps, prevArgs := deps, os.Args - deps = &Deps{Caller: syncCaller(nil), Out: &Formatter{w: io.Discard}} - os.Args = []string{"dws", "drive"} - SetHTTPGetFile(func(context.Context, string, map[string]string, string) error { return errTestDownload }) - t.Cleanup(func() { - deps, os.Args = prevDeps, prevArgs - SetHTTPGetFile(nil) - }) + testseam.Swap(t, &deps, &Deps{Caller: syncCaller(nil), Out: &Formatter{w: io.Discard}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive"}) + swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error { return errTestDownload }) res := &driveSyncResult{} syncKeepBoth(res, context.Background(), "", &remoteFile{RelPath: "f.txt", FileID: "FID12345678"}, - root, "f.txt", map[string]bool{}, map[string]bool{}) + root, "f.txt", map[string]bool{}) if res.Summary.Failed != 1 { t.Fatalf("failed = %d, want 1", res.Summary.Failed) } if b, err := os.ReadFile(p); err != nil || string(b) != "local-version" { - t.Fatalf("rename must be rolled back: %v / %q", err, string(b)) + t.Fatalf("original must remain before publish: %v / %q", err, string(b)) } - if _, err := os.Stat(filepath.Join(root, syncKeepBothCandidate("f.txt", "FID12345678", 0))); !os.IsNotExist(err) { - t.Error("renamed copy must not survive a successful rollback") + suffixed := filepath.Join(root, syncKeepBothCandidate("f.txt", "FID12345678", 0)) + if b, err := os.ReadFile(suffixed); err != nil || string(b) != "local-version" { + t.Fatalf("candidate must remain after pull failure: %v / %q", err, string(b)) + } + originalInfo, _ := os.Lstat(p) + candidateInfo, _ := os.Lstat(suffixed) + if !os.SameFile(originalInfo, candidateInfo) { + t.Fatal("candidate must be a hard link to the original local version") } } -// 拉取失败且回滚也失败 → 如实上报“本地版本保留为 <改名>”,不谎称成功。 -func TestCrossPlatformCoverageSyncKeepBoth_rollbackFailureIsReported(t *testing.T) { +// 拉取失败只能上报 failed,不得产生 renamed_local 成功项。 +func TestCrossPlatformCoverageSyncKeepBoth_pullFailureDoesNotReportRenamed(t *testing.T) { root := t.TempDir() p := filepath.Join(root, "f.txt") mustWrite(t, p, "local-version") - prevDeps, prevArgs := deps, os.Args - deps = &Deps{Caller: syncCaller(nil), Out: &Formatter{w: io.Discard}} - os.Args = []string{"dws", "drive"} - // 下载失败前先把原名占住一个目录,使回滚 rename 无法复原。 - SetHTTPGetFile(func(context.Context, string, map[string]string, string) error { - if err := os.MkdirAll(filepath.Join(p, "blocker"), 0o755); err != nil { - return err - } - return errTestDownload - }) - t.Cleanup(func() { - deps, os.Args = prevDeps, prevArgs - SetHTTPGetFile(nil) - }) + testseam.Swap(t, &deps, &Deps{Caller: syncCaller(nil), Out: &Formatter{w: io.Discard}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive"}) + swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error { return errTestDownload }) res := &driveSyncResult{} syncKeepBoth(res, context.Background(), "", &remoteFile{RelPath: "f.txt", FileID: "FID12345678"}, - root, "f.txt", map[string]bool{}, map[string]bool{}) + root, "f.txt", map[string]bool{}) if res.Summary.Failed != 1 { t.Fatalf("failed = %d, want 1", res.Summary.Failed) @@ -234,12 +210,11 @@ func TestCrossPlatformCoverageSyncKeepBoth_rollbackFailureIsReported(t *testing. if len(res.Items) != 1 || res.Items[0].Action != syncActionFailed { t.Fatalf("items = %+v", res.Items) } - // 数据没丢:本地版本仍以改名后的名字存在,且错误信息点明了这一点。 suffixed := syncKeepBothCandidate("f.txt", "FID12345678", 0) if b, err := os.ReadFile(filepath.Join(root, suffixed)); err != nil || string(b) != "local-version" { t.Fatalf("local version must survive as %s: %v / %q", suffixed, err, string(b)) } - if !strings.Contains(res.Items[0].Error, "回滚改名失败") { - t.Errorf("error must disclose the failed rollback, got %q", res.Items[0].Error) + if res.Items[0].Action == syncActionRenamedLocal { + t.Fatalf("pull failure must not report renamed_local: %#v", res.Items) } } diff --git a/internal/helpers/drive_sync_coverage_test.go b/internal/helpers/drive_sync_coverage_test.go index 1ab48574..f90b0f63 100644 --- a/internal/helpers/drive_sync_coverage_test.go +++ b/internal/helpers/drive_sync_coverage_test.go @@ -38,14 +38,10 @@ func syncCaller(listing map[string]string) *driveScriptCaller { // withSyncTransport 注入成功的上传与下载。 func withSyncTransport(t *testing.T, body string) { t.Helper() - SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil }) - SetHTTPGetFile(func(_ context.Context, _ string, _ map[string]string, dest string) error { + testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { return nil }) + swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error { return os.WriteFile(dest, []byte(body), 0o644) }) - t.Cleanup(func() { - SetHTTPPutFile(nil) - SetHTTPGetFile(nil) - }) } // ────────────────────────────────────────────────────────── @@ -133,7 +129,7 @@ func TestCrossPlatformCoverageDriveSync_unknownIsSkipped(t *testing.T) { // exact 模式下本地 MD5 计算失败 → 整个 sync 中止。错误通过 seam 确定性注入, // 避免依赖 chmod:Windows 不遵循 POSIX 的 000 权限语义。 func TestCrossPlatformCoverageDriveSync_md5FailureAborts(t *testing.T) { - testseam.Swap(t, &md5File, func(string) (string, error) { + testseam.Swap(t, &pushMD5OpenedFile, func(*os.File) (string, error) { return "", errors.New("md5 boom") }) @@ -223,8 +219,7 @@ func TestCrossPlatformCoverageDriveSync_folderCreationFailureCascades(t *testing func TestCrossPlatformCoverageDriveSync_pushUploadFailureIsFailed(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "only-local.txt"), "x") - SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return errTestUpload }) - t.Cleanup(func() { SetHTTPPutFile(nil) }) + testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { return errTestUpload }) caller := syncCaller(nil) err := runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT", "--quick") diff --git a/internal/helpers/drive_sync_edges_test.go b/internal/helpers/drive_sync_edges_test.go index c8c3cfbf..f55d9065 100644 --- a/internal/helpers/drive_sync_edges_test.go +++ b/internal/helpers/drive_sync_edges_test.go @@ -52,10 +52,9 @@ func TestCrossPlatformCoverageDrivePull_emptyIfExistsDefaultsToSkip(t *testing.T p := filepath.Join(root, "a.txt") mustWrite(t, p, "local-old") caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":1}`) - SetHTTPGetFile(func(_ context.Context, _ string, _ map[string]string, dest string) error { + swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error { return os.WriteFile(dest, []byte("remote-new"), 0o644) }) - t.Cleanup(func() { SetHTTPGetFile(nil) }) if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT", "--if-exists", ""); err != nil { t.Fatalf("unexpected error: %v", err) @@ -88,14 +87,12 @@ func TestCrossPlatformCoverageDrivePush_emptyIfExistsDefaultsToSkip(t *testing.T // 目标目录不可写 → 创建临时文件失败。 func TestCrossPlatformCoveragePullOneFile_tempFileCreationFailure(t *testing.T) { root := t.TempDir() - testseam.Swap(t, &pullCreateTemp, func(string, string) (*os.File, error) { - return nil, errors.New("create temp boom") + testseam.Swap(t, &pullCreateTemp, func(*os.Root) (*os.File, string, error) { + return nil, "", errors.New("create temp boom") }) - prevDeps, prevArgs := deps, os.Args - deps = &Deps{Caller: pullListingCaller(""), Out: &Formatter{w: io.Discard}} - os.Args = []string{"dws", "drive"} - t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs }) + testseam.Swap(t, &deps, &Deps{Caller: pullListingCaller(""), Out: &Formatter{w: io.Discard}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive"}) action, err := pullOneFile(context.Background(), "", &remoteFile{RelPath: "a.txt", FileID: "A"}, filepath.Join(root, "a.txt"), ifExistsOverwrite) @@ -112,16 +109,11 @@ func TestCrossPlatformCoveragePullOneFile_renameOntoDirectoryFails(t *testing.T) t.Fatal(err) } - prevDeps, prevArgs := deps, os.Args - deps = &Deps{Caller: pullListingCaller(""), Out: &Formatter{w: io.Discard}} - os.Args = []string{"dws", "drive"} - SetHTTPGetFile(func(_ context.Context, _ string, _ map[string]string, dest string) error { + testseam.Swap(t, &deps, &Deps{Caller: pullListingCaller(""), 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("payload"), 0o644) }) - t.Cleanup(func() { - deps, os.Args = prevDeps, prevArgs - SetHTTPGetFile(nil) - }) action, err := pullOneFile(context.Background(), "", &remoteFile{RelPath: "a.txt", FileID: "A"}, target, ifExistsOverwrite) @@ -137,10 +129,8 @@ func TestCrossPlatformCoveragePullOneFile_renameOntoDirectoryFails(t *testing.T) func TestCrossPlatformCoverageDrivePull_printFailurePropagates(t *testing.T) { dir := t.TempDir() - prevDeps, prevArgs := deps, os.Args - deps = &Deps{Caller: pullListingCaller(""), Out: &Formatter{w: failingWriter{}}} - os.Args = []string{"dws", "drive", "pull"} - t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs }) + testseam.Swap(t, &deps, &Deps{Caller: pullListingCaller(""), Out: &Formatter{w: failingWriter{}}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive", "pull"}) cmd := findDriveSubcommand(t, "pull") mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"}) @@ -154,10 +144,8 @@ func TestCrossPlatformCoverageDrivePush_printFailurePropagates(t *testing.T) { mustWrite(t, filepath.Join(dir, "a.txt"), "x") withNoopPut(t) - prevDeps, prevArgs := deps, os.Args - deps = &Deps{Caller: pushOKCaller(nil), Out: &Formatter{w: failingWriter{}}} - os.Args = []string{"dws", "drive", "push"} - t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs }) + testseam.Swap(t, &deps, &Deps{Caller: pushOKCaller(nil), Out: &Formatter{w: failingWriter{}}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive", "push"}) cmd := findDriveSubcommand(t, "push") mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"}) @@ -169,13 +157,10 @@ func TestCrossPlatformCoverageDrivePush_printFailurePropagates(t *testing.T) { func TestCrossPlatformCoverageDriveSync_printFailurePropagates(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "a.txt"), "x") - SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil }) - t.Cleanup(func() { SetHTTPPutFile(nil) }) + testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { return nil }) - prevDeps, prevArgs := deps, os.Args - deps = &Deps{Caller: syncCaller(nil), Out: &Formatter{w: failingWriter{}}} - os.Args = []string{"dws", "drive", "sync"} - t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs }) + testseam.Swap(t, &deps, &Deps{Caller: syncCaller(nil), Out: &Formatter{w: failingWriter{}}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive", "sync"}) cmd := findDriveSubcommand(t, "sync") mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT", "quick": "true"}) @@ -253,11 +238,12 @@ func TestCrossPlatformCoverageReserveSyncKeepBothTarget_escapeFails(t *testing.T } } -// 候选目标所在目录不可写 → OpenFile 以非 EEXIST 错误失败,直接上报。 +// 候选硬链接创建返回非 EEXIST 错误时直接上报。 func TestCrossPlatformCoverageReserveSyncKeepBothTarget_unwritableDirFails(t *testing.T) { root := t.TempDir() - testseam.Swap(t, &syncOpenFile, func(string, int, os.FileMode) (*os.File, error) { - return nil, errors.New("open file boom") + mustWrite(t, filepath.Join(root, "f.txt"), "local") + testseam.Swap(t, &syncRootLink, func(*os.Root, string, string) error { + return errors.New("link boom") }) if _, _, err := reserveSyncKeepBothTarget(root, "f.txt", "FID12345678", map[string]bool{}); err == nil { @@ -268,6 +254,7 @@ func TestCrossPlatformCoverageReserveSyncKeepBothTarget_unwritableDirFails(t *te // occupied 中已知占用的首选候选被跳过,改用下一个后缀。 func TestCrossPlatformCoverageReserveSyncKeepBothTarget_skipsKnownOccupied(t *testing.T) { root := t.TempDir() + mustWrite(t, filepath.Join(root, "f.txt"), "local") first := syncKeepBothCandidate("f.txt", "FID12345678", 0) occupied := map[string]bool{first: true} diff --git a/internal/helpers/drive_sync_gaps_test.go b/internal/helpers/drive_sync_gaps_test.go index 5165c757..4d622446 100644 --- a/internal/helpers/drive_sync_gaps_test.go +++ b/internal/helpers/drive_sync_gaps_test.go @@ -7,6 +7,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" ) // 本文件补齐几条在默认策略改为 skip 后失去覆盖的边界分支。 @@ -62,10 +64,8 @@ func TestCrossPlatformCoverageDrivePush_remoteNonFileEntriesAreSkipped(t *testin return `{"result":{"items":[],"nextToken":""}}`, nil }} - prevDeps, prevArgs := deps, os.Args - deps = &Deps{Caller: caller, Out: &Formatter{w: io.Discard}} - os.Args = []string{"dws", "drive"} - t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs }) + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive"}) files, _, err := fetchRemoteTreeForPush(context.Background(), "", "ROOT") if err != nil { @@ -81,14 +81,10 @@ func TestCrossPlatformCoverageDrivePush_remoteNonFileEntriesAreSkipped(t *testin } } -// list_files 返回体没有 result 字段 → 视为空列表,而不是解析错误。 -func TestCrossPlatformCoverageParseDriveList_missingResultIsEmpty(t *testing.T) { - items, token, err := parseDriveList(`{"success":true}`) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(items) != 0 || token != "" { - t.Errorf("parseDriveList = (%v, %q), want empty list", items, token) +// list_files 是远端权威树的数据来源;缺失 result 不能伪装成空目录。 +func TestCrossPlatformCoverageParseDriveList_missingResultFailsClosed(t *testing.T) { + if _, _, err := parseDriveList(`{"success":true}`); err == nil || !strings.Contains(err.Error(), "缺少 result") { + t.Fatalf("parseDriveList error = %v, want missing-result failure", err) } } diff --git a/internal/helpers/drive_sync_platform_test.go b/internal/helpers/drive_sync_platform_test.go index 2ab28887..d7f19fb3 100644 --- a/internal/helpers/drive_sync_platform_test.go +++ b/internal/helpers/drive_sync_platform_test.go @@ -33,11 +33,10 @@ func TestCrossPlatformCoverageDrivePull_equivalentPathCollisionFailsBoth(t *test caller := pullListingCaller( `{"name":"A.txt","type":"file","fileId":"UP","modifyTime":1},` + `{"name":"a.txt","type":"file","fileId":"LOW","modifyTime":2}`) - SetHTTPGetFile(func(context.Context, string, map[string]string, string) error { + swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error { t.Error("collided entries must not be downloaded") return nil }) - t.Cleanup(func() { SetHTTPGetFile(nil) }) err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT") var pf *drivePartialFailure @@ -95,7 +94,7 @@ func TestCrossPlatformCoverageDetectCaseInsensitiveFS_caselessProbeFallsBackToPl // ────────────────────────────────────────────────────────── func TestCrossPlatformCoverageIsSafeRemoteSegmentPlatform_allowsPlainNames(t *testing.T) { - for _, name := range []string{"a.txt", "报告.pdf", "with space.md", "..."} { + for _, name := range []string{"a.txt", "报告.pdf", "with space.md"} { if !isSafeRemoteSegmentPlatform(name) { t.Errorf("isSafeRemoteSegmentPlatform(%q) = false, want true", name) } @@ -106,8 +105,18 @@ func TestCrossPlatformCoverageIsSafeRemoteSegmentPlatform_rejectsNonCanonicalWin if runtime.GOOS != "windows" { return } - if isSafeRemoteSegmentPlatform(`dir\..\file.txt`) { - t.Error("Windows non-canonical segment must be rejected") + for _, name := range []string{ + `C:relative`, `a?.txt`, `a*.txt`, `a:stream`, `a<.txt`, `a>.txt`, `a|.txt`, `a".txt`, + "name.", "name ", "...", "CON", "con.txt", "PRN", "AUX", "NUL", "COM1", "LPT9", "COM¹", "CONIN$", + } { + if isSafeRemoteSegmentPlatform(name) { + t.Errorf("Windows unsafe segment %q must be rejected", name) + } + } + for _, name := range []string{"report.txt", "报告 2026.pdf", "COM10", "LPT0"} { + if !isSafeRemoteSegmentPlatform(name) { + t.Errorf("Windows ordinary segment %q must be accepted", name) + } } } diff --git a/internal/helpers/drive_sync_test.go b/internal/helpers/drive_sync_test.go index e2221dac..854aecb3 100644 --- a/internal/helpers/drive_sync_test.go +++ b/internal/helpers/drive_sync_test.go @@ -17,6 +17,16 @@ import ( // errTestDownload 用于模拟 httpGetFile 下载失败。 var errTestDownload = errors.New("simulated download failure") +// swapPullDownloadPath 为存量测试保留基于路径的传输模拟写法。 +// 生产 pull/sync 始终把已由 os.Root 安全打开的句柄交给 pullDownloadFile; +// 只有测试适配层才把该句柄的名称传给旧 callback。 +func swapPullDownloadPath(t *testing.T, fn func(context.Context, string, map[string]string, string) error) { + t.Helper() + testseam.Swap(t, &pullDownloadFile, func(ctx context.Context, url string, headers map[string]string, destination *os.File) error { + return fn(ctx, url, headers, destination.Name()) + }) +} + // ────────────────────────────────────────────────────────── // driveSyncFailure — sync 部分失败错误:exit=1 // ────────────────────────────────────────────────────────── @@ -65,7 +75,7 @@ func TestCrossPlatformCoverageDriveSyncSuffixedRel(t *testing.T) { // sync 双向流程 — new_local 上传 / new_remote 下载 // ────────────────────────────────────────────────────────── -// runDriveSyncTest 用 mock caller 执行 `drive sync`,注入 httpPutFile / httpGetFile 为 +// runDriveSyncTest 用 mock caller 执行 `drive sync`,注入 opened-file PUT / GET 为 // 可控 no-op,避免真实 OSS 传输。返回 root.Execute() 的结果。 func runDriveSyncTest(t *testing.T, caller *driveSyncMockCaller, localDir string, args ...string) error { // 默认下载:把远端内容写到目标路径,模拟成功落盘。 @@ -78,12 +88,9 @@ func runDriveSyncTest(t *testing.T, caller *driveSyncMockCaller, localDir string func runDriveSyncTestWithGet(t *testing.T, caller *driveSyncMockCaller, getFn func(context.Context, string, map[string]string, string) error, localDir string, args ...string) error { t.Helper() - prevDeps := deps - prevOSArgs := os.Args - SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil }) - SetHTTPGetFile(getFn) - - deps = &Deps{Caller: caller, Out: &Formatter{w: io.Discard}} + testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { return nil }) + swapPullDownloadPath(t, getFn) + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}}) root := &cobra.Command{Use: "dws"} root.PersistentFlags().BoolP("yes", "y", false, "") @@ -92,16 +99,9 @@ func runDriveSyncTestWithGet(t *testing.T, caller *driveSyncMockCaller, getFn fu // sync 是 user_required 叶子,非交互环境需 --yes 才能越过统一确认门。 full := append([]string{"sync", "--local-folder", localDir, "--remote-folder", "ROOT", "--yes"}, args...) - os.Args = append([]string{"dws", "drive"}, full...) + testseam.Swap(t, &os.Args, append([]string{"dws", "drive"}, full...)) root.SetArgs(append([]string{"drive"}, full...)) - t.Cleanup(func() { - deps = prevDeps - os.Args = prevOSArgs - SetHTTPPutFile(nil) - SetHTTPGetFile(nil) - }) - return root.Execute() } @@ -198,6 +198,36 @@ func TestCrossPlatformCoverageDriveSync_modifiedLocalWins(t *testing.T) { } } +func TestCrossPlatformCoverageDriveSyncRejectsUnknownCommitEffect(t *testing.T) { + for _, response := range []string{"", `{}`} { + t.Run(response, func(t *testing.T) { + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "local.txt"), "payload") + caller := &driveSyncMockCaller{ + listJSON: `{"result":{"items":[]}}`, + commitJSON: &response, + } + err, output, putCalls := runDriveMirrorUploadCommandCapture(t, "sync", caller, dir) + if err == nil { + t.Fatal("empty commit result must make sync exit non-zero") + } + if failure, ok := err.(*driveSyncFailure); !ok || failure.ExitCode() != 1 { + t.Fatalf("error=%T %v", err, err) + } + assertMirrorUploadFailedJSON(t, output) + if putCalls != 1 { + t.Fatalf("OSS PUT calls=%d, want exactly 1", putCalls) + } + if got := len(caller.callsFor("get_upload_info")); got != 1 { + t.Fatalf("get_upload_info calls=%d, want 1", got) + } + if got := len(caller.callsFor("commit_upload")); got != 1 { + t.Fatalf("commit_upload calls=%d, want 1", got) + } + }) + } +} + // --dry-run:只计算差异,不触发任何 MCP 写操作与本地落盘。 func TestCrossPlatformCoverageDriveSync_dryRun(t *testing.T) { dir := t.TempDir() @@ -247,9 +277,7 @@ func TestCrossPlatformCoverageDriveSync_pullFailureKeepsLocal(t *testing.T) { } } -// keep-both 成功路径:本地既有文件改名保留(os.Rename 覆盖 O_EXCL 空占位),远端拉取到 -// 原路径。此测试在 Windows 主机上运行即验证 os.Rename 可替换既有目标(MOVEFILE_REPLACE_ -// EXISTING),覆盖 pull 覆盖与 keep-both 两条 rename 路径的平台行为。 +// keep-both 成功路径:先以 no-clobber 硬链接保留本地版本,再把远端拉取到原路径。 func TestCrossPlatformCoverageDriveSync_keepBothSuccess(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "f.txt") @@ -282,9 +310,8 @@ func TestCrossPlatformCoverageDriveSync_keepBothSuccess(t *testing.T) { } } -// keep-both 下载失败:改名保留后拉取远端失败,应回滚改名——原文件恢复原名原内容, -// 不留下改名后的残留文件。 -func TestCrossPlatformCoverageDriveSync_keepBothRollbackOnPullFailure(t *testing.T) { +// keep-both 下载失败:不做可能误删并发文件的回滚,原名与本地保留候选都应保留。 +func TestCrossPlatformCoverageDriveSync_keepBothPreservesLinkOnPullFailure(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "f.txt") mustWrite(t, p, "local-original") @@ -301,16 +328,13 @@ func TestCrossPlatformCoverageDriveSync_keepBothRollbackOnPullFailure(t *testing if err == nil { t.Fatal("下载失败时应以非零退出码退出") } - // 原文件恢复原名原内容。 + // 原名尚未被远端发布,保持原内容。 if b, rerr := os.ReadFile(p); rerr != nil || string(b) != "local-original" { - t.Errorf("keep-both 拉取失败应回滚改名, 原文件未恢复: got %q err=%v", string(b), rerr) + t.Errorf("keep-both 拉取失败后原文件应保留: got %q err=%v", string(b), rerr) } - // 目录下不应残留改名后的 *.conflict-* 文件。 - entries, _ := os.ReadDir(dir) - for _, e := range entries { - if strings.Contains(e.Name(), "conflict-") { - t.Errorf("回滚后不应残留改名文件: %s", e.Name()) - } + aside := filepath.Join(dir, "f.conflict-F_FID.txt") + if b, rerr := os.ReadFile(aside); rerr != nil || string(b) != "local-original" { + t.Errorf("keep-both 拉取失败后候选应保留: got %q err=%v", string(b), rerr) } } @@ -318,6 +342,7 @@ func TestCrossPlatformCoverageDriveSync_keepBothRollbackOnPullFailure(t *testing // 应原子占位到下一个后缀,且既有文件内容不被破坏。此断言在任何文件系统上都成立。 func TestCrossPlatformCoverageReserveSyncKeepBothTarget_noOverwriteExact(t *testing.T) { dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "f.txt"), "local-data") // 预置一个与首选候选精确同名的既有文件(模拟未被 occupied 记录的外部残留)。 first := "f.conflict-ABCD1234.txt" mustWrite(t, filepath.Join(dir, first), "existing-data") @@ -336,9 +361,9 @@ func TestCrossPlatformCoverageReserveSyncKeepBothTarget_noOverwriteExact(t *test if b, _ := os.ReadFile(filepath.Join(dir, first)); string(b) != "existing-data" { t.Error("既有文件被覆盖,数据丢失") } - // 返回的目标是新建的空占位文件。 - if fi, e := os.Stat(abs); e != nil || fi.Size() != 0 { - t.Errorf("占位文件应存在且为空: err=%v", e) + // 返回的目标是原始本地版本的硬链接,不是空占位。 + if b, e := os.ReadFile(abs); e != nil || string(b) != "local-data" { + t.Errorf("候选硬链接内容错误: content=%q err=%v", b, e) } } @@ -346,6 +371,7 @@ func TestCrossPlatformCoverageReserveSyncKeepBothTarget_noOverwriteExact(t *test // 既有文件——O_EXCL 由 OS 兜底判等价性,occupied 的精确字符串键覆盖不到这种情况。 func TestCrossPlatformCoverageReserveSyncKeepBothTarget_noOverwriteCaseEquivalent(t *testing.T) { dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "f.txt"), "local-data") if !isCaseInsensitiveFS(dir) { t.Skip("文件系统大小写敏感,跳过大小写等价覆盖测试") } diff --git a/internal/helpers/drive_sync_type_conflict_test.go b/internal/helpers/drive_sync_type_conflict_test.go index dbb66b66..9ed891ae 100644 --- a/internal/helpers/drive_sync_type_conflict_test.go +++ b/internal/helpers/drive_sync_type_conflict_test.go @@ -107,11 +107,11 @@ type driveSyncTransferCalls struct { func captureDriveSyncTransfers(t *testing.T) *driveSyncTransferCalls { t.Helper() calls := &driveSyncTransferCalls{} - testseam.Swap(t, &httpGetFile, func(context.Context, string, map[string]string, string) error { + swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error { calls.get++ return nil }) - testseam.Swap(t, &httpPutFile, func(context.Context, string, map[string]string, string, int64) error { + testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { calls.put++ return nil }) diff --git a/skills/mono/references/products/drive.md b/skills/mono/references/products/drive.md index 5289cf76..3842b17d 100644 --- a/skills/mono/references/products/drive.md +++ b/skills/mono/references/products/drive.md @@ -389,7 +389,7 @@ Flags: - 默认 `detection=exact`(比较 MD5);传 `--quick` 后 `detection=quick`(只比较 modified_time,best-effort)。 - exact 模式**只在能拿到远端 MD5 时才判定 unchanged/modified**;远端缺失 MD5 的文件一律进入 `unknown`,绝不会因大小 / mtime 恰好相同而被误报为 unchanged。当前 `list_files` 通常不返回 MD5,因此这类文件多会落在 `unknown`——请据此决定是否用 `pull`/`push` 强制对齐。 - 本地 hash 仅在文件双端都存在、远端有 MD5、且非 `--quick` 模式时才按需计算。 -- 远端条目名称若含 `..`、路径分隔符或盘符等非规范成分,会被直接跳过、不纳入比对(防止逃逸性路径)。 +- 远端文件或文件夹名称若无法安全、无歧义地映射到本地路径(如 `..`、路径分隔符、盘符或目标平台保留名),命令会中止整棵远端树并返回失败;不会静默跳过后继续报告不完整结果。 - 只比对钉盘 `type=file` 的二进制文件;在线文档(docx/sheet/bitable/mindnote/slides)与快捷方式(shortcut)会被跳过。本地只比对常规文件(符号链接、设备文件忽略)。 - `--local-folder` 必须是绝对路径(相对路径会被直接拒绝);`--remote-folder` 必传,是钉盘侧待比对文件夹的 dentryUuid(可用 `dws drive list` 查到)。 @@ -435,7 +435,8 @@ Flags: 注意事项: - 只下载钉盘 `type=file` 的二进制文件;在线文档与快捷方式会被跳过。`rel_path` 始终以 `/` 分隔。 -- 下载目标始终被约束在 `--local-folder` 之内:远端名称含 `..`、路径分隔符或盘符等非规范成分的条目会被跳过,拼接后仍逃逸出根目录的路径记为 `failed`、不会落盘。 +- 下载目标始终被约束在 `--local-folder` 之内:远端名称含 `..`、路径分隔符、盘符或目标平台保留名等不可安全映射成分时,命令会在下载前中止整棵远端树;拼接后仍逃逸出根目录的路径记为 `failed`、不会落盘。 +- 镜像采用跨平台一致的路径等价规则:远端树中若出现 `A/a`、Unicode NFC/NFD 异写,或等价目录前缀下的不同子树,会在任何下载前整批失败,避免不同文件系统得到不一致结果。 - 下载成功后本地文件 mtime 会对齐到远端 `modified_time`,便于后续 `--if-exists smart` 增量同步跳过。 - `summary.failed > 0` 时命令以**非零退出码**退出;结构化 `summary + items` 仍打印在 stdout 上,stderr 只保留简短失败说明。脚本/agent 直接看 exit code 即可判断成败。 @@ -485,6 +486,7 @@ Flags: - 只上传/覆盖 `type=file`;`summary.uploaded` 同时统计新建与覆盖,**不含目录**。 - `overwrite` / `smart` 命中覆盖分支时走**覆盖上传**(`get_upload_info` 与 `commit_upload` 两阶段都携带远端 `overwriteFileId`、不传 `parentId`),在原文件上原地覆盖、保留 fileId,不会在同目录新建重名副本。 - 本地子目录(含空目录)整体镜像:缺失的按需 `create_folder`(以 `folder_created` 留痕),已存在的远端目录复用其 fileId、不重建、不出现在 `items[]`。 +- 本地名称若含反斜杠、控制字符等无法安全映射到钉盘的成分,或双端存在 `A/a`、Unicode NFC/NFD、等价祖先前缀或文件/目录类型歧义,命令会在任何创建或上传前整批失败;不会只跳过冲突项后继续写入。 - `summary.failed > 0` 时命令以**非零退出码**退出;结构化 `summary + items` 仍打印在 stdout 上,脚本/agent 直接看 exit code 判断成败。 ### 本地文件夹与钉盘文件夹双向同步 @@ -514,10 +516,10 @@ Flags: | `skip`(默认) | 两侧都不动,两边内容都保留,计入 `skipped` | | `remote-wins` | 下载远端覆盖本地(需 `--yes`) | | `local-wins` | 覆盖上传本地到远端(原地覆盖、保留 fileId;需 `--yes`) | -| `keep-both` | 本地先改名保留副本(`名.conflict-.扩展名`),再把远端拉到原名;拉取失败会回滚改名、恢复原文件 | +| `keep-both` | 先在同一目录以不覆盖的原子硬链接保留本地副本(`名.conflict-.扩展名`),再把远端拉到原名;拉取失败时原文件与候选副本都保留并报告失败,不做可能误伤并发文件的回滚 | | `ask` | 逐个交互询问;`--dry-run` 或非交互环境下等价于跳过 | -输出 schema(`action`:`downloaded` / `uploaded` / `overwritten` / `folder_created` / `renamed_local` / `skipped` / `failed`;`direction`:`pull` / `push` / `conflict`): +输出 schema(`action`:`downloaded` / `uploaded` / `overwritten` / `folder_created` / `renamed_local` / `skipped` / `failed`;其中 `renamed_local` 是兼容动作名,表示已成功保留本地冲突副本;`direction`:`pull` / `push` / `conflict`): ```json { @@ -540,8 +542,9 @@ Flags: 注意事项: -- 复用 `status`/`pull`/`push` 的全部安全约束:只处理 `type=file`(在线文档、快捷方式跳过);远端名称含 `..`、路径分隔符或盘符等非规范成分会被跳过,拼接后逃逸出 `--local-folder` 的路径记为 `failed` 不落盘;下载走「先写临时文件、成功才原子 rename」,失败绝不破坏本地原文件。 -- `--dry-run` 只算差异并打印 `diff`,不触发任何下载/上传/改名/落盘。 +- 复用 `status`/`pull`/`push` 的全部安全约束:只处理 `type=file`(在线文档、快捷方式跳过);远端名称含 `..`、路径分隔符、盘符或目标平台保留名等不可安全映射成分时会在任何同步写入前中止整棵远端树,拼接后逃逸出 `--local-folder` 的路径记为 `failed` 不落盘;下载走「先写临时文件、成功才原子 rename」,失败绝不破坏本地原文件。 +- `--dry-run` 只算差异并输出独立 JSON 预览对象,不触发任何下载/上传/改名/落盘;差异位于顶层预览对象的 `plan.diff`(同时包含 `dry_run=true`、`executed=false` 与 `preview_kind=plan`)。 +- 双端存在 `A/a`、Unicode NFC/NFD、等价祖先前缀或文件/目录类型歧义时,`sync` 会在任何一侧写入前整批失败;本地无法安全映射到钉盘的名称同样 fail-closed。 - `unknown`(exact 模式远端无可靠 MD5)一律计入 `skipped`、不做任何写操作;需要强制对齐时改用单向的 `pull`/`push`。 - `summary.failed > 0` 时命令以**非零退出码**退出,结构化结果仍打印在 stdout 上。 diff --git a/skills/multi/dingtalk-drive/references/drive.md b/skills/multi/dingtalk-drive/references/drive.md index 04703443..096a3c8a 100644 --- a/skills/multi/dingtalk-drive/references/drive.md +++ b/skills/multi/dingtalk-drive/references/drive.md @@ -374,7 +374,7 @@ Flags: - 默认 `detection=exact`(比较 MD5);传 `--quick` 后 `detection=quick`(只比较 modified_time,best-effort)。 - exact 模式**只在能拿到远端 MD5 时才判定 unchanged/modified**;远端缺失 MD5 的文件一律进入 `unknown`,绝不会因大小 / mtime 恰好相同而被误报为 unchanged。当前 `list_files` 通常不返回 MD5,因此这类文件多会落在 `unknown`——请据此决定是否用 `pull`/`push` 强制对齐。 - 本地 hash 仅在文件双端都存在、远端有 MD5、且非 `--quick` 模式时才按需计算。 -- 远端条目名称若含 `..`、路径分隔符或盘符等非规范成分,会被直接跳过、不纳入比对(防止逃逸性路径)。 +- 远端文件或文件夹名称若无法安全、无歧义地映射到本地路径(如 `..`、路径分隔符、盘符或目标平台保留名),命令会中止整棵远端树并返回失败;不会静默跳过后继续报告不完整结果。 - 只比对钉盘 `type=file` 的二进制文件;在线文档(docx/sheet/bitable/mindnote/slides)与快捷方式(shortcut)会被跳过。本地只比对常规文件(符号链接、设备文件忽略)。 - `--local-folder` 必须是绝对路径(相对路径会被直接拒绝);`--remote-folder` 必传,是钉盘侧待比对文件夹的 dentryUuid(可用 `dws drive list` 查到)。 @@ -420,7 +420,8 @@ Flags: 注意事项: - 只下载钉盘 `type=file` 的二进制文件;在线文档与快捷方式会被跳过。`rel_path` 始终以 `/` 分隔。 -- 下载目标始终被约束在 `--local-folder` 之内:远端名称含 `..`、路径分隔符或盘符等非规范成分的条目会被跳过,拼接后仍逃逸出根目录的路径记为 `failed`、不会落盘。 +- 下载目标始终被约束在 `--local-folder` 之内:远端名称含 `..`、路径分隔符、盘符或目标平台保留名等不可安全映射成分时,命令会在下载前中止整棵远端树;拼接后仍逃逸出根目录的路径记为 `failed`、不会落盘。 +- 镜像采用跨平台一致的路径等价规则:远端树中若出现 `A/a`、Unicode NFC/NFD 异写,或等价目录前缀下的不同子树,会在任何下载前整批失败,避免不同文件系统得到不一致结果。 - 下载成功后本地文件 mtime 会对齐到远端 `modified_time`,便于后续 `--if-exists smart` 增量同步跳过。 - `summary.failed > 0` 时命令以**非零退出码**退出;结构化 `summary + items` 仍打印在 stdout 上,stderr 只保留简短失败说明。脚本/agent 直接看 exit code 即可判断成败。 @@ -470,6 +471,7 @@ Flags: - 只上传/覆盖 `type=file`;`summary.uploaded` 同时统计新建与覆盖,**不含目录**。 - `overwrite` / `smart` 命中覆盖分支时走**覆盖上传**(`get_upload_info` 与 `commit_upload` 两阶段都携带远端 `overwriteFileId`、不传 `parentId`),在原文件上原地覆盖、保留 fileId,不会在同目录新建重名副本。 - 本地子目录(含空目录)整体镜像:缺失的按需 `create_folder`(以 `folder_created` 留痕),已存在的远端目录复用其 fileId、不重建、不出现在 `items[]`。 +- 本地名称若含反斜杠、控制字符等无法安全映射到钉盘的成分,或双端存在 `A/a`、Unicode NFC/NFD、等价祖先前缀或文件/目录类型歧义,命令会在任何创建或上传前整批失败;不会只跳过冲突项后继续写入。 - `summary.failed > 0` 时命令以**非零退出码**退出;结构化 `summary + items` 仍打印在 stdout 上,脚本/agent 直接看 exit code 判断成败。 ### 本地文件夹与钉盘文件夹双向同步 @@ -499,10 +501,10 @@ Flags: | `skip`(默认) | 两侧都不动,两边内容都保留,计入 `skipped` | | `remote-wins` | 下载远端覆盖本地(需 `--yes`) | | `local-wins` | 覆盖上传本地到远端(原地覆盖、保留 fileId;需 `--yes`) | -| `keep-both` | 本地先改名保留副本(`名.conflict-.扩展名`),再把远端拉到原名;拉取失败会回滚改名、恢复原文件 | +| `keep-both` | 先在同一目录以不覆盖的原子硬链接保留本地副本(`名.conflict-.扩展名`),再把远端拉到原名;拉取失败时原文件与候选副本都保留并报告失败,不做可能误伤并发文件的回滚 | | `ask` | 逐个交互询问;`--dry-run` 或非交互环境下等价于跳过 | -输出 schema(`action`:`downloaded` / `uploaded` / `overwritten` / `folder_created` / `renamed_local` / `skipped` / `failed`;`direction`:`pull` / `push` / `conflict`): +输出 schema(`action`:`downloaded` / `uploaded` / `overwritten` / `folder_created` / `renamed_local` / `skipped` / `failed`;其中 `renamed_local` 是兼容动作名,表示已成功保留本地冲突副本;`direction`:`pull` / `push` / `conflict`): ```json { @@ -525,8 +527,9 @@ Flags: 注意事项: -- 复用 `status`/`pull`/`push` 的全部安全约束:只处理 `type=file`(在线文档、快捷方式跳过);远端名称含 `..`、路径分隔符或盘符等非规范成分会被跳过,拼接后逃逸出 `--local-folder` 的路径记为 `failed` 不落盘;下载走「先写临时文件、成功才原子 rename」,失败绝不破坏本地原文件。 -- `--dry-run` 只算差异并打印 `diff`,不触发任何下载/上传/改名/落盘。 +- 复用 `status`/`pull`/`push` 的全部安全约束:只处理 `type=file`(在线文档、快捷方式跳过);远端名称含 `..`、路径分隔符、盘符或目标平台保留名等不可安全映射成分时会在任何同步写入前中止整棵远端树,拼接后逃逸出 `--local-folder` 的路径记为 `failed` 不落盘;下载走「先写临时文件、成功才原子 rename」,失败绝不破坏本地原文件。 +- `--dry-run` 只算差异并输出独立 JSON 预览对象,不触发任何下载/上传/改名/落盘;差异位于顶层预览对象的 `plan.diff`(同时包含 `dry_run=true`、`executed=false` 与 `preview_kind=plan`)。 +- 双端存在 `A/a`、Unicode NFC/NFD、等价祖先前缀或文件/目录类型歧义时,`sync` 会在任何一侧写入前整批失败;本地无法安全映射到钉盘的名称同样 fail-closed。 - `unknown`(exact 模式远端无可靠 MD5)一律计入 `skipped`、不做任何写操作;需要强制对齐时改用单向的 `pull`/`push`。 - `summary.failed > 0` 时命令以**非零退出码**退出,结构化结果仍打印在 stdout 上。 From 35f399e2cf78a72073f5e6a2359d3aa2dd1d5651 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Thu, 13 Aug 2026 16:16:00 +0800 Subject: [PATCH 64/86] fix(chat): use Shanghai time for message defaults --- internal/helpers/chat.go | 8 +++- internal/helpers/chat_message_search_test.go | 43 ++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index c1c7e672..69af617d 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -615,6 +615,10 @@ func formatChatMessageListAllTime(ms int64) string { return time.UnixMilli(ms).Format("2006-01-02 15:04:05") } +func defaultChatMessageListTime() string { + return time.Now().In(shanghaiLocation()).Format("2006-01-02 15:04:05") +} + func chatMessageSearchAdvancedArgs(cmd *cobra.Command) (map[string]any, error) { toolArgs := map[string]any{} if v := flagOrFallback(cmd, "query", "keyword"); v != "" { @@ -2664,7 +2668,7 @@ func newChatCommand() *cobra.Command { timeVal := mustGetFlag(cmd, "time") defaultForward := true if timeVal == "" { - timeVal = time.Now().Format("2006-01-02 15:04:05") + timeVal = defaultChatMessageListTime() defaultForward = false } forward, err := resolveMessageForward(cmd, defaultForward) @@ -2756,7 +2760,7 @@ func newChatCommand() *cobra.Command { timeVal, _ := cmd.Flags().GetString("time") defaultForward := true if strings.TrimSpace(timeVal) == "" { - timeVal = time.Now().Format("2006-01-02 15:04:05") + timeVal = defaultChatMessageListTime() defaultForward = false } forward, err := resolveMessageForward(cmd, defaultForward) diff --git a/internal/helpers/chat_message_search_test.go b/internal/helpers/chat_message_search_test.go index 340a3bd6..44c68aab 100644 --- a/internal/helpers/chat_message_search_test.go +++ b/internal/helpers/chat_message_search_test.go @@ -724,6 +724,49 @@ func TestCrossPlatformCoverageChatMessageListUsesMCPMetadataGroupKey(t *testing. } } +func TestCrossPlatformCoverageChatMessageListDefaultTimeUsesShanghaiLocation(t *testing.T) { + previousLocal := time.Local + time.Local = time.UTC + t.Cleanup(func() { time.Local = previousLocal }) + + tests := []struct { + name string + args []string + }{ + {name: "group", args: []string{"message", "list", "--group", "cid-1", "--limit", "50"}}, + {name: "direct", args: []string{"message", "list-direct", "--user", "user-1", "--limit", "50"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caller := &chatChangedContractCaller{} + before := time.Now() + err := executeChatChangedContract(t, caller, tt.args...) + after := time.Now() + if err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 { + t.Fatalf("calls = %#v, want one MCP call", caller.calls) + } + raw, ok := caller.calls[0].args["time"].(string) + if !ok || raw == "" { + t.Fatalf("time arg = %#v, want non-empty string", caller.calls[0].args["time"]) + } + got, err := time.ParseInLocation("2006-01-02 15:04:05", raw, shanghaiLocation()) + if err != nil { + t.Fatalf("time arg = %q, parse err = %v", raw, err) + } + if got.Before(before.Add(-time.Second)) || got.After(after.Add(time.Second)) { + t.Fatalf("time arg = %q (%s), want current time in Shanghai between %s and %s", raw, got, before, after) + } + if caller.calls[0].args["forward"] != false { + t.Fatalf("forward = %#v, want false when --time is omitted", caller.calls[0].args["forward"]) + } + }) + } +} + func TestCrossPlatformCoverageChatAuditUsesUserIDs(t *testing.T) { caller := &chatChangedContractCaller{} err := executeChatChangedContract(t, caller, From a36189d31eb1f1da10f6601c5715b0c5655ca0be Mon Sep 17 00:00:00 2001 From: chichuan Date: Thu, 13 Aug 2026 16:19:04 +0800 Subject: [PATCH 65/86] docs: seal v1.0.58-beta.6 changelog --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d1d23e1..1809c488 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +## [1.0.58-beta.6] - 2026-08-13 + +### Fixed + +- **npm package verification for multi-skill installs** (#991) — aligns the + release verifier with the installer’s concrete Agent skill-root selection, + preventing valid multi-skill package layouts from failing release delivery. + +### Changed + +- **Release-seal CI classification** (#987) — recognizes the reviewed + CHANGELOG-and-fragment archival shape while retaining release-contract and + lifecycle validation, reducing unrelated CI work for release-seal PRs. + ## [1.0.58-beta.5] - 2026-08-13 ### Added From c3ef04988bfe076fe359f18cb6a05bb331fba687 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:10:23 +0000 Subject: [PATCH 66/86] chore: update beta formula for v1.0.58-beta.6 [skip ci] --- Formula/dingtalk-workspace-cli-beta.rb | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Formula/dingtalk-workspace-cli-beta.rb b/Formula/dingtalk-workspace-cli-beta.rb index 00ad56f7..1ee3c02a 100644 --- a/Formula/dingtalk-workspace-cli-beta.rb +++ b/Formula/dingtalk-workspace-cli-beta.rb @@ -1,33 +1,33 @@ class DingtalkWorkspaceCliBeta < Formula desc "Automate DingTalk workspace tasks from the terminal (beta channel)" homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli" - version "1.0.58-beta.4" + version "1.0.58-beta.6" license "Apache-2.0" keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli" on_macos do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-darwin-arm64.tar.gz" - sha256 "5c2ac92e35b1f1dba80234af8b0c9505b2883f4a37c1e73892b8a1c3087b7702" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-darwin-arm64.tar.gz" + sha256 "8f55497b84113f81b318e087c723016a02eded1cb5784cbd0811fe527d5852ca" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-darwin-amd64.tar.gz" - sha256 "93ef787770105fe1f0d27585adcac7b740aa6c37ff490275c4113814541ae095" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-darwin-amd64.tar.gz" + sha256 "b1762f1640310fb4100634fe54d9baace46384bb270c59148fe306275554d491" end end on_linux do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-linux-arm64.tar.gz" - sha256 "011ce16a73d8fd24275e34c3122d3d0832c60cde2480f496018eb654059b5c05" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-linux-arm64.tar.gz" + sha256 "55393310ef0e1f24ea2c0dc22f00c0eb3b343edc2deb18d0db7838cda65af25d" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-linux-amd64.tar.gz" - sha256 "847b17ff8a8d80dce38f0013eb35c77c102be16c9f98b955a632b983cd5ec104" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-linux-amd64.tar.gz" + sha256 "3830f77d09b4da4aa39f0c08d772ff3fa1ffb837eb15bb417f97edbccb073b7d" end end resource "skills" do - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-skills.zip" - sha256 "f5e0c72cc92cb7e8886409319cf68bbbfc7740e969bd39a389b74af4befdbc66" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-skills.zip" + sha256 "f304a883a4f9e938b26a44692cd5a8d3d8704ba70ee7f33ba7c288434da72b6f" end def install From a32608f96400fcba8aa2af35b84c3341b6122e04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Thu, 13 Aug 2026 17:38:35 +0800 Subject: [PATCH 67/86] fix(chat): use local time for message defaults --- internal/helpers/chat.go | 23 +++++++++++++++++-- .../helpers/chat_message_pagination_test.go | 22 ++++++++++++++++-- internal/helpers/chat_message_search_test.go | 6 ++--- 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 69af617d..5950772a 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -467,7 +467,7 @@ func validateNativeSearchConversationScope(conversationIDs []string) error { } func chatMessageListAllArgs(cmd *cobra.Command) (map[string]any, error) { - startRaw, endRaw, err := defaultChatMessageTimeRange(cmd, 24*time.Hour) + startRaw, endRaw, err := defaultChatMessageListAllTimeRange(cmd, 24*time.Hour) if err != nil { return nil, err } @@ -480,6 +480,25 @@ func chatMessageListAllArgs(cmd *cobra.Command) (map[string]any, error) { }, nil } +func defaultChatMessageListAllTimeRange(cmd *cobra.Command, lookback time.Duration) (string, string, error) { + startRaw := mustGetFlag(cmd, "start") + endRaw := mustGetFlag(cmd, "end") + anchor := time.Now() + if endRaw == "" { + endRaw = formatChatMessageListAllTime(anchor.UnixMilli()) + } else if startRaw == "" { + endMs, err := parseISOTimeToMillis("end", endRaw) + if err != nil { + return "", "", err + } + anchor = time.UnixMilli(endMs) + } + if startRaw == "" { + startRaw = formatChatMessageListAllTime(anchor.Add(-lookback).UnixMilli()) + } + return startRaw, endRaw, nil +} + func chatMessageListBySenderArgs(cmd *cobra.Command) (map[string]any, error) { senderUserID := flagOrFallback(cmd, "sender-user-id", "sender") senderOpenDingTalkID, _ := cmd.Flags().GetString("sender-open-dingtalk-id") @@ -616,7 +635,7 @@ func formatChatMessageListAllTime(ms int64) string { } func defaultChatMessageListTime() string { - return time.Now().In(shanghaiLocation()).Format("2006-01-02 15:04:05") + return time.Now().Format("2006-01-02 15:04:05") } func chatMessageSearchAdvancedArgs(cmd *cobra.Command) (map[string]any, error) { diff --git a/internal/helpers/chat_message_pagination_test.go b/internal/helpers/chat_message_pagination_test.go index f373e130..e444eb5b 100644 --- a/internal/helpers/chat_message_pagination_test.go +++ b/internal/helpers/chat_message_pagination_test.go @@ -210,6 +210,10 @@ func TestChatMessagePaginationUsesDefaultTimeWindows(t *testing.T) { if startMs < wantStartMin || startMs > wantStartMax { t.Fatalf("startTime = %d, want between %d and %d", startMs, wantStartMin, wantStartMax) } + if tt.wantTool == "search_messages_by_time_range" { + assertChatMessageListAllTimeFormat(t, got.args["startTime"]) + assertChatMessageListAllTimeFormat(t, got.args["endTime"]) + } }) } } @@ -276,7 +280,7 @@ func TestChatMessagePaginationDefaultsStartFromExplicitEnd(t *testing.T) { t.Fatalf("startTime = %d, want %d", startMs, wantStartMs) } if tt.wantTool == "search_messages_by_time_range" { - assertStringArg(t, got.args["startTime"], time.UnixMilli(wantStartMs).Format(time.RFC3339)) + assertStringArg(t, got.args["startTime"], formatChatMessageListAllTime(wantStartMs)) assertStringArg(t, got.args["endTime"], endRaw) } }) @@ -344,7 +348,7 @@ func TestChatMessagePaginationDefaultsEndFromNowWhenOnlyStartProvided(t *testing } if tt.wantTool == "search_messages_by_time_range" { assertStringArg(t, got.args["startTime"], startRaw) - parseChatMessageListAllTimeArg(t, got.args["endTime"]) + assertChatMessageListAllTimeFormat(t, got.args["endTime"]) } }) } @@ -440,6 +444,20 @@ func parseChatMessageListAllTimeArg(t *testing.T, value any) time.Time { return parsed } +func assertChatMessageListAllTimeFormat(t *testing.T, value any) { + t.Helper() + raw, ok := value.(string) + if !ok { + t.Fatalf("time arg = %#v, want time string", value) + } + if strings.Contains(raw, "T") { + t.Fatalf("time arg = %q, want yyyy-MM-dd HH:mm:ss without RFC3339 separator", raw) + } + if _, err := time.ParseInLocation("2006-01-02 15:04:05", raw, time.Local); err != nil { + t.Fatalf("time arg = %q, parse err = %v", raw, err) + } +} + func assertStringArg(t *testing.T, value any, want string) { t.Helper() got, ok := value.(string) diff --git a/internal/helpers/chat_message_search_test.go b/internal/helpers/chat_message_search_test.go index 44c68aab..eeec924e 100644 --- a/internal/helpers/chat_message_search_test.go +++ b/internal/helpers/chat_message_search_test.go @@ -724,7 +724,7 @@ func TestCrossPlatformCoverageChatMessageListUsesMCPMetadataGroupKey(t *testing. } } -func TestCrossPlatformCoverageChatMessageListDefaultTimeUsesShanghaiLocation(t *testing.T) { +func TestCrossPlatformCoverageChatMessageListDefaultTimeUsesLocalLocation(t *testing.T) { previousLocal := time.Local time.Local = time.UTC t.Cleanup(func() { time.Local = previousLocal }) @@ -753,12 +753,12 @@ func TestCrossPlatformCoverageChatMessageListDefaultTimeUsesShanghaiLocation(t * if !ok || raw == "" { t.Fatalf("time arg = %#v, want non-empty string", caller.calls[0].args["time"]) } - got, err := time.ParseInLocation("2006-01-02 15:04:05", raw, shanghaiLocation()) + got, err := time.ParseInLocation("2006-01-02 15:04:05", raw, time.Local) if err != nil { t.Fatalf("time arg = %q, parse err = %v", raw, err) } if got.Before(before.Add(-time.Second)) || got.After(after.Add(time.Second)) { - t.Fatalf("time arg = %q (%s), want current time in Shanghai between %s and %s", raw, got, before, after) + t.Fatalf("time arg = %q (%s), want current local time between %s and %s", raw, got, before, after) } if caller.calls[0].args["forward"] != false { t.Fatalf("forward = %#v, want false when --time is omitted", caller.calls[0].args["forward"]) From 18fad57bbe0c37f0ffd9e35bb66ae439e8ec3698 Mon Sep 17 00:00:00 2001 From: chichuan Date: Thu, 13 Aug 2026 17:44:56 +0800 Subject: [PATCH 68/86] docs: seal changelog for v1.0.58 --- CHANGELOG.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1809c488..0656ba9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,30 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +## [1.0.58] - 2026-08-13 + +This release promotes the sealed `v1.0.58-beta.6` contents to stable. + +### Changed + +- **Expanded collaborative workflows** — adds full AI Table, Sheet, Minutes, + approval-event, Drive-comment, document export, and CSV workflow support, + including safer validation, explicit confirmation for writes, and + machine-readable completion receipts. +- **More capable Chat operations** — adds robot image/file messages, toolbar + management, streaming-card mentions, automatic pagination controls, and + clearer post-send ID, Markdown-image, paging, and result-shape guidance. +- **Reliable Agent and CLI contracts** — expands Agent-visible Chat and + Minutes commands, aligns bundled skills, improves schema/result envelopes, + and hardens parameter, pagination, runtime-token, and write-result + verification so ambiguous or incomplete operations fail closed. +- **Multi-skill install and upgrade** — makes the multi-skill layout the + default for fresh installs and upgrades while preserving an explicit legacy + mono option. +- **Safer release delivery** — strengthens release-equivalent compatibility, + sealing, package verification, and evaluation-dispatch checks for more + reliable cross-platform releases. + ## [1.0.58-beta.6] - 2026-08-13 ### Fixed From 90278ab2fc69215cf41942e405d9774b80193750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Thu, 13 Aug 2026 18:01:57 +0800 Subject: [PATCH 69/86] test(chat): cover message default end window --- internal/helpers/chat_command_edges_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/helpers/chat_command_edges_test.go b/internal/helpers/chat_command_edges_test.go index 2c97ce06..93322ff4 100644 --- a/internal/helpers/chat_command_edges_test.go +++ b/internal/helpers/chat_command_edges_test.go @@ -180,6 +180,7 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T) {"message", "list-by-sender", "--sender-user-id=u1", "--start=bad"}, {"message", "list-by-sender", "--sender-user-id=u1", "--start=2026-01-01T00:00:00Z", "--end=bad"}, {"message", "list-by-sender", "--sender-user-id=u1", "--start=2026-01-02T00:00:00Z", "--end=2026-01-01T00:00:00Z"}, + {"message", "list-by-sender", "--sender-user-id=u1", "--end=2026-01-02T00:00:00Z"}, {"message", "list-by-sender", "--sender-user-id=u1", "--start=2026-01-01T00:00:00Z", "--end=2026-01-02T00:00:00Z"}, {"message", "list-by-sender", "--sender-open-dingtalk-id=D1", "--start=2026-01-01T00:00:00Z"}, {"message", "list-mentions", "--start=bad", "--end=2026-01-02T00:00:00Z"}, From 973671bdf18d8b8b4bba82906e165e872d66efb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=B7=E5=BF=83=E8=8F=9C?= Date: Thu, 13 Aug 2026 18:36:38 +0800 Subject: [PATCH 70/86] chore: trigger auto CR re-review From 14a21754348fc58a417a38e3f9f83bf0c75843b4 Mon Sep 17 00:00:00 2001 From: chichuan Date: Thu, 13 Aug 2026 19:04:57 +0800 Subject: [PATCH 71/86] fix(ci): preserve delivered stable compatibility baseline --- .github/workflows/release.yml | 34 ++++++++ test/scripts/package_script_test.go | 130 ++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08e83fd8..8ee47b14 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2645,6 +2645,40 @@ jobs: "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \ "$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT" + # The sealed candidate tag is intentionally visible while its GitHub + # authority is checked above. Compatibility must instead discover the + # previous delivered stable tag, so hide only this verified candidate + # from this isolated runner's local tag namespace. + - name: Prepare delivered-stable compatibility ref view + if: ${{ matrix.check == 'compatibility' }} + env: + RELEASE_VERSION: ${{ needs.release-contract.outputs.release_version }} + RELEASE_COMMIT: ${{ needs.release-contract.outputs.release_commit }} + RELEASE_TAG_OBJECT: ${{ needs.release-contract.outputs.release_tag_object }} + PREVIOUS_STABLE: ${{ needs.release-contract.outputs.previous_stable }} + PREVIOUS_STABLE_COMMIT: ${{ needs.release-contract.outputs.previous_stable_commit }} + run: | + set -eu + test -n "$RELEASE_VERSION" + test -n "$RELEASE_COMMIT" + test -n "$RELEASE_TAG_OBJECT" + test -n "$PREVIOUS_STABLE" + test -n "$PREVIOUS_STABLE_COMMIT" + test "$RELEASE_VERSION" != "$PREVIOUS_STABLE" + test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT" + test "$(git rev-parse --verify "refs/tags/${RELEASE_VERSION}")" = "$RELEASE_TAG_OBJECT" + test "$(git rev-parse --verify "refs/tags/${RELEASE_VERSION}^{commit}")" = "$RELEASE_COMMIT" + test "$(git rev-parse --verify "${PREVIOUS_STABLE}^{commit}")" = "$PREVIOUS_STABLE_COMMIT" + + git update-ref -d "refs/tags/${RELEASE_VERSION}" "$RELEASE_TAG_OBJECT" + + if git show-ref --verify --quiet "refs/tags/${RELEASE_VERSION}"; then + echo "sealed candidate tag is still visible to compatibility baseline discovery" >&2 + exit 2 + fi + test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT" + test "$(git rev-parse --verify "${PREVIOUS_STABLE}^{commit}")" = "$PREVIOUS_STABLE_COMMIT" + - name: Set up Go uses: actions/setup-go@v5 with: diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index 62b51c6c..fd3746ba 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -1222,6 +1222,136 @@ func TestReleaseWorkflowParallelizesSealedValidationWithoutWeakeningPublication( } } +func TestReleaseWorkflowHidesOnlyVerifiedSealedTagFromCompatibilityBaseline(t *testing.T) { + t.Parallel() + workflow := readReleaseWorkflow(t) + validation := releaseWorkflowSection(t, workflow, " release-validation:\n", "\n release-plan:\n") + preparedView := releaseWorkflowSection( + t, + validation, + " - name: Prepare delivered-stable compatibility ref view\n", + "\n - name: Set up Go\n", + ) + script := releaseWorkflowRunScript( + t, + validation, + "Prepare delivered-stable compatibility ref view", + "Set up Go", + ) + + if !strings.Contains(preparedView, "if: ${{ matrix.check == 'compatibility' }}") { + t.Fatal("delivered-stable ref view must run only for the compatibility matrix leg") + } + for _, required := range []string{ + "RELEASE_VERSION: ${{ needs.release-contract.outputs.release_version }}", + "RELEASE_COMMIT: ${{ needs.release-contract.outputs.release_commit }}", + "RELEASE_TAG_OBJECT: ${{ needs.release-contract.outputs.release_tag_object }}", + "PREVIOUS_STABLE: ${{ needs.release-contract.outputs.previous_stable }}", + "PREVIOUS_STABLE_COMMIT: ${{ needs.release-contract.outputs.previous_stable_commit }}", + } { + if !strings.Contains(preparedView, required) { + t.Errorf("delivered-stable compatibility view is missing %q", required) + } + } + for _, required := range []string{ + `test "$RELEASE_VERSION" != "$PREVIOUS_STABLE"`, + `test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT"`, + `test "$(git rev-parse --verify "refs/tags/${RELEASE_VERSION}")" = "$RELEASE_TAG_OBJECT"`, + `test "$(git rev-parse --verify "refs/tags/${RELEASE_VERSION}^{commit}")" = "$RELEASE_COMMIT"`, + `test "$(git rev-parse --verify "${PREVIOUS_STABLE}^{commit}")" = "$PREVIOUS_STABLE_COMMIT"`, + `git update-ref -d "refs/tags/${RELEASE_VERSION}" "$RELEASE_TAG_OBJECT"`, + `git show-ref --verify --quiet "refs/tags/${RELEASE_VERSION}"`, + } { + if !strings.Contains(script, required) { + t.Errorf("delivered-stable compatibility view is missing %q", required) + } + } + + verifiedTag := strings.Index(validation, "verify-github-tag-authority.sh") + deleteLocalTag := strings.Index(validation, "git update-ref -d") + compatibility := strings.Index(validation, "check-command-compatibility.sh") + if verifiedTag == -1 || deleteLocalTag == -1 || compatibility == -1 || + verifiedTag > deleteLocalTag || deleteLocalTag > compatibility { + t.Fatal("release tag authority verification, local candidate removal, and compatibility checking must stay ordered") + } + for _, forbidden := range []string{"git push --delete", "github.rest.git.deleteRef"} { + if strings.Contains(workflow, forbidden) { + t.Errorf("release workflow must not delete a remote ref: found %q", forbidden) + } + } + + repo, err := os.MkdirTemp(".", ".release-compatibility-test-") + if err != nil { + t.Fatalf("MkdirTemp(release compatibility test repository) error = %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(repo); err != nil { + t.Errorf("RemoveAll(%s) error = %v", repo, err) + } + }) + mustRun(t, repo, "git", "init", "-b", "main") + mustRun(t, repo, "git", "config", "user.name", "Release Compatibility Test") + mustRun(t, repo, "git", "config", "user.email", "release-compatibility@example.com") + mustWriteFile(t, filepath.Join(repo, "tracked"), []byte("stable\n"), 0o644) + mustRun(t, repo, "git", "add", "tracked") + mustRun(t, repo, "git", "commit", "-m", "stable release") + previousStableCommit := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD")) + mustRun(t, repo, "git", "tag", "-a", "v1.0.57", "-m", "v1.0.57") + mustWriteFile(t, filepath.Join(repo, "tracked"), []byte("sealed\n"), 0o644) + mustRun(t, repo, "git", "commit", "-am", "sealed release") + releaseCommit := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD")) + mustRun(t, repo, "git", "tag", "-a", "v1.0.58", "-m", "v1.0.58") + releaseTagObject := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "refs/tags/v1.0.58")) + + run := func(tagObject string) ([]byte, error) { + cmd := exec.Command("sh", "-c", script) + cmd.Dir = repo + cmd.Env = make([]string, 0, len(os.Environ())+5) + for _, entry := range os.Environ() { + if strings.HasPrefix(entry, "RELEASE_VERSION=") || + strings.HasPrefix(entry, "RELEASE_COMMIT=") || + strings.HasPrefix(entry, "RELEASE_TAG_OBJECT=") || + strings.HasPrefix(entry, "PREVIOUS_STABLE=") || + strings.HasPrefix(entry, "PREVIOUS_STABLE_COMMIT=") { + continue + } + cmd.Env = append(cmd.Env, entry) + } + cmd.Env = append(cmd.Env, + "RELEASE_VERSION=v1.0.58", + "RELEASE_COMMIT="+releaseCommit, + "RELEASE_TAG_OBJECT="+tagObject, + "PREVIOUS_STABLE=v1.0.57", + "PREVIOUS_STABLE_COMMIT="+previousStableCommit, + ) + return cmd.CombinedOutput() + } + + if output, err := run(releaseTagObject); err != nil { + t.Fatalf("prepared compatibility ref view failed: %v\noutput:\n%s", err, output) + } + candidateTag := exec.Command("git", "rev-parse", "--verify", "refs/tags/v1.0.58") + candidateTag.Dir = repo + if _, err := candidateTag.CombinedOutput(); err == nil { + t.Fatal("prepared compatibility ref view left the sealed candidate tag visible") + } + mustRun(t, repo, "git", "cat-file", "-e", releaseTagObject+"^{tag}") + if got := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "refs/tags/v1.0.57^{commit}")); got != previousStableCommit { + t.Fatalf("previous stable tag commit = %s, want %s", got, previousStableCommit) + } + if got := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD")); got != releaseCommit { + t.Fatalf("HEAD after prepared compatibility ref view = %s, want %s", got, releaseCommit) + } + + mustRun(t, repo, "git", "update-ref", "refs/tags/v1.0.58", releaseTagObject) + if output, err := run(strings.Repeat("0", 40)); err == nil { + t.Fatalf("prepared compatibility ref view unexpectedly accepted a mismatched tag object\noutput:\n%s", output) + } + if got := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "refs/tags/v1.0.58")); got != releaseTagObject { + t.Fatalf("mismatched tag object removed or changed the local sealed tag: got %s, want %s", got, releaseTagObject) + } +} + func TestReleaseFingerprintRefPatternsMatchAllAllocatedTags(t *testing.T) { t.Parallel() repo := t.TempDir() From 91090a13b9a0adff8fbf419ad1141bb54ed37e99 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:51:41 +0000 Subject: [PATCH 72/86] chore: update formula for v1.0.58 [skip ci] --- Formula/dingtalk-workspace-cli.rb | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Formula/dingtalk-workspace-cli.rb b/Formula/dingtalk-workspace-cli.rb index fffbf6c0..46d064f7 100644 --- a/Formula/dingtalk-workspace-cli.rb +++ b/Formula/dingtalk-workspace-cli.rb @@ -1,33 +1,33 @@ class DingtalkWorkspaceCli < Formula desc "Automate DingTalk workspace tasks from the terminal" homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli" - version "1.0.57" + version "1.0.58" license "Apache-2.0" on_macos do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-darwin-arm64.tar.gz" - sha256 "c01c28dc13948a70fca905207073dc8dbd22f7ba7fc90e68b3316eb9a9c98e88" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-darwin-arm64.tar.gz" + sha256 "7d98599f90cae9d42b51ff2863efc87dbfb4a3176ff3c84fc2216110c0157a70" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-darwin-amd64.tar.gz" - sha256 "d7baa218beefc851c6a933b456055195f8272984ce008d7e0122bdfc5dad94ea" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-darwin-amd64.tar.gz" + sha256 "4c12e35e5bf7e0905812cd42dc94a5345068a2c16e306bb50b13c5c78b5cb95d" end end on_linux do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-linux-arm64.tar.gz" - sha256 "0bbe9c233a3ff585077bae1ac5000937c32d967846d14cc44c46f98d49b95ae2" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-arm64.tar.gz" + sha256 "5ef6bde24bc3db6a11a0f1d0b3343a048956b2cbcf6cd3409a037fb6ba425489" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-linux-amd64.tar.gz" - sha256 "f113ce3654f21d1f9ecc7c196f815aeafbca54d377a347b244a15116c5cba698" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-amd64.tar.gz" + sha256 "3ccadcc6f070a39d2b2ba20429a4fcdc2f21639bf79f34361dc7d16f501bfda6" end end resource "skills" do - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-skills.zip" - sha256 "0c9667209cf30761427a8f9348149cbbf1e397aa3c25587e99f205bc7525e101" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-skills.zip" + sha256 "2626debc21c3daadfd155b4c167b2219b97e801398fe4441a8b48138960ab264" end def install From 504db23823695bcf0d7ac6df36776144bae70f19 Mon Sep 17 00:00:00 2001 From: chichuan Date: Thu, 13 Aug 2026 20:55:06 +0800 Subject: [PATCH 73/86] test(drive): cover platform-only branches missed by the platform coverage gate The platform coverage gate runs only TestAllShortcuts and TestCrossPlatformCoverage*, so several changed statements had no platform test exercising them: - drive_pull.go: the smart-policy re-check that skips publication when the target is refreshed in place (same inode) while the download is running. - drive_pull.go: the post-publish verifyParent failure, where the result is already on disk and must not be rolled back. - drive_replace_unix.go: rename(2) replacement of an existing target; the Windows side already had the symmetric test. - drive_status_windows.go: the filepath.Clean rewrite guard had no input reaching it, because isSafeRemoteSegment filters separators upstream. macOS changed-code coverage: 99.8053% -> 100.0000% (1541 statements). --- .../drive_pull_publish_recheck_test.go | 91 +++++++++++++++++++ internal/helpers/drive_replace_unix_test.go | 34 +++++++ internal/helpers/drive_sync_platform_test.go | 3 + 3 files changed, 128 insertions(+) create mode 100644 internal/helpers/drive_pull_publish_recheck_test.go create mode 100644 internal/helpers/drive_replace_unix_test.go diff --git a/internal/helpers/drive_pull_publish_recheck_test.go b/internal/helpers/drive_pull_publish_recheck_test.go new file mode 100644 index 00000000..ce00ff03 --- /dev/null +++ b/internal/helpers/drive_pull_publish_recheck_test.go @@ -0,0 +1,91 @@ +package helpers + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +// smart 策略在入口按固定的 initialInfo 判定「本地落后、需要下载」,但远端读与落盘 +// 期间同一 inode 的 mtime 仍可能被并发写者推到 ≥ 远端时间。此时发布会用旧的远端版本 +// 覆盖更新的本地内容,因此必须在发布前按当前状态二次判定并跳过。 +func TestCrossPlatformCoverageDrivePullSmartSkipsWhenTargetCatchesUpDuringDownload(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "a.txt") + const remoteMillis int64 = 2_000_000 + if err := os.WriteFile(target, []byte("local"), 0o600); err != nil { + t.Fatal(err) + } + // 入口处的 mtime 必须严格早于远端,否则在发出远端读之前就已跳过。 + stale := time.UnixMilli(remoteMillis - 60_000) + if err := os.Chtimes(target, stale, stale); err != nil { + t.Fatal(err) + } + installPinnedPullSuccess(t) + testseam.Swap(t, &pullDownloadFile, func(_ context.Context, _ string, _ map[string]string, destination *os.File) error { + if _, err := destination.WriteString("remote"); err != nil { + return err + } + // 就地改时间:inode 不变,所以身份比较仍然成立,只有时间判定能拦下发布。 + fresh := time.UnixMilli(remoteMillis + 60_000) + return os.Chtimes(target, fresh, fresh) + }) + + action, err := pullOneFileAtRoot(context.Background(), "", &remoteFile{ + FileID: "F", + ModifiedTime: remoteMillis, + ModifiedTimeValid: true, + }, root, "a.txt", ifExistsSmart) + if err != nil { + t.Fatalf("pull = %v", err) + } + if action != pullActionSkipped { + t.Fatalf("action = %q, want %q", action, pullActionSkipped) + } + if got, err := os.ReadFile(target); err != nil || string(got) != "local" { + t.Fatalf("target = %q, err=%v; 追平后的本地内容必须保留", got, err) + } +} + +// 发布成功后固定目录仍可能被移走。此时结果已经落盘,门禁只能报失败而不能回删—— +// 同名 terminal entry 可能已被并发替换,二次 Lstat→Remove 会误删他人的文件。 +func TestCrossPlatformCoverageDrivePullFailsWhenPinnedRootMovesAfterPublish(t *testing.T) { + root := t.TempDir() + installPinnedPullSuccess(t) + decoy := t.TempDir() + decoyInfo, err := os.Stat(decoy) + if err != nil { + t.Fatal(err) + } + published := false + testseam.Swap(t, &pullRename, func(parent *os.Root, oldName, newName string) error { + if err := parent.Rename(oldName, newName); err != nil { + return err + } + published = true + return nil + }) + // 只在发布之后让根目录身份不匹配:下载前与发布前的两次核对都必须先通过。 + testseam.Swap(t, &pullPathStat, func(path string) (os.FileInfo, error) { + if published { + return decoyInfo, nil + } + return os.Stat(path) + }) + + action, err := pullOneFileAtRoot(context.Background(), "", &remoteFile{FileID: "F"}, root, "a.txt", ifExistsOverwrite) + if err == nil || !strings.Contains(err.Error(), "本地根目录在同步期间被替换") { + t.Fatalf("error = %v", err) + } + if action != pullActionFailed { + t.Fatalf("action = %q, want %q", action, pullActionFailed) + } + if got, err := os.ReadFile(filepath.Join(root, "a.txt")); err != nil || string(got) != "remote" { + t.Fatalf("published target = %q, err=%v; 已发布的结果不得被回删", got, err) + } +} diff --git a/internal/helpers/drive_replace_unix_test.go b/internal/helpers/drive_replace_unix_test.go new file mode 100644 index 00000000..70a5b94e --- /dev/null +++ b/internal/helpers/drive_replace_unix_test.go @@ -0,0 +1,34 @@ +//go:build !windows + +package helpers + +import ( + "os" + "path/filepath" + "testing" +) + +// Unix 的 rename(2) 原生提供原子替换语义;该路径与 Windows 的 +// MOVEFILE_REPLACE_EXISTING 同样服务 pull overwrite、sync remote-wins 与 +// keep-both 的占位文件替换,因此两侧都需要平台覆盖。 +func TestCrossPlatformCoverageDriveReplaceFileUnix_replacesExistingTarget(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source.tmp") + target := filepath.Join(dir, "target.txt") + if err := os.WriteFile(source, []byte("new"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + + if err := driveReplaceFile(source, target); err != nil { + t.Fatalf("replace existing target: %v", err) + } + if got, err := os.ReadFile(target); err != nil || string(got) != "new" { + t.Fatalf("target = %q, err=%v; want new content", got, err) + } + if _, err := os.Stat(source); !os.IsNotExist(err) { + t.Fatalf("source must be moved, stat err=%v", err) + } +} diff --git a/internal/helpers/drive_sync_platform_test.go b/internal/helpers/drive_sync_platform_test.go index d7f19fb3..2fa85b72 100644 --- a/internal/helpers/drive_sync_platform_test.go +++ b/internal/helpers/drive_sync_platform_test.go @@ -108,6 +108,9 @@ func TestCrossPlatformCoverageIsSafeRemoteSegmentPlatform_rejectsNonCanonicalWin for _, name := range []string{ `C:relative`, `a?.txt`, `a*.txt`, `a:stream`, `a<.txt`, `a>.txt`, `a|.txt`, `a".txt`, "name.", "name ", "...", "CON", "con.txt", "PRN", "AUX", "NUL", "COM1", "LPT9", "COM¹", "CONIN$", + // 调用方 isSafeRemoteSegment 已先过滤分隔符,所以只有直接调用才会走到 + // filepath.Clean 改写这条防御分支;平台函数本身仍必须 fail closed。 + `a/b`, `.\a`, } { if isSafeRemoteSegmentPlatform(name) { t.Errorf("Windows unsafe segment %q must be rejected", name) From c3ffb9c831d4bce71e6050aab4c1c9c705f03ec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Thu, 13 Aug 2026 20:56:46 +0800 Subject: [PATCH 74/86] fix(chat): validate list-all time defaults --- .changes/973-chat-message-time-defaults.md | 2 +- internal/helpers/chat.go | 13 ++- internal/helpers/chat_command_edges_test.go | 1 + .../helpers/chat_message_pagination_test.go | 83 +++++++++++++------ 4 files changed, 72 insertions(+), 27 deletions(-) diff --git a/.changes/973-chat-message-time-defaults.md b/.changes/973-chat-message-time-defaults.md index fbe6ba2c..09da6854 100644 --- a/.changes/973-chat-message-time-defaults.md +++ b/.changes/973-chat-message-time-defaults.md @@ -2,4 +2,4 @@ category: Fixed --- -- **Chat message time defaults** (#973) — defaults omitted message-query time bounds without creating reversed windows. +- **Chat message time defaults** (#973) — default omitted `chat message list-all` time bounds in `Asia/Shanghai` when emitting timezone-less `yyyy-MM-dd HH:mm:ss` values, matching parsing semantics and rejecting reversed windows. diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 5950772a..0a3a9b27 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -471,6 +471,17 @@ func chatMessageListAllArgs(cmd *cobra.Command) (map[string]any, error) { if err != nil { return nil, err } + startMs, err := parseISOTimeToMillis("start", startRaw) + if err != nil { + return nil, err + } + endMs, err := parseISOTimeToMillis("end", endRaw) + if err != nil { + return nil, err + } + if err := validateTimeRange(startMs, endMs); err != nil { + return nil, err + } cursor, _ := cmd.Flags().GetString("cursor") return map[string]any{ "startTime": startRaw, @@ -631,7 +642,7 @@ func defaultChatMessageTimeRange(cmd *cobra.Command, lookback time.Duration) (st } func formatChatMessageListAllTime(ms int64) string { - return time.UnixMilli(ms).Format("2006-01-02 15:04:05") + return time.UnixMilli(ms).In(shanghaiLocation()).Format("2006-01-02 15:04:05") } func defaultChatMessageListTime() string { diff --git a/internal/helpers/chat_command_edges_test.go b/internal/helpers/chat_command_edges_test.go index 93322ff4..a5f0bacd 100644 --- a/internal/helpers/chat_command_edges_test.go +++ b/internal/helpers/chat_command_edges_test.go @@ -177,6 +177,7 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T) {"message", "list-all", "--start=not-a-time", "--end=2026-01-02T00:00:00Z"}, {"message", "list-all", "--start=2026-01-01 00:00:00", "--end=bad"}, {"message", "list-all", "--start=2026-01-02 00:00:00", "--end=2026-01-01 00:00:00"}, + {"message", "list-all", "--start=2027-01-01 00:00:00"}, {"message", "list-by-sender", "--sender-user-id=u1", "--start=bad"}, {"message", "list-by-sender", "--sender-user-id=u1", "--start=2026-01-01T00:00:00Z", "--end=bad"}, {"message", "list-by-sender", "--sender-user-id=u1", "--start=2026-01-02T00:00:00Z", "--end=2026-01-01T00:00:00Z"}, diff --git a/internal/helpers/chat_message_pagination_test.go b/internal/helpers/chat_message_pagination_test.go index e444eb5b..e947f489 100644 --- a/internal/helpers/chat_message_pagination_test.go +++ b/internal/helpers/chat_message_pagination_test.go @@ -287,6 +287,40 @@ func TestChatMessagePaginationDefaultsStartFromExplicitEnd(t *testing.T) { } } +func TestChatMessageListAllDefaultStartFromTimezoneLessEndUsesShanghai(t *testing.T) { + previousLocal := time.Local + t.Cleanup(func() { time.Local = previousLocal }) + + for _, loc := range []*time.Location{time.UTC, time.FixedZone("EST", -5*3600)} { + t.Run(loc.String(), func(t *testing.T) { + time.Local = loc + caller := &chatMessagePaginationCaller{} + _, err := executeChatMessagePaginationCommand(t, caller, "message", "list-all", "--end", "2026-03-01 00:00:00") + if err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 { + t.Fatalf("calls = %#v, want one call", caller.calls) + } + got := caller.calls[0] + assertStringArg(t, got.args["endTime"], "2026-03-01 00:00:00") + assertStringArg(t, got.args["startTime"], "2026-02-28 00:00:00") + + startMs, err := parseISOTimeToMillis("start", got.args["startTime"].(string)) + if err != nil { + t.Fatal(err) + } + endMs, err := parseISOTimeToMillis("end", got.args["endTime"].(string)) + if err != nil { + t.Fatal(err) + } + if gotWindow := time.Duration(endMs-startMs) * time.Millisecond; gotWindow != 24*time.Hour { + t.Fatalf("window = %v, want 24h", gotWindow) + } + }) + } +} + func TestChatMessagePaginationDefaultsEndFromNowWhenOnlyStartProvided(t *testing.T) { startRaw := "2026-01-01T00:00:00+08:00" startMs, err := parseISOTimeToMillis("start", startRaw) @@ -354,24 +388,26 @@ func TestChatMessagePaginationDefaultsEndFromNowWhenOnlyStartProvided(t *testing } } -func TestChatMessageListAllPassesExplicitTimeBoundsThrough(t *testing.T) { +func TestChatMessageListAllRejectsInvalidTimeBounds(t *testing.T) { tests := []struct { - name string - args []string - wantStart string - wantEnd string + name string + args []string + wantErr string }{ { - name: "invalid start", - args: []string{"message", "list-all", "--start", "not-a-time", "--end", "2020-01-01T00:00:00+08:00"}, - wantStart: "not-a-time", - wantEnd: "2020-01-01T00:00:00+08:00", + name: "invalid start", + args: []string{"message", "list-all", "--start", "not-a-time", "--end", "2020-01-01T00:00:00+08:00"}, + wantErr: "cannot parse time for --start", }, { - name: "end before start", - args: []string{"message", "list-all", "--start", "2021-01-01T00:00:00+08:00", "--end", "2020-01-01T00:00:00+08:00"}, - wantStart: "2021-01-01T00:00:00+08:00", - wantEnd: "2020-01-01T00:00:00+08:00", + name: "end before start", + args: []string{"message", "list-all", "--start", "2021-01-01T00:00:00+08:00", "--end", "2020-01-01T00:00:00+08:00"}, + wantErr: "--end must be after --start", + }, + { + name: "default end before future start", + args: []string{"message", "list-all", "--start", "2027-01-01 00:00:00"}, + wantErr: "--end must be after --start", }, } @@ -379,18 +415,15 @@ func TestChatMessageListAllPassesExplicitTimeBoundsThrough(t *testing.T) { t.Run(tt.name, func(t *testing.T) { caller := &chatMessagePaginationCaller{} _, err := executeChatMessagePaginationCommand(t, caller, tt.args...) - if err != nil { - t.Fatal(err) + if err == nil { + t.Fatal("expected validation error, got nil") } - if len(caller.calls) != 1 { - t.Fatalf("calls = %#v, want one MCP call", caller.calls) + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %q, want contains %q", err.Error(), tt.wantErr) } - got := caller.calls[0] - if got.tool != "search_messages_by_time_range" { - t.Fatalf("tool = %q, want search_messages_by_time_range", got.tool) + if len(caller.calls) != 0 { + t.Fatalf("calls = %#v, want no MCP call", caller.calls) } - assertStringArg(t, got.args["startTime"], tt.wantStart) - assertStringArg(t, got.args["endTime"], tt.wantEnd) }) } } @@ -437,11 +470,11 @@ func parseChatMessageListAllTimeArg(t *testing.T, value any) time.Time { } return time.UnixMilli(parsedMs) } - parsed, err := time.ParseInLocation("2006-01-02 15:04:05", raw, time.Local) + parsedMs, err := parseISOTimeToMillis("time", raw) if err != nil { t.Fatalf("time arg = %q, parse err = %v", raw, err) } - return parsed + return time.UnixMilli(parsedMs) } func assertChatMessageListAllTimeFormat(t *testing.T, value any) { @@ -453,7 +486,7 @@ func assertChatMessageListAllTimeFormat(t *testing.T, value any) { if strings.Contains(raw, "T") { t.Fatalf("time arg = %q, want yyyy-MM-dd HH:mm:ss without RFC3339 separator", raw) } - if _, err := time.ParseInLocation("2006-01-02 15:04:05", raw, time.Local); err != nil { + if _, err := parseISOTimeToMillis("time", raw); err != nil { t.Fatalf("time arg = %q, parse err = %v", raw, err) } } From 91f0fb7b116b7d090c918f57ea3a7d016fb0f678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Thu, 13 Aug 2026 21:03:03 +0800 Subject: [PATCH 75/86] fix(chat): declare idempotency key alias --- internal/app/root_help_test.go | 7 +++++++ internal/helpers/chat.go | 9 ++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/internal/app/root_help_test.go b/internal/app/root_help_test.go index 79c1c967..2224ad22 100644 --- a/internal/app/root_help_test.go +++ b/internal/app/root_help_test.go @@ -22,6 +22,7 @@ import ( "strings" "testing" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" @@ -377,6 +378,12 @@ func TestChatFileUploadDownlinedButMessageFileSendStays(t *testing.T) { if legacyUUID == nil || !legacyUUID.Hidden { t.Fatalf("chat message send --uuid hidden = %#v, want hidden compatibility flag", legacyUUID) } + if got := legacyUUID.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != "idempotency-key" { + t.Fatalf("chat message send --uuid alias_of = %#v, want idempotency-key", got) + } + if got := legacyUUID.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 { + t.Fatalf("chat message send --uuid alias_origin = %#v, want %s", got, runtimeannotate.FlagAliasOriginCorecmdV1) + } got, err := executeRootCaptureStdout(t, []string{ "chat", "file", "upload", diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 1f8ad37f..19fabba2 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -18,6 +18,7 @@ import ( "unicode/utf8" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver" @@ -4386,9 +4387,11 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 _ = chatMessageSendCmd.Flags().MarkHidden("file-type") _ = chatMessageSendCmd.Flags().MarkHidden("file-size") chatMessageSendCmd.Flags().Bool("ai-tag", true, "消息是否带 AI 发送角标(默认 true)") - chatMessageSendCmd.Flags().String("idempotency-key", "", "幂等键,相同 key 在 24h 内不会重复发送(可选)") - chatMessageSendCmd.Flags().String("uuid", "", "--idempotency-key 的兼容别名") - _ = chatMessageSendCmd.Flags().MarkHidden("uuid") + corecmd.RegisterFlags(chatMessageSendCmd, []corecmd.FlagSpec{{ + Name: "idempotency-key", + Usage: "幂等键,相同 key 在 24h 内不会重复发送(可选)", + Aliases: []string{"uuid"}, + }}) cli.AttachRuntimeSchema(chatMessageSendCmd, "chat", "send_personal_message", "hardcoded:chat") cli.AnnotateRuntimeConstraints(chatMessageSendCmd, cli.RuntimeSchemaConstraints{ MutuallyExclusive: [][]string{{"group", "user", "open-dingtalk-id"}}, From 8c6266f15892ac93cbb0a3ac578507c5e7ef99bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Thu, 13 Aug 2026 21:06:44 +0800 Subject: [PATCH 76/86] chore(policy): consume idempotency flag migration --- .../interface-migrations/approved-flag-migrations-v1.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json index a89aaea1..ece90440 100644 --- a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json +++ b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json @@ -958,7 +958,7 @@ "required": false } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将幂等参数规范 flag 设为唯一可见入口" }, { From 97b6022017653f68964ac4c12ee82f2fb12d00df Mon Sep 17 00:00:00 2001 From: chichuan Date: Thu, 13 Aug 2026 21:35:48 +0800 Subject: [PATCH 77/86] test(drive): make pinned-root TOCTOU reproductions runnable on Windows Windows keeps the pinned directory locked while a handle inside it is open (os.Root plus the pull temp file or the upload source), so renaming that directory fails with a sharing violation. Every "pinned root/ancestor was swapped" reproduction in the drive mirror tests relied on such a rename, so 13 tests failed on windows-latest. That, not a coverage shortfall, is why Coverage (Windows) exited 1 before the gate ever ran. Each reproduction now falls back to injecting the equivalent identity change when the rename is refused. pinnedPullRoot.verify() and verifyParent() read current identity only through pullPathStat / pullRootLstat, so pointing those seams at another directory hits the same fail-closed branches. Unix still performs the real move and loses no strength. Assertions that need an actual replacement tree now branch on the helper's return value. forcePinnedFallbackForTest makes the fallback path itself regressible on any platform, and a dedicated test covers it. Verified locally with the fallback forced on: all 13 tests pass and changed code coverage stays at 100%. --- .../drive_pinned_root_identity_test.go | 77 +++++++++++++++++++ .../helpers/drive_pull_plan_coverage_test.go | 11 ++- internal/helpers/drive_pull_toctou_test.go | 56 +++++++++----- internal/helpers/drive_push_toctou_test.go | 46 ++++++++--- 4 files changed, 159 insertions(+), 31 deletions(-) create mode 100644 internal/helpers/drive_pinned_root_identity_test.go 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) } }) From 2734e3e1ce427a26c0e9da898c0b234f182b789e Mon Sep 17 00:00:00 2001 From: chichuan Date: Thu, 13 Aug 2026 22:14:05 +0800 Subject: [PATCH 78/86] test(drive): cover fs.WalkDir callback error short-circuit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows coverage gate reported changed-code coverage at 99.9360% because drive_push.go:471-473 — the branch that surfaces an error passed to the fs.WalkDir callback as its third argument — was not exercised. macOS runners happen to exercise it via directory-lstat failures, Windows runners do not. Add walk_callback_receives_error under TestCrossPlatformCoverageDrivePushFinalWalkAndCommandGates, which swaps walkPinnedLocalFS to invoke the callback with a non-nil err and asserts the error is bubbled up unchanged. Verified locally that the new subtest hits drive_push.go:471.17,473.4 with count=1. --- .../helpers/drive_push_sync_final_coverage_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/helpers/drive_push_sync_final_coverage_test.go b/internal/helpers/drive_push_sync_final_coverage_test.go index ddbe2a8b..13aaf929 100644 --- a/internal/helpers/drive_push_sync_final_coverage_test.go +++ b/internal/helpers/drive_push_sync_final_coverage_test.go @@ -219,6 +219,19 @@ func TestCrossPlatformCoverageDrivePushFinalWalkAndCommandGates(t *testing.T) { } }) + // fs.WalkDir 允许把中途的 lstat 失败通过第三个参数传给回调而不是 DirEntry.Info; + // 该短路分支必须直接原样上抛,不能吞掉底层错误。macOS 上并非每次遍历都会经过它, + // 因此需要一个显式测试固定这条路径。 + t.Run("walk callback receives error", func(t *testing.T) { + _, root, _ := openDriveFinalCoverageRoot(t) + testseam.Swap(t, &walkPinnedLocalFS, func(_ fs.FS, _ string, fn fs.WalkDirFunc) error { + return fn("bad", nil, errTestInfo) + }) + if _, _, err := walkLocalForPushPinned(root); !errors.Is(err, errTestInfo) { + t.Fatalf("walk callback error = %v", err) + } + }) + t.Run("walk final root verification", func(t *testing.T) { rootPath, root, _ := openDriveFinalCoverageRoot(t) moved := filepath.Join(filepath.Dir(rootPath), "moved-after-walk") From 113e084a8d447593d4e5cf04058be0e48a238b42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Thu, 13 Aug 2026 22:31:57 +0800 Subject: [PATCH 79/86] fix(chat): align default message time timezone --- internal/helpers/chat.go | 2 +- internal/helpers/chat_message_search_test.go | 112 ++++++++++++++----- 2 files changed, 82 insertions(+), 32 deletions(-) diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 0a3a9b27..770e43c9 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -646,7 +646,7 @@ func formatChatMessageListAllTime(ms int64) string { } func defaultChatMessageListTime() string { - return time.Now().Format("2006-01-02 15:04:05") + return time.Now().In(shanghaiLocation()).Format("2006-01-02 15:04:05") } func chatMessageSearchAdvancedArgs(cmd *cobra.Command) (map[string]any, error) { diff --git a/internal/helpers/chat_message_search_test.go b/internal/helpers/chat_message_search_test.go index eeec924e..bdf1da88 100644 --- a/internal/helpers/chat_message_search_test.go +++ b/internal/helpers/chat_message_search_test.go @@ -724,44 +724,94 @@ func TestCrossPlatformCoverageChatMessageListUsesMCPMetadataGroupKey(t *testing. } } -func TestCrossPlatformCoverageChatMessageListDefaultTimeUsesLocalLocation(t *testing.T) { +func TestCrossPlatformCoverageChatMessageListDefaultTimeUsesShanghaiLocation(t *testing.T) { previousLocal := time.Local - time.Local = time.UTC t.Cleanup(func() { time.Local = previousLocal }) tests := []struct { - name string - args []string + name string + args []string + wantTool string + wantTarget map[string]any }{ - {name: "group", args: []string{"message", "list", "--group", "cid-1", "--limit", "50"}}, - {name: "direct", args: []string{"message", "list-direct", "--user", "user-1", "--limit", "50"}}, + { + name: "group", + args: []string{"message", "list", "--group", "cid-1", "--limit", "50"}, + wantTool: "list_conversation_message_v2", + wantTarget: map[string]any{"openconversation_id": "cid-1"}, + }, + { + name: "user", + args: []string{"message", "list", "--user", "user-1", "--limit", "50"}, + wantTool: "list_individual_chat_message", + wantTarget: map[string]any{"userId": "user-1"}, + }, + { + name: "user open DingTalk ID fallback", + args: []string{"message", "list", "--user", "D-user", "--limit", "50"}, + wantTool: "list_individual_chat_message", + wantTarget: map[string]any{"openDingTalkId": "D-user"}, + }, + { + name: "open DingTalk ID", + args: []string{"message", "list", "--open-dingtalk-id", "D-open", "--limit", "50"}, + wantTool: "list_individual_chat_message", + wantTarget: map[string]any{"openDingTalkId": "D-open"}, + }, + { + name: "direct user", + args: []string{"message", "list-direct", "--user", "user-1", "--limit", "50"}, + wantTool: "list_individual_chat_message", + wantTarget: map[string]any{"userId": "user-1"}, + }, + { + name: "direct open DingTalk ID", + args: []string{"message", "list-direct", "--open-dingtalk-id", "D-direct", "--limit", "50"}, + wantTool: "list_individual_chat_message", + wantTarget: map[string]any{"openDingTalkId": "D-direct"}, + }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - caller := &chatChangedContractCaller{} - before := time.Now() - err := executeChatChangedContract(t, caller, tt.args...) - after := time.Now() - if err != nil { - t.Fatal(err) - } - if len(caller.calls) != 1 { - t.Fatalf("calls = %#v, want one MCP call", caller.calls) - } - raw, ok := caller.calls[0].args["time"].(string) - if !ok || raw == "" { - t.Fatalf("time arg = %#v, want non-empty string", caller.calls[0].args["time"]) - } - got, err := time.ParseInLocation("2006-01-02 15:04:05", raw, time.Local) - if err != nil { - t.Fatalf("time arg = %q, parse err = %v", raw, err) - } - if got.Before(before.Add(-time.Second)) || got.After(after.Add(time.Second)) { - t.Fatalf("time arg = %q (%s), want current local time between %s and %s", raw, got, before, after) - } - if caller.calls[0].args["forward"] != false { - t.Fatalf("forward = %#v, want false when --time is omitted", caller.calls[0].args["forward"]) + for _, loc := range []*time.Location{time.UTC, time.FixedZone("EST", -5*3600)} { + t.Run(loc.String(), func(t *testing.T) { + time.Local = loc + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caller := &chatChangedContractCaller{} + before := time.Now() + err := executeChatChangedContract(t, caller, tt.args...) + after := time.Now() + if err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 { + t.Fatalf("calls = %#v, want one MCP call", caller.calls) + } + if caller.calls[0].toolName != tt.wantTool { + t.Fatalf("tool = %q, want %q", caller.calls[0].toolName, tt.wantTool) + } + for key, want := range tt.wantTarget { + if got := caller.calls[0].args[key]; got != want { + t.Fatalf("arg %s = %#v, want %#v", key, got, want) + } + } + raw, ok := caller.calls[0].args["time"].(string) + if !ok || raw == "" { + t.Fatalf("time arg = %#v, want non-empty string", caller.calls[0].args["time"]) + } + gotMs, err := parseISOTimeToMillis("time", raw) + if err != nil { + t.Fatalf("time arg = %q, parse err = %v", raw, err) + } + wantMin := before.Add(-time.Second).UnixMilli() + wantMax := after.Add(time.Second).UnixMilli() + if gotMs < wantMin || gotMs > wantMax { + t.Fatalf("time arg = %q (%d), want between %d and %d", raw, gotMs, wantMin, wantMax) + } + if caller.calls[0].args["forward"] != false { + t.Fatalf("forward = %#v, want false when --time is omitted", caller.calls[0].args["forward"]) + } + }) } }) } From 6c78db7467d17035e049ea5794656cd4dc224ec6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=BF=E7=9C=9F?= Date: Thu, 13 Aug 2026 23:36:53 +0800 Subject: [PATCH 80/86] fix(chat): document Shanghai time message default --- internal/helpers/chat.go | 2 +- internal/helpers/chat_message_help_test.go | 4 ++-- internal/helpers/im_read_result_contract_test.go | 7 ++++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 267574de..f36dd104 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -2663,7 +2663,7 @@ func newChatCommand() *cobra.Command { chatMessageListCmd := &cobra.Command{ Use: "list", Short: "拉取会话消息内容", - Long: `拉取指定群聊或单聊的会话消息内容。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId 和 content。--group 指定群聊,--user 指定单聊用户(userId),--open-dingtalk-id 指定单聊用户(openDingTalkId),三者互斥。--time 可选,不传时默认当前时间并向旧消息拉取。推荐使用 --direction newer/older 控制时间方向:newer 表示从给定时间往现在拉,older 表示从给定时间往以前拉。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。引用回复消息会返回 quotedMessage 引用上下文;被引用的原消息是合并转发或图片时,对应的类型与内容也会随引用上下文返回。如果返回的会话消息中包含 openConvThreadId 字段,说明是话题消息,可以调用 dws chat message list-topic-replies 拉取话题回复消息列表,openConvThreadId 作为 topic-id 参数。`, + Long: `拉取指定群聊或单聊的会话消息内容。输出顶层 messages,稳定字段为 messageId 和 text;兼容保留 openMessageId 和 content。--group 指定群聊,--user 指定单聊用户(userId),--open-dingtalk-id 指定单聊用户(openDingTalkId),三者互斥。--time 可选,不传时默认上海时间当前时间并向旧消息拉取。推荐使用 --direction newer/older 控制时间方向:newer 表示从给定时间往现在拉,older 表示从给定时间往以前拉。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。引用回复消息会返回 quotedMessage 引用上下文;被引用的原消息是合并转发或图片时,对应的类型与内容也会随引用上下文返回。如果返回的会话消息中包含 openConvThreadId 字段,说明是话题消息,可以调用 dws chat message list-topic-replies 拉取话题回复消息列表,openConvThreadId 作为 topic-id 参数。`, Example: ` dws chat message list --group dws chat message list --group --time "2025-03-01 00:00:00" dws chat message list --user --time "2025-03-01 00:00:00" --limit 50 diff --git a/internal/helpers/chat_message_help_test.go b/internal/helpers/chat_message_help_test.go index 6941e2e8..9e528d3c 100644 --- a/internal/helpers/chat_message_help_test.go +++ b/internal/helpers/chat_message_help_test.go @@ -102,8 +102,8 @@ func TestCrossPlatformCoverageChatMessageHelpDocumentsOptionalTimeDefaults(t *te name: "list", args: []string{"message", "list", "--help"}, contains: []string{ - "--time 可选,不传时默认当前时间并向旧消息拉取", - "默认当前时间", + "--time 可选,不传时默认上海时间当前时间并向旧消息拉取", + "默认上海时间当前时间", "未传 --time 时默认 older", }, absent: []string{"开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)"}, diff --git a/internal/helpers/im_read_result_contract_test.go b/internal/helpers/im_read_result_contract_test.go index 0d983925..9494069d 100644 --- a/internal/helpers/im_read_result_contract_test.go +++ b/internal/helpers/im_read_result_contract_test.go @@ -213,11 +213,12 @@ func TestCrossPlatformCoverageChatMessageListProjectsStableFieldsAndPreservesLeg func TestCrossPlatformCoverageChatMessageListDefaultsTimeAndOlderDirection(t *testing.T) { payload := `{"result":{"messages":[],"hasMore":false}}` caller := &imReadResultCaller{responses: map[string]string{"list_conversation_message_v2": payload}} + loc := shanghaiLocation() - before := time.Now() + before := time.Now().In(loc) _, err := executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand, "message", "list", "--group", "cid-1", "--limit", "50") - after := time.Now() + after := time.Now().In(loc) if err != nil { t.Fatalf("chat message list returned error: %v", err) } @@ -231,7 +232,7 @@ func TestCrossPlatformCoverageChatMessageListDefaultsTimeAndOlderDirection(t *te if !ok || strings.TrimSpace(timeRaw) == "" { t.Fatalf("time arg = %#v, want generated current time", caller.args["time"]) } - gotTime, err := time.ParseInLocation("2006-01-02 15:04:05", timeRaw, time.Local) + gotTime, err := time.ParseInLocation("2006-01-02 15:04:05", timeRaw, loc) if err != nil { t.Fatalf("parse generated time %q: %v", timeRaw, err) } From a5111f486bff48d8fb1cfbe82c7e596225031850 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A0=A9=E6=9C=9D?= Date: Thu, 13 Aug 2026 16:13:28 +0800 Subject: [PATCH 81/86] fix(chat): harden openDingTalkId target routing --- .changes/open-dingtalk-id-format-routing.md | 5 + internal/app/param_alias_e2e_test.go | 8 +- .../param_alias_payload_equivalence_test.go | 27 +- internal/helpers/chat.go | 30 +- internal/helpers/chat_coverage_more_test.go | 29 +- internal/helpers/chat_file_path_send_test.go | 8 +- internal/helpers/chat_message_search_test.go | 52 ++-- internal/shortcut/chat/chat.go | 21 +- internal/shortcut/chat/chat_conversation.go | 4 + internal/shortcut/chat/chat_group.go | 3 + internal/shortcut/chat/chat_message.go | 18 ++ .../chat/compatibility_coverage_test.go | 23 +- .../shortcut/chat/coverage_completion_test.go | 32 +- internal/shortcut/chat/gap_fill_test.go | 69 +++-- internal/shortcut/chat/lark_alignment.go | 9 +- internal/shortcut/chat/lark_alignment_test.go | 10 +- .../chat/remaining_read_pagination_test.go | 2 +- internal/shortcut/chat/unified_send.go | 11 + internal/shortcut/smart/chat_messages.go | 270 ++++++++++++++--- internal/shortcut/smart/chat_messages_test.go | 145 ++++++++- .../smart/coverage_completion_test.go | 2 +- .../smart/natural_target_resolution_test.go | 79 ++++- internal/shortcut/smart/search_msg.go | 264 +++++++++++++++-- .../smart/search_msg_execution_test.go | 230 +++++++++++++-- internal/shortcut/targetresolver/resolver.go | 269 +++++++++++++++++ .../shortcut/targetresolver/resolver_test.go | 275 ++++++++++++++++++ test/mock_mcp/mock_mcp_smoke_test.go | 19 +- 27 files changed, 1691 insertions(+), 223 deletions(-) create mode 100644 .changes/open-dingtalk-id-format-routing.md diff --git a/.changes/open-dingtalk-id-format-routing.md b/.changes/open-dingtalk-id-format-routing.md new file mode 100644 index 00000000..10f6f191 --- /dev/null +++ b/.changes/open-dingtalk-id-format-routing.md @@ -0,0 +1,5 @@ +--- +category: Changed +--- + +- **Chat identity routing** — validates explicit `openDingTalkId` inputs and improves name, `userId`, and `openDingTalkId` routing for message shortcuts. diff --git a/internal/app/param_alias_e2e_test.go b/internal/app/param_alias_e2e_test.go index 25581789..a3eeaf51 100644 --- a/internal/app/param_alias_e2e_test.go +++ b/internal/app/param_alias_e2e_test.go @@ -361,7 +361,7 @@ func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) { caller := ¶mAliasCaptureCaller{} ctx, err := executeParamAliasE2E(t, caller, "chat", "message", "send", - "--to-user", "D-recipient", + "--to-user", appFixtureCurrentDOpenID, "--text", "hello alias", "--idempotency-key", "alias-e2e", ) @@ -375,7 +375,7 @@ func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) { t.Fatalf("chat calls = %#v", caller.calls) } payload := caller.calls[0].args - if payload["receiverOpenDingTalkId"] != "D-recipient" || payload["uuid"] != "alias-e2e" || payload["msgType"] != "markdown" { + if payload["receiverOpenDingTalkId"] != appFixtureCurrentDOpenID || payload["uuid"] != "alias-e2e" || payload["msgType"] != "markdown" { t.Fatalf("chat payload identity fields = %#v", payload) } content, _ := payload["content"].(string) @@ -689,11 +689,11 @@ func TestCrossPlatformCoverageSelectedParamAliasesProduceCanonicalEquivalentDryR tool: "send_personal_message", canonicalArgs: []string{ "--dry-run", "chat", "message", "send", - "--user", "D-recipient", "--text", "hello dry-run", "--uuid", "alias-dry-run", + "--user", appFixtureCurrentDOpenID, "--text", "hello dry-run", "--uuid", "alias-dry-run", }, aliasArgs: []string{ "--dry-run", "chat", "message", "send", - "--to-user", "D-recipient", "--text", "hello dry-run", "--uuid", "alias-dry-run", + "--to-user", appFixtureCurrentDOpenID, "--text", "hello dry-run", "--uuid", "alias-dry-run", }, wantCorrections: 1, wantArgKeys: []string{"clawType", "content", "msgType", "receiverOpenDingTalkId", "uuid"}, diff --git a/internal/app/param_alias_payload_equivalence_test.go b/internal/app/param_alias_payload_equivalence_test.go index 5d2637c8..ef191e85 100644 --- a/internal/app/param_alias_payload_equivalence_test.go +++ b/internal/app/param_alias_payload_equivalence_test.go @@ -14,6 +14,11 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" ) +const ( + appFixtureCurrentDOpenID = "DAAAAAAAAAAAiE" + appFixtureCurrentDOpenID2 = "DAQEBAQEBAQEiE" +) + // paramAliasCompleteCommands is deliberately keyed by the exact reviewed // fixture command path. Every argv is a complete, business-valid invocation: // required companion flags are present, time and enum values are valid, and @@ -34,12 +39,12 @@ var paramAliasCompleteCommands = map[string][]string{ "chat +chat-messages": {"chat", "+chat-messages", "--group", "fixture-conversation"}, "chat +chat-add-bot": {"chat", "+chat-add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"}, "chat +chat-audit-join": {"chat", "+chat-audit-join", "--group", "fixture-conversation", "--record-id", "7", "--applicant", "user-1", "--inviter", "user-2", "--status", "AuditApprove", "--yes"}, - "chat +chat-members-get": {"chat", "+chat-members-get", "--id", "fixture-conversation", "--users", "D-user-1,D-user-2"}, + "chat +chat-members-get": {"chat", "+chat-members-get", "--id", "fixture-conversation", "--users", appFixtureCurrentDOpenID + "," + appFixtureCurrentDOpenID2}, "chat +chat-members-list": {"chat", "+chat-members-list", "--conversation-id", "fixture-conversation", "--member-types", "user,bot"}, - "chat +chat-mute-member": {"chat", "+chat-mute-member", "--group", "fixture-conversation", "--users", "D-user-1,D-user-2", "--mute-time", "3600000", "--yes"}, + "chat +chat-mute-member": {"chat", "+chat-mute-member", "--group", "fixture-conversation", "--users", appFixtureCurrentDOpenID + "," + appFixtureCurrentDOpenID2, "--mute-time", "3600000", "--yes"}, "chat +chat-remove-bot": {"chat", "+chat-remove-bot", "--id", "fixture-conversation", "--bot-id", "bot-1", "--yes"}, - "chat +chat-role-remove-user": {"chat", "+chat-role-remove-user", "--group", "fixture-conversation", "--user", "D-user-1", "--role-ids", "role-1", "--yes"}, - "chat +chat-transfer-owner": {"chat", "+chat-transfer-owner", "--group", "fixture-conversation", "--new-owner", "D-user-1", "--yes"}, + "chat +chat-role-remove-user": {"chat", "+chat-role-remove-user", "--group", "fixture-conversation", "--user", appFixtureCurrentDOpenID, "--role-ids", "role-1", "--yes"}, + "chat +chat-transfer-owner": {"chat", "+chat-transfer-owner", "--group", "fixture-conversation", "--new-owner", appFixtureCurrentDOpenID, "--yes"}, "chat +chat-update": {"chat", "+chat-update", "--group", "fixture-conversation", "--name", "Fixture Renamed Group", "--yes"}, "chat +bot-find": {"chat", "+bot-find", "--query", "fixture", "--limit", "7"}, "chat +bot-search": {"chat", "+bot-search", "--name", "Fixture Bot", "--page", "2", "--size", "7"}, @@ -57,7 +62,7 @@ var paramAliasCompleteCommands = map[string][]string{ "chat +messages-list": {"chat", "+messages-list", "--group", "fixture-conversation", "--time", "2026-03-10 00:00:00", "--limit", "7"}, "chat +messages-list-direct": {"chat", "+messages-list-direct", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"}, "chat +messages-list-unread-conversations": {"chat", "+messages-list-unread-conversations", "--count", "7", "--exclude-muted"}, - "chat +messages-reply": {"chat", "+messages-reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", "D-sender", "--text", "hello fixture", "--yes"}, + "chat +messages-reply": {"chat", "+messages-reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--text", "hello fixture", "--yes"}, "chat +messages-resource-download": {"chat", "+messages-resource-download", "--resource-id", "resource-1", "--message-id", "message-1", "--open-conversation-id", "fixture-conversation", "--output", "downloads/fixture.bin"}, "chat +messages-set-pin": {"chat", "+messages-set-pin", "--open-conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"}, "chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"}, @@ -70,10 +75,10 @@ var paramAliasCompleteCommands = map[string][]string{ "chat category create-smart": {"chat", "category", "create-smart", "--name", "Fixture Smart Category", "--keywords", "fixture,priority", "--yes"}, "chat category rename": {"chat", "category", "rename", "--category-id", "7", "--title", "Renamed Cat", "--yes"}, "chat group members": {"chat", "group", "members", "--id", "fixture-conversation"}, - "chat group members add": {"chat", "group", "members", "add", "--id", "fixture-conversation", "--users", "D-user-1"}, + "chat group members add": {"chat", "group", "members", "add", "--id", "fixture-conversation", "--users", appFixtureCurrentDOpenID}, "chat group members add-bot": {"chat", "group", "members", "add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"}, - "chat group members list-by-ids": {"chat", "group", "members", "list-by-ids", "--id", "fixture-conversation", "--users", "D-user-1,D-user-2"}, - "chat group members remove": {"chat", "group", "members", "remove", "--id", "fixture-conversation", "--users", "D-user-1", "--yes"}, + "chat group members list-by-ids": {"chat", "group", "members", "list-by-ids", "--id", "fixture-conversation", "--users", appFixtureCurrentDOpenID + "," + appFixtureCurrentDOpenID2}, + "chat group members remove": {"chat", "group", "members", "remove", "--id", "fixture-conversation", "--users", appFixtureCurrentDOpenID, "--yes"}, "chat group members remove-bot": {"chat", "group", "members", "remove-bot", "--id", "fixture-conversation", "--bot-id", "bot-1", "--yes"}, "chat group rename": {"chat", "group", "rename", "--id", "fixture-conversation", "--name", "Fixture Renamed Group", "--yes"}, "chat group set-admin": {"chat", "group", "set-admin", "--group", "fixture-conversation", "--user", "user-1", "--yes"}, @@ -88,9 +93,9 @@ var paramAliasCompleteCommands = map[string][]string{ "chat message list-by-ids": {"chat", "message", "list-by-ids", "--msg-ids", "message-1,message-2"}, "chat message list-unread-conversations": {"chat", "message", "list-unread-conversations", "--count", "7", "--exclude-muted"}, "chat message recall": {"chat", "message", "recall", "--conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"}, - "chat message reply": {"chat", "message", "reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", "D-sender", "--text", "hello fixture", "--yes"}, + "chat message reply": {"chat", "message", "reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--text", "hello fixture", "--yes"}, "chat message search-advanced": {"chat", "message", "search-advanced", "--conversation-ids", "fixture-conversation", "--query", "fixture"}, - "chat message send": {"chat", "message", "send", "--user", "D-recipient", "--text", "hello fixture", "--idempotency-key", "param-alias-equivalence", "--yes"}, + "chat message send": {"chat", "message", "send", "--user", appFixtureCurrentDOpenID, "--text", "hello fixture", "--idempotency-key", "param-alias-equivalence", "--yes"}, "chat message send-by-bot": {"chat", "message", "send-by-bot", "--robot-code", "robot-1", "--group", "fixture-conversation", "--title", "Fixture Alert", "--text", "@user-1 @user-2 fixture", "--at-user-ids", "user-1,user-2", "--yes"}, "chat message send-by-webhook": {"chat", "message", "send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"}, "contact +dept-members": {"contact", "+dept-members", "--dept", "Fixture Dept"}, @@ -211,7 +216,7 @@ var paramAliasCompleteCommandVariants = map[string]map[string][]string{ "user": {"chat", "message", "list", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"}, }, "chat message list-by-sender": { - "sender-open-dingtalk-id": {"chat", "message", "list-by-sender", "--sender-open-dingtalk-id", "D-sender", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"}, + "sender-open-dingtalk-id": {"chat", "message", "list-by-sender", "--sender-open-dingtalk-id", appFixtureCurrentDOpenID, "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"}, }, "chat message send": { "group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--idempotency-key", "param-alias-equivalence-group", "--yes"}, diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index f36dd104..bba9e26c 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -520,6 +520,11 @@ func chatMessageListBySenderArgs(cmd *cobra.Command) (map[string]any, error) { if senderUserID == "" && senderOpenDingTalkID == "" { return nil, fmt.Errorf("--sender-user-id or --sender-open-dingtalk-id is required") } + if senderOpenDingTalkID != "" { + if err := targetresolver.ValidateExplicitOpenDingTalkID("--sender-open-dingtalk-id", senderOpenDingTalkID); err != nil { + return nil, err + } + } startRaw, endRaw, err := defaultChatMessageTimeRange(cmd, 7*24*time.Hour) if err != nil { return nil, err @@ -1051,8 +1056,7 @@ func isNumericUserID(value string) bool { } func isOpenDingTalkID(value string) bool { - value = strings.TrimSpace(value) - return len(value) > 0 && (value[0] == 'D' || value[0] == 'd') + return targetresolver.LooksLikeCurrentDOpenDingTalkID(value) } // webhookErrcodeFailure 解析自定义机器人 webhook 的响应,判定是否发送失败。 @@ -2692,6 +2696,11 @@ func newChatCommand() *cobra.Command { if specified == 0 { return fmt.Errorf("--group, --user or --open-dingtalk-id is required") } + if openDingTalkID != "" { + if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil { + return err + } + } if userID != "" && isOpenDingTalkID(userID) { openDingTalkID = userID userID = "" @@ -2784,6 +2793,11 @@ func newChatCommand() *cobra.Command { if userID == "" && openDingTalkID == "" { return fmt.Errorf("--user or --open-dingtalk-id is required") } + if openDingTalkID != "" { + if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil { + return err + } + } if userID != "" && isOpenDingTalkID(userID) { openDingTalkID = userID userID = "" @@ -2913,6 +2927,11 @@ func newChatCommand() *cobra.Command { if specified == 0 { return fmt.Errorf("--group, --user or --open-dingtalk-id is required") } + if openDingTalkID != "" { + if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil { + return err + } + } if userID != "" && isOpenDingTalkID(userID) { openDingTalkID = userID userID = "" @@ -4726,9 +4745,10 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。 userID := rawUserID openDingTalkID := rawOpenDingTalkID - if openDingTalkID != "" && !isOpenDingTalkID(openDingTalkID) { - userID = openDingTalkID - openDingTalkID = "" + if openDingTalkID != "" { + if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil { + return err + } } if userID != "" && isOpenDingTalkID(userID) { openDingTalkID = userID diff --git a/internal/helpers/chat_coverage_more_test.go b/internal/helpers/chat_coverage_more_test.go index 55510c88..3c9f1ad0 100644 --- a/internal/helpers/chat_coverage_more_test.go +++ b/internal/helpers/chat_coverage_more_test.go @@ -14,6 +14,11 @@ import ( "github.com/spf13/cobra" ) +const ( + helperCurrentDOpenID = "DAAAAAAAAAAAiE" + helperCurrentDOpenID2 = "DAQEBAQEBAQEiE" +) + func newChatFlagTestCommand() *cobra.Command { cmd := &cobra.Command{Use: "chat"} cmd.Flags().String("forward", "", "") @@ -37,6 +42,22 @@ func newChatFlagTestCommand() *cobra.Command { return cmd } +func TestNativeChatIDSplitUsesCurrentDFormat(t *testing.T) { + userIDs, openIDs := splitChatIDValues([]string{ + helperCurrentDOpenID, + "D-prefix-fixture-user", + "d-prefix-fixture-user", + "D-invalid", + "fixture-user-id", + }) + if len(openIDs) != 1 || openIDs[0] != helperCurrentDOpenID { + t.Fatalf("open IDs=%#v", openIDs) + } + if got := strings.Join(userIDs, ","); got != "D-prefix-fixture-user,d-prefix-fixture-user,D-invalid,fixture-user-id" { + t.Fatalf("user IDs=%#v", userIDs) + } +} + func TestCrossPlatformCoverageChatDirectionAndScalarCoverage(t *testing.T) { search := &cobra.Command{Use: "search"} search.Flags().String("nicks", "", "") @@ -151,18 +172,18 @@ func TestCrossPlatformCoverageChatContactMappingCoverage(t *testing.T) { } func TestCrossPlatformCoverageResolveOpenDingTalkIDsCoverage(t *testing.T) { - if id, err := resolveOpenDingTalkID(context.Background(), "D-direct"); err != nil || id != "D-direct" { + if id, err := resolveOpenDingTalkID(context.Background(), helperCurrentDOpenID); err != nil || id != helperCurrentDOpenID { t.Fatalf("direct ID = %q, %v", id, err) } if _, err := resolveOpenDingTalkID(context.Background(), ""); err == nil { t.Fatal("empty ID unexpectedly resolved") } - if ids, err := resolveOpenDingTalkIDs(context.Background(), []string{" D1 ", ""}); err != nil || ids[0] != "D1" { + if ids, err := resolveOpenDingTalkIDs(context.Background(), []string{" " + helperCurrentDOpenID + " ", ""}); err != nil || ids[0] != helperCurrentDOpenID { t.Fatalf("direct IDs = %#v, %v", ids, err) } - caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[{"userId":"u1","openDingTalkId":"D1"}]}`}}} + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[{"userId":"u1","openDingTalkId":"` + helperCurrentDOpenID2 + `"}]}`}}} installScriptedCaller(t, caller) - if ids, err := resolveOpenDingTalkIDs(context.Background(), []string{"u1", "u1"}); err != nil || ids[1] != "D1" { + if ids, err := resolveOpenDingTalkIDs(context.Background(), []string{"u1", "u1"}); err != nil || ids[1] != helperCurrentDOpenID2 { t.Fatalf("resolved IDs = %#v, %v", ids, err) } caller.steps = []scriptedToolStep{{text: `{}`}, {text: `{}`}} diff --git a/internal/helpers/chat_file_path_send_test.go b/internal/helpers/chat_file_path_send_test.go index 005940e7..b828a35d 100644 --- a/internal/helpers/chat_file_path_send_test.go +++ b/internal/helpers/chat_file_path_send_test.go @@ -178,7 +178,7 @@ func TestChatMessageSendFilePathUsesOpenDingTalkIDTarget(t *testing.T) { caller := &chatFilePathCaller{} commandArgs := []string{ "message", "send", - "--open-dingtalk-id=D-target", + "--open-dingtalk-id=" + helperCurrentDOpenID, "--msg-type=file", "--file-path=" + filePath, } @@ -203,7 +203,7 @@ func TestChatMessageSendFilePathUsesOpenDingTalkIDTarget(t *testing.T) { server: "im", tool: "init_conversation_file_upload", args: map[string]any{ - "openDingTalkId": "D-target", + "openDingTalkId": helperCurrentDOpenID, "fileName": "report.pdf", "fileSize": int64(len(payload)), "md5": fileMD5, @@ -216,7 +216,7 @@ func TestChatMessageSendFilePathUsesOpenDingTalkIDTarget(t *testing.T) { server: "im", tool: "commit_conversation_file_upload", args: map[string]any{ - "openDingTalkId": "D-target", + "openDingTalkId": helperCurrentDOpenID, "uploadKey": "upload-key", "fileName": "report.pdf", "fileSize": int64(len(payload)), @@ -231,7 +231,7 @@ func TestChatMessageSendFilePathUsesOpenDingTalkIDTarget(t *testing.T) { if send.server != "chat" || send.tool != "send_personal_message" || send.args["msgType"] != "file" { t.Fatalf("send direct target call = %#v", send) } - if send.args["receiverOpenDingTalkId"] != "D-target" { + if send.args["receiverOpenDingTalkId"] != helperCurrentDOpenID { t.Fatalf("send direct target = %#v", send.args) } if _, ok := send.args["openDingTalkId"]; ok { diff --git a/internal/helpers/chat_message_search_test.go b/internal/helpers/chat_message_search_test.go index bdf1da88..c0bcc370 100644 --- a/internal/helpers/chat_message_search_test.go +++ b/internal/helpers/chat_message_search_test.go @@ -879,11 +879,11 @@ func TestChatSendAndReplyDefaultToAgentProductForIMClawType(t *testing.T) { }{ { name: "send", - args: []string{"message", "send", "--open-dingtalk-id", "D1", "--text", "hello"}, + args: []string{"message", "send", "--open-dingtalk-id", helperCurrentDOpenID, "--text", "hello"}, }, { name: "reply", - args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", "D1", "--text", "hello"}, + args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", helperCurrentDOpenID, "--text", "hello"}, }, } @@ -912,11 +912,11 @@ func TestChatSendAndReplyDisableAITagWithEmptyClawType(t *testing.T) { }{ { name: "send", - args: []string{"message", "send", "--open-dingtalk-id", "D1", "--text", "hello", "--ai-tag=false"}, + args: []string{"message", "send", "--open-dingtalk-id", helperCurrentDOpenID, "--text", "hello", "--ai-tag=false"}, }, { name: "reply", - args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", "D1", "--text", "hello", "--ai-tag=false"}, + args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", helperCurrentDOpenID, "--text", "hello", "--ai-tag=false"}, }, } @@ -950,25 +950,25 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T) name: "send", args: []string{ "message", "send", "--group", "cid", - "--text", "收到 @D-target 和 <@D-second>", - "--at-open-dingtalk-ids", "D-target,D-second", + "--text", "收到 @" + helperCurrentDOpenID + " 和 <@" + helperCurrentDOpenID2 + ">", + "--at-open-dingtalk-ids", helperCurrentDOpenID + "," + helperCurrentDOpenID2, "--at-all", }, contentField: "text", - wantContent: "<@all> 收到 <@D-target> 和 <@D-second>", + wantContent: "<@all> 收到 <@" + helperCurrentDOpenID + "> 和 <@" + helperCurrentDOpenID2 + ">", wantAtAll: true, - wantOpenIDs: []string{"D-target", "D-second"}, + wantOpenIDs: []string{helperCurrentDOpenID, helperCurrentDOpenID2}, }, { name: "send keeps missing member placeholders unchanged", args: []string{ "message", "send", "--group", "cid", "--text", "DWS 发消息自测", - "--at-open-dingtalk-ids", "D-target", + "--at-open-dingtalk-ids", helperCurrentDOpenID, }, contentField: "text", wantContent: "DWS 发消息自测", - wantOpenIDs: []string{"D-target"}, + wantOpenIDs: []string{helperCurrentDOpenID}, }, { name: "reply", @@ -976,15 +976,15 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T) "message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", - "--ref-sender", "D-sender", - "--text", "收到 @D-target 和 <@D-second>", - "--at-open-dingtalk-ids", "D-target,D-second", + "--ref-sender", helperCurrentDOpenID, + "--text", "收到 @" + helperCurrentDOpenID + " 和 <@" + helperCurrentDOpenID2 + ">", + "--at-open-dingtalk-ids", helperCurrentDOpenID + "," + helperCurrentDOpenID2, "--at-all", }, contentField: "content", - wantContent: "<@all> 收到 <@D-target> 和 <@D-second>", + wantContent: "<@all> 收到 <@" + helperCurrentDOpenID + "> 和 <@" + helperCurrentDOpenID2 + ">", wantAtAll: true, - wantOpenIDs: []string{"D-target", "D-second"}, + wantOpenIDs: []string{helperCurrentDOpenID, helperCurrentDOpenID2}, }, { name: "reply adds missing member placeholders", @@ -992,13 +992,13 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T) "message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", - "--ref-sender", "D-sender", - "--text", "DWS 回复艾特前津(非主用)自测", - "--at-open-dingtalk-ids", "D-target,D-second,D-target", + "--ref-sender", helperCurrentDOpenID, + "--text", "DWS synthetic reply mention test", + "--at-open-dingtalk-ids", helperCurrentDOpenID + "," + helperCurrentDOpenID2 + "," + helperCurrentDOpenID, }, contentField: "content", - wantContent: "<@D-target> <@D-second> DWS 回复艾特前津(非主用)自测", - wantOpenIDs: []string{"D-target", "D-second", "D-target"}, + wantContent: "<@" + helperCurrentDOpenID + "> <@" + helperCurrentDOpenID2 + "> DWS synthetic reply mention test", + wantOpenIDs: []string{helperCurrentDOpenID, helperCurrentDOpenID2, helperCurrentDOpenID}, }, { name: "reply adds missing member placeholders after at-all", @@ -1006,15 +1006,15 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T) "message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", - "--ref-sender", "D-sender", + "--ref-sender", helperCurrentDOpenID, "--text", "请大家确认", - "--at-open-dingtalk-ids", "D-target", + "--at-open-dingtalk-ids", helperCurrentDOpenID, "--at-all", }, contentField: "content", - wantContent: "<@all> <@D-target> 请大家确认", + wantContent: "<@all> <@" + helperCurrentDOpenID + "> 请大家确认", wantAtAll: true, - wantOpenIDs: []string{"D-target"}, + wantOpenIDs: []string{helperCurrentDOpenID}, }, { name: "reply at-all preserves alliance word", @@ -1022,7 +1022,7 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T) "message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", - "--ref-sender", "D-sender", + "--ref-sender", helperCurrentDOpenID, "--text", "联系 @alliance", "--at-all", }, @@ -1036,7 +1036,7 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T) "message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", - "--ref-sender", "D-sender", + "--ref-sender", helperCurrentDOpenID, "--text", "联系 @alliance", }, contentField: "content", diff --git a/internal/shortcut/chat/chat.go b/internal/shortcut/chat/chat.go index 148a7f38..4a0ed222 100644 --- a/internal/shortcut/chat/chat.go +++ b/internal/shortcut/chat/chat.go @@ -24,13 +24,14 @@ import ( "fmt" "strconv" "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver" ) -// isOpenID reports whether value looks like an openDingTalkId (starts with D/d), -// mirroring helpers.isOpenDingTalkID. +// isOpenID classifies mixed userId/openDingTalkId inputs using the canonical +// current-D wire format shared with helpers and smart shortcuts. func isOpenID(v string) bool { - v = strings.TrimSpace(v) - return len(v) > 0 && (v[0] == 'D' || v[0] == 'd') + return targetresolver.LooksLikeCurrentDOpenDingTalkID(v) } // splitIDs partitions a mixed list of userId / openDingTalkId values, mirroring @@ -50,6 +51,18 @@ func splitIDs(vals []string) (userIDs, openIDs []string) { return userIDs, openIDs } +func validateExplicitOpenIDs(flagName string, values []string) error { + for _, value := range values { + if strings.TrimSpace(value) == "" { + continue + } + if err := targetresolver.ValidateExplicitOpenDingTalkID(flagName, value); err != nil { + return err + } + } + return nil +} + // toInt64Slice converts string values to []int64, mirroring helpers.parseCSVInt64. func toInt64Slice(vals []string) ([]int64, error) { out := make([]int64, 0, len(vals)) diff --git a/internal/shortcut/chat/chat_conversation.go b/internal/shortcut/chat/chat_conversation.go index a575f9a2..a5e83752 100644 --- a/internal/shortcut/chat/chat_conversation.go +++ b/internal/shortcut/chat/chat_conversation.go @@ -25,6 +25,7 @@ import ( apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver" ) // ConversationInfo gets conversation info (get_conversation_info, chat server). @@ -71,6 +72,9 @@ var ConversationInfo = shortcut.Shortcut{ params["openConversationId"] = rt.Str("group") } if rt.Str("open-dingtalk-id") != "" { + if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", rt.Str("open-dingtalk-id")); err != nil { + return err + } params["openDingTalkId"] = rt.Str("open-dingtalk-id") } if len(params) == 0 { diff --git a/internal/shortcut/chat/chat_group.go b/internal/shortcut/chat/chat_group.go index d9b5d31c..a959874e 100644 --- a/internal/shortcut/chat/chat_group.go +++ b/internal/shortcut/chat/chat_group.go @@ -409,6 +409,9 @@ var ChatMembersGet = shortcut.Shortcut{ }, Tips: []string{`dws chat +chat-members-get --id --users odid1,odid2`}, Execute: func(rt *shortcut.RuntimeContext) error { + if err := validateExplicitOpenIDs("--users", rt.StrSlice("users")); err != nil { + return err + } return rt.CallMCP("list_group_member_by_ids", map[string]any{ "openConversationId": rt.Str("id"), "cid": rt.Str("id"), diff --git a/internal/shortcut/chat/chat_message.go b/internal/shortcut/chat/chat_message.go index 536686e6..553b604c 100644 --- a/internal/shortcut/chat/chat_message.go +++ b/internal/shortcut/chat/chat_message.go @@ -29,6 +29,7 @@ import ( apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver" ) const directMessagesHardPageLimit = 500 @@ -55,6 +56,9 @@ var MessagesSendByBot = shortcut.Shortcut{ }, Tips: []string{`dws chat +messages-send-by-bot --robot-code --group --title "日报" --text "## 今日完成"`}, Execute: func(rt *shortcut.RuntimeContext) error { + if err := validateExplicitOpenIDs("--at-open-dingtalk-ids", rt.StrSlice("at-open-dingtalk-ids")); err != nil { + return err + } params := map[string]any{ "robotCode": rt.Str("robot-code"), "openConversationId": rt.Str("group"), @@ -93,6 +97,9 @@ var MessagesBatchSendByBot = shortcut.Shortcut{ }, Tips: []string{`dws chat +messages-batch-send-by-bot --robot-code --users userId1,userId2 --title "提醒" --text "请提交周报"`}, Execute: func(rt *shortcut.RuntimeContext) error { + if err := validateExplicitOpenIDs("--open-dingtalk-ids", rt.StrSlice("open-dingtalk-ids")); err != nil { + return err + } params := map[string]any{ "robotCode": rt.Str("robot-code"), "title": rt.Str("title"), @@ -493,6 +500,9 @@ func executeMessagesListDirect(rt *shortcut.RuntimeContext) error { } switch { case rt.Str("open-dingtalk-id") != "": + if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", rt.Str("open-dingtalk-id")); err != nil { + return err + } params["openDingTalkId"] = rt.Str("open-dingtalk-id") case rt.Str("user") != "": params["userId"] = rt.Str("user") @@ -1515,6 +1525,14 @@ var MessagesSendCard = shortcut.Shortcut{ `dws chat +messages-send-card --group --at-open-dingtalk-ids --content "任务已完成"`, }, Validate: func(rt *shortcut.RuntimeContext) error { + if receiverOpenID := rt.Str("receiver-open-dingtalk-id"); receiverOpenID != "" { + if err := targetresolver.ValidateExplicitOpenDingTalkID("--receiver-open-dingtalk-id", receiverOpenID); err != nil { + return err + } + } + if err := validateExplicitOpenIDs("--at-open-dingtalk-ids", rt.StrSlice("at-open-dingtalk-ids")); err != nil { + return err + } if status := rt.Int("flow-status"); !validCardFlowStatus(status) { return fmt.Errorf("--flow-status 必须在 1-5 之间") } diff --git a/internal/shortcut/chat/compatibility_coverage_test.go b/internal/shortcut/chat/compatibility_coverage_test.go index 82b3defd..43d94d8d 100644 --- a/internal/shortcut/chat/compatibility_coverage_test.go +++ b/internal/shortcut/chat/compatibility_coverage_test.go @@ -26,6 +26,11 @@ import ( "github.com/spf13/cobra" ) +const ( + fixtureCurrentDOpenID = "DAAAAAAAAAAAiE" + fixtureCurrentDOpenID2 = "DAQEBAQEBAQEiE" +) + type platformCoverageCaller struct { product string tool string @@ -53,7 +58,7 @@ func (f *muteMemberResolutionCaller) CallTool(_ context.Context, product, tool s case "contact/get_user_info_by_user_ids": text = `{"result":[{"orgEmployeeModel":{"orgUserId":"user-2","orgUserName":"测试成员"}}]}` case "chat/get_group_members": - text = `{"result":{"hasMore":false,"list":[{"memberEmpName":"测试成员","openDingtalkId":"D-open-2"}]}}` + text = `{"result":{"hasMore":false,"list":[{"memberEmpName":"测试成员","openDingtalkId":"` + fixtureCurrentDOpenID2 + `"}]}}` case "im/set_group_member_mute_list": text = `{"success":true}` default: @@ -235,8 +240,10 @@ func TestCrossPlatformCoverageChatIDHelpers(t *testing.T) { value string want bool }{ - {value: "DingTalk-open-id", want: true}, - {value: " dingtalk-open-id ", want: true}, + {value: "DAAAAAAAAAAAiE", want: true}, + {value: " DAAAAAAAAAAAiE ", want: true}, + {value: "D-prefix-fixture-user", want: false}, + {value: "d-prefix-fixture-user", want: false}, {value: "user-id", want: false}, {value: " ", want: false}, } @@ -248,11 +255,11 @@ func TestCrossPlatformCoverageChatIDHelpers(t *testing.T) { }) t.Run("split mixed IDs", func(t *testing.T) { - userIDs, openIDs := splitIDs([]string{" user-1 ", "", "D-open-1", "d-open-2", "user-2"}) - if want := []string{"user-1", "user-2"}; !reflect.DeepEqual(userIDs, want) { + userIDs, openIDs := splitIDs([]string{" user-1 ", "", "DAAAAAAAAAAAiE", "d-open-2", "D-prefix-fixture-user", "user-2"}) + if want := []string{"user-1", "d-open-2", "D-prefix-fixture-user", "user-2"}; !reflect.DeepEqual(userIDs, want) { t.Fatalf("user IDs = %#v, want %#v", userIDs, want) } - if want := []string{"D-open-1", "d-open-2"}; !reflect.DeepEqual(openIDs, want) { + if want := []string{"DAAAAAAAAAAAiE"}; !reflect.DeepEqual(openIDs, want) { t.Fatalf("open IDs = %#v, want %#v", openIDs, want) } }) @@ -281,7 +288,7 @@ func TestCrossPlatformCoverageChatMuteMemberResolvesUserIDToOpenDingTalkID(t *te root.SetArgs([]string{ "chat", "+chat-mute-member", "--group", "cid-1", - "--users", "user-2,D-open-2", + "--users", "user-2," + fixtureCurrentDOpenID2, "--mute-time", "300000", "--yes", }) @@ -298,7 +305,7 @@ func TestCrossPlatformCoverageChatMuteMemberResolvesUserIDToOpenDingTalkID(t *te if _, ok := final.args["uids"]; ok { t.Fatalf("known-broken uids argument leaked into final call: %#v", final.args) } - if got, want := final.args["openDingTalkIds"], []string{"D-open-2"}; !reflect.DeepEqual(got, want) { + if got, want := final.args["openDingTalkIds"], []string{fixtureCurrentDOpenID2}; !reflect.DeepEqual(got, want) { t.Fatalf("openDingTalkIds = %#v, want %#v", got, want) } } diff --git a/internal/shortcut/chat/coverage_completion_test.go b/internal/shortcut/chat/coverage_completion_test.go index 98d5cb1a..0bdbe1ab 100644 --- a/internal/shortcut/chat/coverage_completion_test.go +++ b/internal/shortcut/chat/coverage_completion_test.go @@ -225,7 +225,7 @@ func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) { { name: "reply write", caller: &larkAlignmentCaller{failProductTool: "chat/send_personal_message"}, - args: []string{"chat", "+messages-reply", "--conversation-id", "cid", "--message-id", "msg", "--ref-sender", "D-sender", "--text", "收到", "--yes"}, + args: []string{"chat", "+messages-reply", "--conversation-id", "cid", "--message-id", "msg", "--ref-sender", fixtureCurrentDOpenID, "--text", "收到", "--yes"}, wantError: "fixture lower call failed", }, } @@ -259,7 +259,7 @@ func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) { }} helpers.InitDeps(external) root = newPlatformCoverageRoot() - root.SetArgs([]string{"chat", "+chat-create", "--name", "群", "--owner-open-dingtalk-id", "D-owner", "--member-query", "外部联系人", "--yes"}) + root.SetArgs([]string{"chat", "+chat-create", "--name", "群", "--owner-open-dingtalk-id", fixtureCurrentDOpenID, "--member-query", "外部联系人", "--yes"}) if err := root.Execute(); err != nil { t.Fatal(err) } @@ -400,17 +400,17 @@ func TestCrossPlatformCoverageUnifiedSendValidationMatrix(t *testing.T) { cases := [][]string{ {"--identity", "user", "--group", "cid", "--users", "u1", "--text", "x"}, {"--identity", "user", "--text", "x"}, - {"--identity", "user", "--group", "cid", "--open-dingtalk-id", "D1", "--text", "x"}, + {"--identity", "user", "--group", "cid", "--open-dingtalk-id", fixtureCurrentDOpenID, "--text", "x"}, {"--identity", "user", "--group", "cid", "--at-user-ids", "u1", "--text", "x"}, - {"--identity", "user", "--open-dingtalk-id", "D1", "--at-all", "--text", "x"}, + {"--identity", "user", "--open-dingtalk-id", fixtureCurrentDOpenID, "--at-all", "--text", "x"}, {"--identity", "bot", "--group", "cid", "--text", "x"}, {"--identity", "bot", "--robot-code", "r", "--group", "cid", "--users", "u1", "--text", "x"}, - {"--identity", "bot", "--robot-code", "r", "--group", "cid", "--open-dingtalk-id", "D1", "--text", "x"}, + {"--identity", "bot", "--robot-code", "r", "--group", "cid", "--open-dingtalk-id", fixtureCurrentDOpenID, "--text", "x"}, {"--identity", "bot", "--robot-code", "r", "--group", "cid", "--at-mobiles", "13800000000", "--text", "x"}, {"--identity", "bot", "--robot-code", "r", "--users", "u1", "--at-user-ids", "u2", "--text", "x"}, {"--identity", "webhook", "--text", "x"}, {"--identity", "webhook", "--webhook-token", "token", "--group", "cid", "--text", "x"}, - {"--identity", "webhook", "--webhook-token", "token", "--at-open-dingtalk-ids", "D1", "--text", "x"}, + {"--identity", "webhook", "--webhook-token", "token", "--at-open-dingtalk-ids", fixtureCurrentDOpenID, "--text", "x"}, {"--identity", "webhook", "--webhook-token", "token", "--uuid", "key", "--text", "x"}, } for _, tail := range cases { @@ -437,23 +437,23 @@ func TestCrossPlatformCoverageUnifiedSendOptionalArgumentsAndErrors(t *testing.T }{ { name: "user group mentions", - args: []string{"--identity", "user", "--group", "cid", "--text", "x", "--at-open-dingtalk-ids", "D1,D2", "--at-all"}, - want: map[string]any{"atOpenDingTalkIds": []string{"D1", "D2"}, "atAll": true}, + args: []string{"--identity", "user", "--group", "cid", "--text", "x", "--at-open-dingtalk-ids", fixtureCurrentDOpenID + "," + fixtureCurrentDOpenID2, "--at-all"}, + want: map[string]any{"atOpenDingTalkIds": []string{fixtureCurrentDOpenID, fixtureCurrentDOpenID2}, "atAll": true}, }, { name: "user direct", - args: []string{"--identity", "user", "--open-dingtalk-id", "D1", "--text", "x"}, - want: map[string]any{"receiverOpenDingTalkId": "D1"}, + args: []string{"--identity", "user", "--open-dingtalk-id", fixtureCurrentDOpenID, "--text", "x"}, + want: map[string]any{"receiverOpenDingTalkId": fixtureCurrentDOpenID}, }, { name: "bot group mentions", - args: []string{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--text", "x", "--at-user-ids", "u1", "--at-open-dingtalk-ids", "D1"}, - want: map[string]any{"atUserIds": []string{"u1"}, "atOpendingtalkIds": []string{"D1"}}, + args: []string{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--text", "x", "--at-user-ids", "u1", "--at-open-dingtalk-ids", fixtureCurrentDOpenID}, + want: map[string]any{"atUserIds": []string{"u1"}, "atOpendingtalkIds": []string{fixtureCurrentDOpenID}}, }, { name: "bot direct targets", - args: []string{"--identity", "bot", "--robot-code", "r", "--users", "u1", "--open-dingtalk-ids", "D1", "--text", "x", "--at-all"}, - want: map[string]any{"userIds": []string{"u1"}, "openDingtalkIds": []string{"D1"}, "isAtAll": "true"}, + args: []string{"--identity", "bot", "--robot-code", "r", "--users", "u1", "--open-dingtalk-ids", fixtureCurrentDOpenID, "--text", "x", "--at-all"}, + want: map[string]any{"userIds": []string{"u1"}, "openDingtalkIds": []string{fixtureCurrentDOpenID}, "isAtAll": "true"}, }, { name: "webhook mentions", @@ -580,7 +580,7 @@ func TestCrossPlatformCoverageUnifiedSendGroupFileAndBatchBoundaries(t *testing. root.SetArgs([]string{ "chat", "+messages-send", "--identity", "bot", "--robot-code", "r", "--groups", "c1,c2", "--text", "x", "--at-user-ids", "u1", - "--at-open-dingtalk-ids", "D1", "--at-all", "--yes", + "--at-open-dingtalk-ids", fixtureCurrentDOpenID, "--at-all", "--yes", }) if err := root.Execute(); err != nil { t.Fatal(err) @@ -590,7 +590,7 @@ func TestCrossPlatformCoverageUnifiedSendGroupFileAndBatchBoundaries(t *testing. } for _, call := range fake.calls { if !reflect.DeepEqual(call.args["atUserIds"], []string{"u1"}) || - !reflect.DeepEqual(call.args["atOpendingtalkIds"], []string{"D1"}) || + !reflect.DeepEqual(call.args["atOpendingtalkIds"], []string{fixtureCurrentDOpenID}) || call.args["isAtAll"] != "true" { t.Fatalf("batch mention args = %#v", call.args) } diff --git a/internal/shortcut/chat/gap_fill_test.go b/internal/shortcut/chat/gap_fill_test.go index ad2d428f..7cd22e11 100644 --- a/internal/shortcut/chat/gap_fill_test.go +++ b/internal/shortcut/chat/gap_fill_test.go @@ -417,8 +417,8 @@ func TestCrossPlatformCoverageChatCreateExplicitOwnerSkipsCurrentProfileAndDedup root := newPlatformCoverageRoot() root.SetOut(&bytes.Buffer{}) root.SetArgs([]string{ - "chat", "+chat-create", "--name", "测试群", "--users", "D-owner,user-1", - "--owner-open-dingtalk-id", "D-owner", "--yes", + "chat", "+chat-create", "--name", "测试群", "--users", fixtureCurrentDOpenID + ",user-1", + "--owner-open-dingtalk-id", fixtureCurrentDOpenID, "--yes", }) if err := root.Execute(); err != nil { t.Fatal(err) @@ -427,24 +427,24 @@ func TestCrossPlatformCoverageChatCreateExplicitOwnerSkipsCurrentProfileAndDedup t.Fatalf("explicit owner calls = %#v", fake.calls) } create := fake.calls[0].args - if create["ownerOpenDingTalkId"] != "D-owner" { + if create["ownerOpenDingTalkId"] != fixtureCurrentDOpenID { t.Fatalf("ownerOpenDingTalkId = %#v", create["ownerOpenDingTalkId"]) } - if got, want := create["groupMembers"], []string{"D-owner", "user-1"}; !reflect.DeepEqual(got, want) { + if got, want := create["groupMembers"], []string{fixtureCurrentDOpenID, "user-1"}; !reflect.DeepEqual(got, want) { t.Fatalf("groupMembers = %#v, want %#v", got, want) } } func TestCrossPlatformCoverageChatCreateOwnerQueryResolvesBeforeSingleCreate(t *testing.T) { fake := &larkAlignmentCaller{responses: map[string]string{ - "contact/search_contact_by_key_word": `{"result":[{"name":"张三","userId":"owner-user","openDingTalkId":"D-owner"}]}`, + "contact/search_contact_by_key_word": `{"result":[{"name":"测试用户甲","userId":"owner-user","openDingTalkId":"` + fixtureCurrentDOpenID + `"}]}`, }} helpers.InitDeps(fake) root := newPlatformCoverageRoot() root.SetOut(&bytes.Buffer{}) root.SetArgs([]string{ "chat", "+chat-create", "--name", "测试群", "--users", "user-1", - "--owner-query", "张三", "--yes", + "--owner-query", "测试用户甲", "--yes", }) if err := root.Execute(); err != nil { t.Fatal(err) @@ -454,10 +454,10 @@ func TestCrossPlatformCoverageChatCreateOwnerQueryResolvesBeforeSingleCreate(t * t.Fatalf("owner query calls = %#v", fake.calls) } create := fake.calls[1].args - if create["ownerOpenDingTalkId"] != "D-owner" { + if create["ownerOpenDingTalkId"] != fixtureCurrentDOpenID { t.Fatalf("ownerOpenDingTalkId = %#v", create["ownerOpenDingTalkId"]) } - if got, want := create["groupMembers"], []string{"D-owner", "user-1"}; !reflect.DeepEqual(got, want) { + if got, want := create["groupMembers"], []string{fixtureCurrentDOpenID, "user-1"}; !reflect.DeepEqual(got, want) { t.Fatalf("groupMembers = %#v, want %#v", got, want) } } @@ -601,7 +601,7 @@ func TestCrossPlatformCoverageMessagesSendCurrentUserLocalFileDryRunAndFailures( root.SetOut(&output) root.SetArgs([]string{ "chat", "+messages-send", - "--open-dingtalk-id", "D-target", + "--open-dingtalk-id", fixtureCurrentDOpenID, "--file", "./fixture.bin", "--dry-run", "--yes", @@ -773,7 +773,7 @@ func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) { root.SetArgs([]string{ "chat", "+messages-send-card", "--group", "cid", - "--at-open-dingtalk-ids", "D-one,D-two,D-one", + "--at-open-dingtalk-ids", fixtureCurrentDOpenID + "," + fixtureCurrentDOpenID2 + "," + fixtureCurrentDOpenID, "--at-all", "--content", "完成", "--flow-status", "3", @@ -786,7 +786,7 @@ func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) { fake.calls[1].tool != "update_streaming_card" { t.Fatalf("card calls = %#v", fake.calls) } - if got, want := fake.calls[0].args["atOpenDingTalkIds"], []string{"D-one", "D-two"}; !reflect.DeepEqual(got, want) { + if got, want := fake.calls[0].args["atOpenDingTalkIds"], []string{fixtureCurrentDOpenID, fixtureCurrentDOpenID2}; !reflect.DeepEqual(got, want) { t.Fatalf("card create atOpenDingTalkIds = %#v, want %#v", got, want) } if fake.calls[0].args["atAll"] != true { @@ -842,7 +842,7 @@ func TestCrossPlatformCoverageMessagesSendCardRejectsMissingRequestedAtTag(t *te root.SetArgs([]string{ "chat", "+messages-send-card", "--group", "cid", - "--at-open-dingtalk-ids", "D-mentioned", + "--at-open-dingtalk-ids", fixtureCurrentDOpenID, "--content", "正文", "--yes", }) @@ -891,7 +891,7 @@ func TestCrossPlatformCoverageMessagesSendCardUsesExplicitOpenReceiver(t *testin root := newPlatformCoverageRoot() root.SetArgs([]string{ "chat", "+messages-send-card", - "--receiver-open-dingtalk-id", "D-direct", + "--receiver-open-dingtalk-id", fixtureCurrentDOpenID2, "--yes", }) if err := root.Execute(); err != nil { @@ -902,7 +902,7 @@ func TestCrossPlatformCoverageMessagesSendCardUsesExplicitOpenReceiver(t *testin fake.calls[0].tool != "create_and_send_card" { t.Fatalf("card open receiver calls = %#v", fake.calls) } - if got := fake.calls[0].args["receiverOpenDingTalkId"]; got != "D-direct" { + if got := fake.calls[0].args["receiverOpenDingTalkId"]; got != fixtureCurrentDOpenID2 { t.Fatalf("receiverOpenDingTalkId = %#v, want D-direct", got) } } @@ -969,7 +969,7 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test root.SetArgs([]string{ "chat", "+messages-send-card", "--group", "cid", - "--at-open-dingtalk-ids", "D-mentioned", + "--at-open-dingtalk-ids", fixtureCurrentDOpenID, "--at-all", "--content", "处理中", "--dry-run", @@ -990,7 +990,7 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test createArguments, _ := create["arguments"].(map[string]any) update, _ := actions[1].(map[string]any) updateArguments, _ := update["arguments"].(map[string]any) - if !reflect.DeepEqual(createArguments["atOpenDingTalkIds"], []any{"D-mentioned"}) || createArguments["atAll"] != true { + if !reflect.DeepEqual(createArguments["atOpenDingTalkIds"], []any{fixtureCurrentDOpenID}) || createArguments["atAll"] != true { t.Fatalf("card create dry-run mentions = %#v", createArguments) } if _, exists := updateArguments["atOpenDingTalkIds"]; exists { @@ -1090,10 +1090,10 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test for _, args := range [][]string{ {"--group", "cid", "--content", "x", "--flow-status", "6"}, {"--group", "cid", "--flow-status", "2"}, - {"--group", "cid", "--receiver-open-dingtalk-id", "D-direct"}, - {"--receiver", "user-id", "--receiver-open-dingtalk-id", "D-direct"}, - {"--receiver", "user-id", "--at-open-dingtalk-ids", "D-mentioned"}, - {"--receiver-open-dingtalk-id", "D-direct", "--at-all"}, + {"--group", "cid", "--receiver-open-dingtalk-id", fixtureCurrentDOpenID2}, + {"--receiver", "user-id", "--receiver-open-dingtalk-id", fixtureCurrentDOpenID2}, + {"--receiver", "user-id", "--at-open-dingtalk-ids", fixtureCurrentDOpenID}, + {"--receiver-open-dingtalk-id", fixtureCurrentDOpenID2, "--at-all"}, } { helpers.InitDeps(&larkAlignmentCaller{}) root := newPlatformCoverageRoot() @@ -1104,6 +1104,35 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test } } +func TestCrossPlatformCoverageExplicitOpenIDValidationEdges(t *testing.T) { + if err := validateExplicitOpenIDs("--open-dingtalk-ids", []string{" ", fixtureCurrentDOpenID}); err != nil { + t.Fatalf("blank and valid IDs: %v", err) + } + if err := validateExplicitOpenIDs("--open-dingtalk-ids", []string{fixtureCurrentDOpenID, "not-an-open-id"}); err == nil { + t.Fatal("invalid explicit open ID unexpectedly accepted") + } + + for _, args := range [][]string{ + {"chat", "+messages-send", "--open-dingtalk-id", "not-an-open-id", "--text", "x", "--yes"}, + {"chat", "+messages-send", "--open-dingtalk-ids", "not-an-open-id", "--text", "x", "--yes"}, + {"chat", "+messages-send", "--group", "cid", "--at-open-dingtalk-ids", "not-an-open-id", "--text", "x", "--yes"}, + {"chat", "+messages-send-card", "--receiver-open-dingtalk-id", "not-an-open-id", "--yes"}, + {"chat", "+messages-send-card", "--group", "cid", "--at-open-dingtalk-ids", "not-an-open-id", "--yes"}, + {"chat", "+conversation-info", "--open-dingtalk-id", "not-an-open-id"}, + {"chat", "+chat-members-get", "--id", "cid", "--users", "not-an-open-id"}, + {"chat", "+messages-send-by-bot", "--robot-code", "robot", "--group", "cid", "--title", "title", "--text", "text", "--at-open-dingtalk-ids", "not-an-open-id", "--yes"}, + {"chat", "+messages-batch-send-by-bot", "--robot-code", "robot", "--title", "title", "--text", "text", "--open-dingtalk-ids", "not-an-open-id", "--yes"}, + {"chat", "+messages-list-direct", "--open-dingtalk-id", "not-an-open-id", "--time", "2026-01-01 00:00:00"}, + {"chat", "+chat-create", "--name", "fixture", "--users", "user-1", "--owner-open-dingtalk-id", "not-an-open-id", "--yes"}, + } { + root := newPlatformCoverageRoot() + root.SetArgs(args) + if err := root.Execute(); err == nil { + t.Fatalf("invalid explicit open ID unexpectedly accepted: %v", args) + } + } +} + func TestCrossPlatformCoverageMessagesUpdateCardVerifiesSuccess(t *testing.T) { t.Run("agent shortcut owns confirmation boundary", func(t *testing.T) { fake := &larkAlignmentCaller{responses: map[string]string{ diff --git a/internal/shortcut/chat/lark_alignment.go b/internal/shortcut/chat/lark_alignment.go index f8a7a605..a9f4c4e0 100644 --- a/internal/shortcut/chat/lark_alignment.go +++ b/internal/shortcut/chat/lark_alignment.go @@ -52,6 +52,11 @@ var ChatCreate = shortcut.Shortcut{ `dws chat +chat-create --name "合作群" --member-query "张三,李四" --type EXTERNAL`, }, Execute: func(rt *shortcut.RuntimeContext) error { + if ownerOpenID := rt.Str("owner-open-dingtalk-id"); ownerOpenID != "" { + if err := targetresolver.ValidateExplicitOpenDingTalkID("--owner-open-dingtalk-id", ownerOpenID); err != nil { + return err + } + } resolvedMembers, err := targetresolver.ResolveUsers( rt, rt.StrSlice("member-query"), @@ -339,7 +344,7 @@ func findOpenDingTalkID(value any) string { switch typed := value.(type) { case map[string]any: for _, key := range []string{"senderOpenDingTalkId", "senderOpenDingtalkId", "openDingTalkId", "openDingtalkId"} { - if candidate := strings.TrimSpace(fmt.Sprint(typed[key])); isOpenID(candidate) { + if candidate := strings.TrimSpace(fmt.Sprint(typed[key])); candidate != "" && candidate != "" { return candidate } } @@ -374,7 +379,7 @@ func findMessageSenderOpenDingTalkID(message map[string]any) string { "senderOpenDingtalkId", "senderOpenId", } { - if candidate := strings.TrimSpace(fmt.Sprint(message[key])); isOpenID(candidate) { + if candidate := strings.TrimSpace(fmt.Sprint(message[key])); candidate != "" && candidate != "" { return candidate } } diff --git a/internal/shortcut/chat/lark_alignment_test.go b/internal/shortcut/chat/lark_alignment_test.go index 5dbf9f19..cea41825 100644 --- a/internal/shortcut/chat/lark_alignment_test.go +++ b/internal/shortcut/chat/lark_alignment_test.go @@ -289,12 +289,12 @@ func TestCrossPlatformCoverageMessagesSendRoutesIdentitySpecificTransports(t *te }{ { name: "user", - args: []string{"chat", "+messages-send", "--as", "user", "--chat-id", "cid", "--markdown", "hello @D1", "--at-open-dingtalk-ids", "D1", "--at-all", "--idempotency-key", "u1", "--yes"}, + args: []string{"chat", "+messages-send", "--as", "user", "--chat-id", "cid", "--markdown", "hello @" + fixtureCurrentDOpenID, "--at-open-dingtalk-ids", fixtureCurrentDOpenID, "--at-all", "--idempotency-key", "u1", "--yes"}, product: "chat", tool: "send_personal_message", want: map[string]any{"openConversationId": "cid", "msgType": "markdown", "uuid": "u1"}, bodyKey: "content", - body: "<@all> hello <@D1>", + body: "<@all> hello <@" + fixtureCurrentDOpenID + ">", }, { name: "bot", @@ -471,7 +471,7 @@ func TestCrossPlatformCoverageMessagesReplyPublishesPlainTextBoundary(t *testing "chat", "+messages-reply", "--conversation-id", "cid", "--message-id", "msg", - "--ref-sender", "D-sender", + "--ref-sender", fixtureCurrentDOpenID, "--text", "收到", "--idempotency-key", "reply-uuid", "--yes", @@ -494,7 +494,7 @@ func TestCrossPlatformCoverageMessagesReplyPublishesPlainTextBoundary(t *testing t.Fatal(err) } if content["referenceOpenMessageId"] != "msg" || - content["srcMsgSendOpenDingTalkId"] != "D-sender" || + content["srcMsgSendOpenDingTalkId"] != fixtureCurrentDOpenID || content["replyMsgType"] != "text" || content["content"] != "收到" { t.Fatalf("reply content = %#v", content) @@ -525,7 +525,7 @@ func TestCrossPlatformCoverageMessagesReplyDryRunStopsBeforeWrite(t *testing.T) "chat", "+messages-reply", "--conversation-id", "cid", "--message-id", "msg", - "--ref-sender", "D-sender", + "--ref-sender", fixtureCurrentDOpenID, "--text", "收到", "--dry-run", "--yes", diff --git a/internal/shortcut/chat/remaining_read_pagination_test.go b/internal/shortcut/chat/remaining_read_pagination_test.go index d5506dbc..b3e1ea77 100644 --- a/internal/shortcut/chat/remaining_read_pagination_test.go +++ b/internal/shortcut/chat/remaining_read_pagination_test.go @@ -56,7 +56,7 @@ func TestCrossPlatformCoverageDirectMessagesPageAllUsesMillisecondCursor(t *test var output bytes.Buffer root.SetOut(&output) root.SetArgs([]string{ - "chat", "+messages-list-direct", "--open-dingtalk-id", "D-user", + "chat", "+messages-list-direct", "--open-dingtalk-id", fixtureCurrentDOpenID, "--time", "2026-08-05 16:49:00", "--forward=false", "--limit", "1", "--page-all", "--page-limit", "5", }) diff --git a/internal/shortcut/chat/unified_send.go b/internal/shortcut/chat/unified_send.go index 9991d147..1977d077 100644 --- a/internal/shortcut/chat/unified_send.go +++ b/internal/shortcut/chat/unified_send.go @@ -142,6 +142,17 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error { atOpenIDs := uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids")) atUserIDs := uniqueShortcutStrings(rt.StrSlice("at-user-ids")) atMobiles := uniqueShortcutStrings(rt.StrSlice("at-mobiles")) + if openID != "" { + if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openID); err != nil { + return err + } + } + if err := validateExplicitOpenIDs("--open-dingtalk-ids", openIDs); err != nil { + return err + } + if err := validateExplicitOpenIDs("--at-open-dingtalk-ids", atOpenIDs); err != nil { + return err + } contentType, err := messagesSendContentType(rt) if err != nil { return err diff --git a/internal/shortcut/smart/chat_messages.go b/internal/shortcut/smart/chat_messages.go index ed03dd4c..83c94868 100644 --- a/internal/shortcut/smart/chat_messages.go +++ b/internal/shortcut/smart/chat_messages.go @@ -57,7 +57,7 @@ var ChatMessages = shortcut.Shortcut{ Command: "+chat-messages", Product: "chat", Description: "读取指定群聊或单聊的消息记录,支持有界全量分页与原子 JSON 导出", - Intent: "当你要读取或导出一个指定群聊或单聊的消息记录时使用;可附带发送者姓名解析,无稳定身份时保留全部消息,唯一解析出稳定身份后按 senderId 筛选同一次读取结果;" + + Intent: "当你要读取或导出一个指定群聊或单聊的消息记录时使用;--sender 是可选过滤条件,可传姓名、userId 或 openDingTalkId,唯一解析后按 senderId 筛选同一次读取结果;不传时原样读取会话且不查询发送者身份。sender 仅是展示名,身份只比较稳定 senderId;" + "群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" + "省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --time/--direction,范围模式可用公开可选的 --start/--end/--order(兼容 --start-time/--end-time/--sort),范围语义为 [start,end)。" + "全量读取用 --page-all,并由 --page-limit/--max-items 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" + @@ -83,7 +83,7 @@ var ChatMessages = shortcut.Shortcut{ }, Selection: contract.SelectionSpec{ AgentSummary: "读取指定群聊或单聊的消息记录,支持有界全量分页与原子 JSON 导出", - UseWhen: []string{"当你要读取或导出一个指定群聊或单聊的消息记录时使用;可附带发送者姓名解析,无稳定身份时保留全部消息,唯一解析出稳定身份后按 senderId 筛选同一次读取结果;" + + UseWhen: []string{"当你要读取或导出一个指定群聊或单聊的消息记录时使用;--sender 是可选过滤条件,可传姓名、userId 或 openDingTalkId,唯一解析后按 senderId 筛选同一次读取结果;不传时原样读取会话且不查询发送者身份。sender 仅是展示名,身份只比较稳定 senderId;" + "群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" + "省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --time/--direction,范围模式可用公开可选的 --start/--end/--order(兼容 --start-time/--end-time/--sort),范围语义为 [start,end)。" + "全量读取用 --page-all,并由 --page-limit/--max-items 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" + @@ -104,7 +104,8 @@ var ChatMessages = shortcut.Shortcut{ {Name: "user", Type: shortcut.FlagString, Desc: "单聊对方的 userId,与 --group 互斥"}, {Name: "user-query", Type: shortcut.FlagString, Desc: "按姓名解析唯一 openDingTalkId 的兼容入口", Hidden: true}, {Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊对方的 openDingTalkId,与 --group/--user 互斥"}, - {Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "按姓名唯一解析发送者并筛选同一次会话读取结果(可选,可重复或逗号分隔)"}, + {Name: "sender", Type: shortcut.FlagStringSlice, Desc: "单个或多个发送者姓名、userId 或 openDingTalkId;自动唯一解析并筛选同一次会话读取结果"}, + {Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "显式按姓名唯一解析发送者的兼容入口(可选,可重复或逗号分隔)"}, {Name: "time", Type: shortcut.FlagString, Desc: "时间边界,如 \"2025-03-01 00:00:00\";--time 必须是 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD;省略时从当前时间向前读取最近消息"}, {Name: "start", Type: shortcut.FlagString, Desc: "范围开始时间(可选、包含),支持 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD"}, {Name: "start-time", Type: shortcut.FlagString, Desc: "--start 的 lark-cli 对齐别名(可选、包含)"}, @@ -133,6 +134,7 @@ var ChatMessages = shortcut.Shortcut{ {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"time", "start", "start-time"}}, {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"time", "end", "end-time"}}, {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"time", "order", "sort"}}, + {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"sender", "sender-query"}}, {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"direction", "start", "start-time"}}, {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"direction", "end", "end-time"}}, {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"direction", "order", "sort"}}, @@ -223,105 +225,267 @@ type chatMessagesRequest struct { } type chatMessagesSenderFilter struct { - requested bool - applied bool - stableIDs map[string]bool - resolutions []targetresolver.UserResolution - failure map[string]any + requested bool + applied bool + inputs []string + inputMode string + stableIDs map[string]bool + hasUserIDs bool + hasOpenIDs bool + resolutions []targetresolver.UserResolution + resolutionFailure map[string]any + resolutionErr error + scopeErr error } -// resolveOptionalChatMessagesSenderFilter is deliberately local to -// +chat-messages. Sender resolution is an optional post-read filter here: an -// unresolved name must not suppress the command's primary conversation-list -// result. +search-msg keeps its stricter semantics because sender identity is -// part of the lower search request there. +// resolveOptionalChatMessagesSenderFilter accepts the same mixed user target +// as +search-msg while retaining --sender-query as a compatibility entry. It +// resolves only explicitly requested senders; it never enumerates every member +// or tries to infer a person from message display names. func resolveOptionalChatMessagesSenderFilter(rt *shortcut.RuntimeContext) chatMessagesSenderFilter { - queries := rt.StrSlice("sender-query") + direct := uniqueChatMessageTargets(rt.StrSlice("sender")) + queries := uniqueChatMessageTargets(rt.StrSlice("sender-query")) + inputs := direct + inputMode := "sender" + if len(inputs) == 0 { + inputs = queries + inputMode = "sender-query" + } filter := chatMessagesSenderFilter{ - requested: len(queries) > 0, + requested: len(inputs) > 0, + inputs: inputs, + inputMode: inputMode, stableIDs: map[string]bool{}, } if !filter.requested { return filter } - resolutions, err := targetresolver.ResolveUsers(rt, queries, targetresolver.IdentityAny) + var resolutions []targetresolver.UserResolution + var err error + if len(direct) > 0 { + for _, value := range direct { + var resolved targetresolver.UserResolution + resolved, err = targetresolver.ResolveSenderTarget(rt, value, targetresolver.IdentityAny) + if err != nil { + break + } + resolutions = append(resolutions, resolved) + } + } else { + resolutions, err = targetresolver.ResolveUsers(rt, queries, targetresolver.IdentityAny) + } if err != nil { failure := map[string]any{ - "stage": "sender_resolution", - "queries": queries, - "error": err.Error(), + "stage": "sender_resolution", + "inputs": inputs, + "mode": inputMode, + "error": err.Error(), } var typed *apperrors.Error if errors.As(err, &typed) { if typed.Reason != "" { failure["reason"] = typed.Reason } + if typed.Hint != "" { + failure["hint"] = typed.Hint + } if len(typed.Details) > 0 { failure["details"] = typed.Details } } - filter.failure = failure + filter.resolutionFailure = failure + filter.resolutionErr = err return filter } filter.resolutions = resolutions - for _, resolution := range resolutions { - for _, identity := range []string{ - resolution.Selected.UserID, - resolution.Selected.OpenDingTalkID, - } { - if identity = strings.TrimSpace(identity); identity != "" { - filter.stableIDs[identity] = true - } - } - } - // ResolveUsers(IdentityAny) only returns users extracted with at least one - // stable userId/openDingTalkId and fails when no resolvable user remains. + filter.hasUserIDs, filter.hasOpenIDs = addChatMessagesResolvedSenderIDs(filter.stableIDs, resolutions) filter.applied = true return filter } -// applyOptionalChatMessagesSenderFilter preserves the unfiltered message list -// when optional resolution fails. Successful resolution filters both the +func uniqueChatMessageTargets(values []string) []string { + seen := map[string]bool{} + result := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + continue + } + seen[value] = true + result = append(result, value) + } + return result +} + +func addChatMessagesResolvedSenderIDs(ids map[string]bool, resolutions []targetresolver.UserResolution) (bool, bool) { + hasUserIDs := false + hasOpenIDs := false + for _, resolution := range resolutions { + if identity := strings.TrimSpace(resolution.Selected.UserID); identity != "" { + ids[identity] = true + hasUserIDs = true + } + if identity := strings.TrimSpace(resolution.Selected.OpenDingTalkID); identity != "" { + ids[identity] = true + hasOpenIDs = true + } + } + return hasUserIDs, hasOpenIDs +} + +// applyOptionalChatMessagesSenderFilter fails closed when an explicitly +// requested sender cannot be resolved. Successful resolution filters both the // public projection and raw rows so exports and resource downloads cannot // accidentally include messages outside the requested sender set. func applyOptionalChatMessagesSenderFilter( rt *shortcut.RuntimeContext, payload map[string]any, rawItems []map[string]any, - filter chatMessagesSenderFilter, + filter *chatMessagesSenderFilter, ) []map[string]any { - if !filter.requested || payload == nil { + if filter == nil || !filter.requested || payload == nil { return rawItems } if !filter.applied { failures, _ := payload["failures"].([]map[string]any) priorFailures := len(failures) - failures = append(failures, filter.failure) + failure := filter.resolutionFailure + if failure == nil { + failure = map[string]any{ + "stage": "sender_resolution", + "inputs": filter.inputs, + "mode": filter.inputMode, + "error": "无法把发送者目标唯一解析为稳定身份", + } + } + failures = append(failures, failure) payload["failures"] = failures payload["failedCount"] = len(failures) payload["complete"] = false - payload["partial"] = len(rawItems) > 0 + payload["partial"] = false + payload["messages"] = []map[string]any{} + payload["count"] = 0 + payload["senderFilter"] = map[string]any{ + "requested": true, + "inputs": filter.inputs, + "mode": filter.inputMode, + "applied": false, + "status": "resolution_failed", + "suppressedCount": len(rawItems), + } + payload["identityResult"] = map[string]any{ + "status": "resolution_failed", + "nameComparisonAllowed": false, + "stableIdField": "senderId", + "hint": "当前发送者尚未解析为稳定身份,不能根据 sender 展示名判断该人员是否发过消息。请先完成人员解析。", + } if priorFailures == 0 { payload["stopReason"] = "sender_resolution_failed" } - return rawItems + return nil } filtered := make([]map[string]any, 0, len(rawItems)) + unverifiableMessageIDs := make([]string, 0) for _, item := range rawItems { identity := strings.TrimSpace(fmt.Sprint(chatmsg.SenderID(item))) - if identity != "" && identity != "" && filter.stableIDs[identity] { - filtered = append(filtered, item) + if identity == "" || identity == "" { + messageID := chatmsg.StableMessageID(item) + if messageID == "" { + messageID = "" + } + unverifiableMessageIDs = append(unverifiableMessageIDs, messageID) + continue } + if filter.stableIDs[identity] { + filtered = append(filtered, item) + continue + } + if targetresolver.LooksLikeCurrentDOpenDingTalkID(identity) { + if !filter.hasOpenIDs { + unverifiableMessageIDs = append(unverifiableMessageIDs, chatMessagesMessageID(item)) + } + } else if !filter.hasUserIDs { + unverifiableMessageIDs = append(unverifiableMessageIDs, chatMessagesMessageID(item)) + } + } + if len(unverifiableMessageIDs) > 0 { + filter.scopeErr = apperrors.NewAPI( + "部分消息缺少 senderId,无法证明发送者过滤范围;已阻止完整成功与导出", + apperrors.WithReason("chat_messages_sender_scope_unverified"), + apperrors.WithRetryable(false), + apperrors.WithHint("请保留 trace_id 并检查消息读取服务是否返回稳定 senderId"), + apperrors.WithDetails(map[string]any{ + "requestedSenders": filter.inputs, + "unverifiableMessageIds": uniqueChatMessageTargets(unverifiableMessageIDs), + }), + ) + failures, _ := payload["failures"].([]map[string]any) + failures = append(failures, map[string]any{ + "stage": "sender_scope_verification", + "error": filter.scopeErr.Error(), + "unverifiableMessageIds": uniqueChatMessageTargets(unverifiableMessageIDs), + }) + payload["failures"] = failures + payload["failedCount"] = len(failures) + payload["complete"] = false + payload["partial"] = len(filtered) > 0 } payload["messages"] = projectChatMessages(filtered, !rt.Bool("no-reactions")) payload["count"] = len(filtered) payload["resolvedFilters"] = map[string]any{"senders": filter.resolutions} + filterStatus := "applied" + if filter.scopeErr != nil { + filterStatus = "scope_unverified" + } + payload["senderFilter"] = map[string]any{ + "requested": true, + "inputs": filter.inputs, + "mode": filter.inputMode, + "applied": true, + "status": filterStatus, + } + identityResult := map[string]any{ + "status": "evaluated", + "nameComparisonAllowed": false, + "negativeConclusionAllowed": true, + "stableIdField": "senderId", + } + if filter.scopeErr != nil { + identityResult["status"] = "scope_unverified" + identityResult["negativeConclusionAllowed"] = false + identityResult["hint"] = "部分消息缺少稳定 senderId,当前结果不能用于断言该人员没有发过消息。" + } + payload["identityResult"] = identityResult return filtered } +func chatMessagesMessageID(item map[string]any) string { + messageID := chatmsg.StableMessageID(item) + if messageID == "" { + return "" + } + return messageID +} + +func attachUnfilteredSenderIdentitySemantics(payload map[string]any, filter chatMessagesSenderFilter) { + if payload == nil || filter.requested { + return + } + payload["senderFilter"] = map[string]any{ + "requested": false, + "applied": false, + "status": "not_requested", + } + payload["identityResult"] = map[string]any{ + "status": "requires_query_check", + "negativeConclusionAllowed": false, + "hint": "若原始任务包含自然人发送者条件,请先将目标人解析为唯一 userId/openDingTalkId,再与 messages[].senderId 比较;禁止直接比较 messages[].sender 展示名。", + } +} + func resolveChatMessagesRequest(rt *shortcut.RuntimeContext) (chatMessagesRequest, error) { groupID := strings.TrimSpace(rt.StrFirst("conversation-id", "id", "open-conversation-id")) userID := rt.Str("user") @@ -331,6 +495,11 @@ func resolveChatMessagesRequest(rt *shortcut.RuntimeContext) (chatMessagesReques "--open-dingtalk-id 收到的是群 openConversationId;群聊请改用 --group(兼容别名 --chat)", ) } + if openID != "" { + if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openID); err != nil { + return chatMessagesRequest{}, err + } + } if groupID == "" && (rt.Str("group") != "" || rt.Str("chat-query") != "") { resolved, err := targetresolver.ResolveChatTarget(rt, rt.Str("group"), rt.Str("chat-query")) if err != nil { @@ -408,7 +577,8 @@ func executeChatMessages(rt *shortcut.RuntimeContext) error { return err } senderFilter := resolveOptionalChatMessagesSenderFilter(rt) - rawItems = applyOptionalChatMessagesSenderFilter(rt, payload, rawItems, senderFilter) + rawItems = applyOptionalChatMessagesSenderFilter(rt, payload, rawItems, &senderFilter) + attachUnfilteredSenderIdentitySemantics(payload, senderFilter) if err != nil { // Full-page collection returns its failure ledger together with a // non-zero error. Publish that ledger for diagnosis, but stop before @@ -420,6 +590,22 @@ func executeChatMessages(rt *shortcut.RuntimeContext) error { } return err } + if senderFilter.requested && !senderFilter.applied { + if payload != nil { + if outputErr := rt.Output(payload); outputErr != nil { + return outputErr + } + } + return senderFilter.resolutionErr + } + if senderFilter.scopeErr != nil { + if payload != nil { + if outputErr := rt.Output(payload); outputErr != nil { + return outputErr + } + } + return senderFilter.scopeErr + } if rt.Bool("download-resources") { chatshortcut.AttachMessageResourceDownloads( payload, diff --git a/internal/shortcut/smart/chat_messages_test.go b/internal/shortcut/smart/chat_messages_test.go index 0c3918c9..66ca0679 100644 --- a/internal/shortcut/smart/chat_messages_test.go +++ b/internal/shortcut/smart/chat_messages_test.go @@ -20,6 +20,7 @@ import ( stderrors "errors" "math" "os" + "reflect" "testing" "time" @@ -546,7 +547,7 @@ func TestCrossPlatformCoverageChatMessagesAdditionalValidationAndHelpers(t *test filter := resolveOptionalChatMessagesSenderFilter(chatMessagesRuntimeForTest(t, map[string]string{ "sender-query": "测试用户甲", })) - if filter.applied || filter.failure == nil { + if filter.applied || filter.resolutionFailure == nil { t.Fatalf("identity-free sender resolution = %#v", filter) } @@ -577,6 +578,148 @@ func TestCrossPlatformCoverageChatMessagesAdditionalValidationAndHelpers(t *test } } +func TestChatMessagesSenderScopeRejectsUnmappedIdentityFamily(t *testing.T) { + filter := chatMessagesSenderFilter{ + requested: true, + applied: true, + inputs: []string{"DAAAAAAAAAAAiE"}, + inputMode: "sender", + stableIDs: map[string]bool{"DAAAAAAAAAAAiE": true}, + hasOpenIDs: true, + } + payload := map[string]any{ + "complete": true, + "failures": []map[string]any{}, + } + filtered := applyOptionalChatMessagesSenderFilter( + chatMessagesRuntimeForTest(t, nil), + payload, + []map[string]any{{"openMessageId": "m-user-family", "senderUserId": "user-1"}}, + &filter, + ) + if len(filtered) != 0 || filter.scopeErr == nil || payload["complete"] != false { + t.Fatalf("filtered=%#v filter=%#v payload=%#v", filtered, filter, payload) + } +} + +func TestCrossPlatformCoverageChatMessagesSenderFilterFailureEdges(t *testing.T) { + t.Run("direct resolution stops after first error", func(t *testing.T) { + fake := &platformCoverageCaller{failTool: "contact/search_contact_by_key_word"} + helpers.InitDeps(fake) + filter := resolveOptionalChatMessagesSenderFilter(chatMessagesRuntimeForTest(t, map[string]string{ + "sender": testCurrentDOpenID + ",fixture-name,ignored-name", + })) + if filter.applied || filter.resolutionErr == nil || len(fake.calls) != 1 { + t.Fatalf("filter=%#v calls=%#v", filter, fake.calls) + } + }) + + t.Run("dedupes blanks and repeated values", func(t *testing.T) { + got := uniqueChatMessageTargets([]string{"", " ", "one", "one", " two "}) + if want := []string{"one", "two"}; !reflect.DeepEqual(got, want) { + t.Fatalf("targets=%#v want=%#v", got, want) + } + }) + + t.Run("synthesizes failure when none was supplied", func(t *testing.T) { + payload := map[string]any{"failures": []map[string]any{{"stage": "existing"}}} + filter := chatMessagesSenderFilter{requested: true, inputs: []string{"fixture"}, inputMode: "sender"} + if got := applyOptionalChatMessagesSenderFilter( + chatMessagesRuntimeForTest(t, nil), payload, []map[string]any{{"openMessageId": "hidden"}}, &filter, + ); got != nil { + t.Fatalf("filtered=%#v", got) + } + if _, exists := payload["stopReason"]; exists { + t.Fatalf("existing failure should retain stop reason ownership: %#v", payload) + } + }) + + t.Run("rejects missing and unmapped identity families", func(t *testing.T) { + payload := map[string]any{"complete": true, "failures": []map[string]any{}} + filter := chatMessagesSenderFilter{ + requested: true, + applied: true, + inputs: []string{"fixture"}, + inputMode: "sender", + stableIDs: map[string]bool{"wanted-user": true}, + hasUserIDs: true, + } + filtered := applyOptionalChatMessagesSenderFilter( + chatMessagesRuntimeForTest(t, nil), + payload, + []map[string]any{ + {"content": "missing sender and id"}, + {"openMessageId": "wanted", "senderUserId": "wanted-user"}, + {"openMessageId": "open-family", "senderOpenDingTalkId": testCurrentDOpenID}, + }, + &filter, + ) + if len(filtered) != 1 || filter.scopeErr == nil || chatMessagesMessageID(map[string]any{}) != "" || chatMessagesMessageID(filtered[0]) != "wanted" { + t.Fatalf("filtered=%#v filter=%#v payload=%#v", filtered, filter, payload) + } + }) + + t.Run("rejects user identity when only open ids were resolved", func(t *testing.T) { + payload := map[string]any{"complete": true, "failures": []map[string]any{}} + filter := chatMessagesSenderFilter{ + requested: true, + applied: true, + inputs: []string{testCurrentDOpenID}, + inputMode: "sender", + stableIDs: map[string]bool{testCurrentDOpenID: true}, + hasOpenIDs: true, + } + applyOptionalChatMessagesSenderFilter( + chatMessagesRuntimeForTest(t, nil), payload, + []map[string]any{{"senderUserId": "other-user"}}, &filter, + ) + if filter.scopeErr == nil { + t.Fatalf("filter=%#v", filter) + } + }) + + if _, err := resolveChatMessagesRequest(chatMessagesRuntimeForTest(t, map[string]string{ + "open-dingtalk-id": "not-an-open-id", + })); err == nil { + t.Fatal("invalid explicit open ID unexpectedly accepted") + } +} + +func TestCrossPlatformCoverageChatMessagesSenderFailureOutputEdges(t *testing.T) { + for _, tc := range []struct { + name string + sender string + }{ + {name: "resolution", sender: "fixture-name"}, + {name: "scope", sender: testCurrentDOpenID}, + } { + t.Run(tc.name+" returns domain error", func(t *testing.T) { + caller := &chatMessagesPagingCaller{responses: []string{ + `{"result":{"hasMore":false,"messages":[{"openMessageId":"m-without-sender"}]}}`, + }} + helpers.InitDeps(caller) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+chat-messages", "--conversation-id", "cid", "--sender", tc.sender}) + if err := root.Execute(); err == nil { + t.Fatal("sender failure unexpectedly succeeded") + } + }) + + t.Run(tc.name+" prioritizes output error", func(t *testing.T) { + caller := &chatMessagesPagingCaller{responses: []string{ + `{"result":{"hasMore":false,"messages":[{"openMessageId":"m-without-sender"}]}}`, + }} + helpers.InitDeps(caller) + root := newPlatformCoverageRoot() + root.SetOut(chatMessagesFailWriter{}) + root.SetArgs([]string{"chat", "+chat-messages", "--conversation-id", "cid", "--sender", tc.sender}) + if err := root.Execute(); err == nil || err.Error() != "fixture output failure" { + t.Fatalf("error=%v", err) + } + }) + } +} + func TestCrossPlatformCoverageChatMessagesAdditionalCollectionEdges(t *testing.T) { runtimeWith := func(t *testing.T, caller *chatMessagesPagingCaller, values map[string]string) *shortcut.RuntimeContext { t.Helper() diff --git a/internal/shortcut/smart/coverage_completion_test.go b/internal/shortcut/smart/coverage_completion_test.go index 4503d79e..61fbe8bc 100644 --- a/internal/shortcut/smart/coverage_completion_test.go +++ b/internal/shortcut/smart/coverage_completion_test.go @@ -105,7 +105,7 @@ func TestCrossPlatformCoverageChatMessagesOpenIDRoute(t *testing.T) { }} helpers.InitDeps(caller) root := newPlatformCoverageRoot() - root.SetArgs([]string{"chat", "+chat-messages", "--open-dingtalk-id", "D-user", "--limit", "1", "--yes"}) + root.SetArgs([]string{"chat", "+chat-messages", "--open-dingtalk-id", testCurrentDOpenID, "--limit", "1", "--yes"}) if err := root.Execute(); err != nil { t.Fatal(err) } diff --git a/internal/shortcut/smart/natural_target_resolution_test.go b/internal/shortcut/smart/natural_target_resolution_test.go index df5bc3ae..f6704eb9 100644 --- a/internal/shortcut/smart/natural_target_resolution_test.go +++ b/internal/shortcut/smart/natural_target_resolution_test.go @@ -22,7 +22,7 @@ func TestCrossPlatformCoverageNaturalSearchAndSenderFlagsStayPublicOptional(t *t want []string }{ {name: "search natural adapters", flags: SearchMsg.Flags, want: []string{"chat-query", "sender-query"}}, - {name: "chat messages natural adapters", flags: ChatMessages.Flags, want: []string{"chat-query", "sender-query"}}, + {name: "chat messages natural adapters", flags: ChatMessages.Flags, want: []string{"chat-query", "sender", "sender-query"}}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -160,7 +160,7 @@ func TestCrossPlatformCoverageChatMessagesOptionallyFiltersResolvedSenderByEithe root.SetOut(&output) root.SetArgs([]string{ "chat", "+chat-messages", "--group", "cid-fixture-chat-0001", - "--sender-query", "测试用户甲", "--page-all", + "--sender", "测试用户甲", "--page-all", }) if err := root.Execute(); err != nil { t.Fatal(err) @@ -201,7 +201,64 @@ func TestCrossPlatformCoverageChatMessagesOptionallyFiltersResolvedSenderByEithe } } -func TestCrossPlatformCoverageChatMessagesSenderResolutionFailureKeepsUnfilteredMessages(t *testing.T) { +func TestChatMessagesFormatValidSenderSkipsDirectoryAndFiltersStableID(t *testing.T) { + fake := &platformCoverageCaller{ + chatMessagesResult: `{"result":{"hasMore":false,"messages":[ + {"openMessageId":"wanted","sender":"目标展示名","senderOpenDingTalkId":"DAAAAAAAAAAAiE","content":"保留"}, + {"openMessageId":"other","sender":"其他人","senderOpenDingTalkId":"DAQEBAQEBAQEiE","content":"过滤"} + ]}}`, + } + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{ + "chat", "+chat-messages", "--group", "cid-fixture-chat-0001", + "--sender", "DAAAAAAAAAAAiE", "--page-all", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 1 || fake.calls[0].tool != "list_conversation_message_v2" { + t.Fatalf("format-valid sender unexpectedly preflighted: %#v", fake.calls) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["count"] != float64(1) || payload["complete"] != true { + t.Fatalf("payload=%#v", payload) + } +} + +func TestChatMessagesWithoutSenderDoesNotResolveEveryMessageIdentity(t *testing.T) { + fake := &platformCoverageCaller{chatMessagesResult: `{"result":{"hasMore":false,"messages":[ + {"openMessageId":"m1","sender":"群昵称一","senderOpenDingTalkId":"D1","content":"一"}, + {"openMessageId":"m2","sender":"群昵称二","senderOpenDingTalkId":"D2","content":"二"} + ]}}`} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{"chat", "+chat-messages", "--group", "cid-fixture-chat-0001", "--page-all"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 1 || fake.calls[0].tool != "list_conversation_message_v2" { + t.Fatalf("calls=%#v", fake.calls) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + identity := payload["identityResult"].(map[string]any) + if payload["count"] != float64(2) || identity["status"] != "requires_query_check" || + identity["negativeConclusionAllowed"] != false { + t.Fatalf("payload=%#v", payload) + } +} + +func TestCrossPlatformCoverageChatMessagesSenderResolutionFailureStopsWithoutUnfilteredMessages(t *testing.T) { fake := &platformCoverageCaller{ contactSearchResult: `{"result":[]}`, chatMessagesResult: `{"result":{"hasMore":false,"messages":[ @@ -216,22 +273,26 @@ func TestCrossPlatformCoverageChatMessagesSenderResolutionFailureKeepsUnfiltered "chat", "+chat-messages", "--group", "cid-fixture-chat-0001", "--sender-query", "不存在的人", "--page-all", }) - if err := root.Execute(); err != nil { - t.Fatalf("optional sender failure stopped message read: %v", err) + if err := root.Execute(); err == nil { + t.Fatal("sender resolution failure unexpectedly succeeded") } if len(fake.calls) != 2 || fake.calls[0].tool != "list_conversation_message_v2" || fake.calls[1].tool != "search_contact_by_key_word" { - t.Fatalf("calls = %#v, want message read followed by non-blocking failed resolve", fake.calls) + t.Fatalf("calls = %#v, want message read followed by failed resolve", fake.calls) } var payload map[string]any if err := json.Unmarshal(output.Bytes(), &payload); err != nil { t.Fatalf("decode output: %v\n%s", err, output.String()) } - if payload["count"] != float64(1) || payload["complete"] != false || - payload["partial"] != true || payload["failedCount"] != float64(1) || + if payload["count"] != float64(0) || payload["complete"] != false || + payload["partial"] != false || payload["failedCount"] != float64(1) || payload["stopReason"] != "sender_resolution_failed" { - t.Fatalf("unfiltered fallback payload = %#v", payload) + t.Fatalf("fail-closed payload = %#v", payload) + } + messages, ok := payload["messages"].([]any) + if !ok || len(messages) != 0 { + t.Fatalf("failed resolution leaked unfiltered messages: %#v", payload["messages"]) } if _, exists := payload["resolvedFilters"]; exists { t.Fatalf("failed resolution unexpectedly published resolvedFilters: %#v", payload) diff --git a/internal/shortcut/smart/search_msg.go b/internal/shortcut/smart/search_msg.go index 48b15ec7..2224d542 100644 --- a/internal/shortcut/smart/search_msg.go +++ b/internal/shortcut/smart/search_msg.go @@ -35,7 +35,7 @@ import ( // search hits through list_messages_by_ids in chunks of 50. A later-page or // enrichment failure never turns a partial result into a false success: the // output carries an explicit failure ledger and complete=false. -const searchMsgIntent = "当你要按关键词、发送者、@对象、消息类型、机器人来源或会话范围组合搜索 IM 消息时使用;可搜索单个、多个或全部会话,会话与发送者过滤使用稳定 ID。默认查询近 7 天,也可指定精确起止时间和输出顺序。" + +const searchMsgIntent = "当你要按关键词、发送者、@对象、消息类型、机器人来源或会话范围组合搜索 IM 消息时使用;可搜索单个、多个或全部会话。--group/--groups 接受群名或 openConversationId,--sender/--senders 接受姓名、userId 或 openDingTalkId,并统一唯一解析为稳定 ID。默认查询近 7 天,也可指定精确起止时间和输出顺序。" + "显式指定会话时会先验证 CID,再执行有界全局扫描并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID。" + "--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。" + "--download-resources 使用安全本地路径、默认不覆盖和原子落盘。" @@ -70,8 +70,8 @@ var SearchMsg = shortcut.Shortcut{ UseWhen: []string{searchMsgIntent}, AvoidWhen: []string{"只想查看或导出一个指定会话的消息记录、且没有发送者、关键词、@对象或消息类型等主要筛选条件时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget"}, Examples: []string{ + "dws chat +search-msg --group \"项目群\" --sender \"测试用户甲\" --page-all", "dws chat +search-msg --query \"周报\" --senders --days 3 --page-all", - "dws chat +search-msg --query \"周报\" --senders --days 3 --page-all --jq '.messages[] | {messageId, text}'", }, }, }, @@ -80,15 +80,15 @@ var SearchMsg = shortcut.Shortcut{ {Name: "keyword", Type: shortcut.FlagString, Desc: "--query 的别名", Hidden: true}, {Name: "text", Type: shortcut.FlagString, Desc: "--query 的兼容别名", Hidden: true}, {Name: "text-query", Type: shortcut.FlagString, Desc: "--query 的兼容别名", Hidden: true}, - {Name: "group", Type: shortcut.FlagString, Desc: "单个会话 openConversationId"}, + {Name: "group", Type: shortcut.FlagString, Desc: "单个群名或 openConversationId;自动唯一解析并校验"}, {Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true}, {Name: "id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true}, - {Name: "groups", Type: shortcut.FlagStringSlice, Desc: "多个会话 openConversationId"}, - {Name: "chat-id", Type: shortcut.FlagStringSlice, Desc: "--groups 的 lark-cli 对齐别名"}, - {Name: "chat-query", Type: shortcut.FlagStringSlice, Desc: "按群名唯一解析会话过滤条件(可选,可重复或逗号分隔)"}, - {Name: "senders", Type: shortcut.FlagStringSlice, Desc: "发送者 userId/openDingTalkId 列表"}, - {Name: "sender", Type: shortcut.FlagStringSlice, Desc: "--senders 的 lark-cli 对齐别名"}, - {Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "按姓名唯一解析发送者过滤条件(可选,可重复或逗号分隔)"}, + {Name: "groups", Type: shortcut.FlagStringSlice, Desc: "多个群名或 openConversationId;可混合输入并逐项唯一解析"}, + {Name: "chat-id", Type: shortcut.FlagStringSlice, Desc: "--groups 的 lark-cli 对齐别名;只接受 openConversationId"}, + {Name: "chat-query", Type: shortcut.FlagStringSlice, Desc: "显式按群名唯一解析的兼容入口(可选,可重复或逗号分隔)"}, + {Name: "senders", Type: shortcut.FlagStringSlice, Desc: "多个发送者姓名、userId 或 openDingTalkId;可混合输入并逐项唯一解析"}, + {Name: "sender", Type: shortcut.FlagStringSlice, Desc: "单个或多个发送者姓名、userId 或 openDingTalkId;--senders 的兼容别名"}, + {Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "显式按姓名唯一解析的兼容入口(可选,可重复或逗号分隔)"}, {Name: "at-me", Type: shortcut.FlagBool, Desc: "只搜索 @我 的消息"}, {Name: "is-at-me", Type: shortcut.FlagBool, Desc: "--at-me 的 lark-cli 对齐别名"}, {Name: "at-ids", Type: shortcut.FlagStringSlice, Desc: "@对象 userId/openDingTalkId 列表"}, @@ -140,6 +140,7 @@ var SearchMsg = shortcut.Shortcut{ {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"cursor", "page-token"}}, }, chatshortcut.MessageResourceDownloadConstraints()...), Tips: []string{ + `dws chat +search-msg --group "项目群" --sender "测试用户甲" --page-all`, `dws chat +search-msg --group --query "changefree"`, `dws chat +search-msg --senders --at-me --days 3 --page-all`, `dws chat +search-msg --group --query "changefree" --jq '.messages[] | {messageId, text}'`, @@ -270,6 +271,13 @@ var SearchMsg = shortcut.Shortcut{ } messages = validatedMessages } + if len(resolvedFilters.Senders) > 0 { + var unverifiableMessageIDs []string + messages, unverifiableMessageIDs = filterSearchSenderScope(messages, resolvedFilters.Senders) + if len(unverifiableMessageIDs) > 0 { + return searchSenderScopeUnverifiedError(resolvedFilters.Senders, unverifiableMessageIDs) + } + } order := strings.ToLower(strings.TrimSpace(rt.StrFirst("order", "sort"))) if order == "" { @@ -293,9 +301,19 @@ var SearchMsg = shortcut.Shortcut{ "failedCount": len(failures), "failures": failures, "queryRange": searchMessageQueryRange(params, order), + "timeCoverage": searchMessageTimeCoverage(rt), + "conclusionGuard": searchMessageConclusionGuard(rt, complete, len(results)), + } + if len(resolvedFilters.Chats) > 0 || len(resolvedFilters.Senders) > 0 { + payload["resolvedFilters"] = resolvedFilters } if len(resolvedFilters.Senders) > 0 { - payload["resolvedFilters"] = resolvedFilters + payload["senderScope"] = map[string]any{ + "targetsResolved": true, + "filterApplied": true, + "filterMode": "server_and_client", + "resultsWithinScope": true, + } } if scopedSearch { payload["scope"] = searchScopePayload(requestedConversationIDs, paginationKnown && !hasMore) @@ -313,6 +331,39 @@ var SearchMsg = shortcut.Shortcut{ }, } +func searchMessageTimeCoverage(rt *shortcut.RuntimeContext) map[string]any { + source := "implicit_default" + if rt.Changed("start") || rt.Changed("start-time") || rt.Changed("end") || rt.Changed("end-time") { + source = "explicit_range" + } else if rt.Changed("days") { + source = "explicit_days" + } + + coverage := map[string]any{ + "source": source, + "allHistory": false, + } + if source != "explicit_range" { + coverage["days"] = rt.Int("days") + } + return coverage +} + +func searchMessageConclusionGuard(rt *shortcut.RuntimeContext, complete bool, resultCount int) map[string]any { + coverage := searchMessageTimeCoverage(rt) + guard := map[string]any{ + "absenceWithinQueryRangeAllowed": complete, + "absenceAcrossAllHistoryAllowed": false, + "allHistoryCountAllowed": false, + "requiredActionForAllHistory": "widen_time_range_or_reuse_complete_message_ledger", + } + if coverage["source"] == "implicit_default" && resultCount == 0 { + guard["requiredAction"] = "widen_time_range_or_reuse_complete_message_ledger" + guard["hint"] = "当前只证明默认 7 天时间窗内没有命中,不能据此判断全部历史没有。若已有完整会话消息,请解析稳定身份后复用现有消息;否则扩大时间范围。" + } + return guard +} + func validateSearchMsgWithResources(rt *shortcut.RuntimeContext) error { if err := validateSearchMsg(rt); err != nil { return err @@ -350,6 +401,39 @@ func validateSearchMsg(rt *shortcut.RuntimeContext) error { if pageLimit := rt.Int("page-limit"); pageLimit < 1 || pageLimit > 40 { return apperrors.NewValidation("--page-limit 必须在 1-40 之间") } + if err := validateSearchMsgStrictConversationAliases(rt); err != nil { + return err + } + return nil +} + +// The conventional --group/--groups flags are intentionally dual-form. Only +// the explicit ID aliases remain strict so scripts that select them retain a +// deterministic contract. +func validateSearchMsgStrictConversationAliases(rt *shortcut.RuntimeContext) error { + values := append([]string{}, rt.StrSlice("chat-id")...) + if value := rt.StrFirst("conversation-id", "id"); value != "" { + values = append(values, value) + } + for _, value := range uniqueSearchStrings(values) { + if strings.HasPrefix(strings.ToLower(value), "cid") { + continue + } + return apperrors.NewValidation( + fmt.Sprintf("显式会话 ID 参数收到非 openConversationId 值 %q;已停止消息搜索", value), + apperrors.WithReason("target_type_mismatch"), + apperrors.WithOrigin("client"), + apperrors.WithFailureStage("request_validation"), + apperrors.WithExecutionStarted(false), + apperrors.WithRetryable(false), + apperrors.WithHint(fmt.Sprintf("--chat-id/--conversation-id/--id 只接受 cid 开头的 openConversationId;群名请使用 --group %q 或 --groups %q。", value, value)), + apperrors.WithDetails(map[string]any{ + "flags": []string{"chat-id", "conversation-id", "id"}, + "expectedType": "openConversationId", + "providedValue": value, + }), + ) + } return nil } @@ -358,6 +442,7 @@ func validateSearchMsg(rt *shortcut.RuntimeContext) error { // the directory name, so consumers must join the selected stable identity to // each projected message's senderId instead of comparing those two names. type searchResolvedFilters struct { + Chats []targetresolver.ChatResolution `json:"chats,omitempty"` Senders []targetresolver.UserResolution `json:"senders,omitempty"` } @@ -367,41 +452,61 @@ func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, searchResolve if value := rt.StrFirst("query", "keyword", "text", "text-query"); value != "" { params["keyword"] = value } - conversationIDs := append([]string{}, rt.StrSlice("groups")...) - conversationIDs = append(conversationIDs, rt.StrSlice("chat-id")...) - if value := rt.StrFirst("group", "conversation-id", "id"); value != "" { + conversationIDs := append([]string{}, rt.StrSlice("chat-id")...) + if value := rt.StrFirst("conversation-id", "id"); value != "" { conversationIDs = append(conversationIDs, value) } + groupTargets := append([]string{}, rt.StrSlice("groups")...) + if value := rt.Str("group"); value != "" { + groupTargets = append(groupTargets, value) + } + for _, target := range uniqueSearchStrings(groupTargets) { + if strings.HasPrefix(strings.ToLower(target), "cid") { + conversationIDs = append(conversationIDs, target) + continue + } + resolved, err := targetresolver.ResolveChatTarget(rt, target, "") + if err != nil { + return nil, searchResolvedFilters{}, err + } + resolvedFilters.Chats = append(resolvedFilters.Chats, resolved) + conversationIDs = append(conversationIDs, resolved.Selected.OpenConversationID) + } if queries := rt.StrSlice("chat-query"); len(queries) > 0 { for _, query := range queries { resolved, err := targetresolver.ResolveChatTarget(rt, "", query) if err != nil { return nil, searchResolvedFilters{}, err } + resolvedFilters.Chats = append(resolvedFilters.Chats, resolved) conversationIDs = append(conversationIDs, resolved.Selected.OpenConversationID) } } if values := uniqueSearchStrings(conversationIDs); len(values) > 0 { params["openConversationIds"] = values } - senders := append([]string{}, rt.StrSlice("senders")...) - senders = append(senders, rt.StrSlice("sender")...) + senderTargets := append([]string{}, rt.StrSlice("senders")...) + senderTargets = append(senderTargets, rt.StrSlice("sender")...) + for _, target := range uniqueSearchStrings(senderTargets) { + resolved, err := targetresolver.ResolveSenderTarget(rt, target, targetresolver.IdentityAny) + if err != nil { + return nil, searchResolvedFilters{}, err + } + resolvedFilters.Senders = append(resolvedFilters.Senders, resolved) + } if queries := rt.StrSlice("sender-query"); len(queries) > 0 { resolvedUsers, err := targetresolver.ResolveUsers(rt, queries, targetresolver.IdentityAny) if err != nil { return nil, searchResolvedFilters{}, err } resolvedFilters.Senders = append(resolvedFilters.Senders, resolvedUsers...) - for _, resolved := range resolvedUsers { - identity := resolved.Selected.OpenDingTalkID - if identity == "" { - identity = resolved.Selected.UserID - } - senders = append(senders, identity) - } } - appendSearchActorIDs(params, senders, "senderUserIds", "senderOpenDingTakIds") - appendSearchActorIDs(params, rt.StrSlice("at-ids"), "atUserIds", "atOpenDingTakIds") + appendResolvedSearchActorIDs(params, resolvedFilters.Senders, "senderUserIds", "senderOpenDingTakIds") + atUsers, err := resolveSearchStableActorTargets(rt, rt.StrSlice("at-ids")) + if err != nil { + return nil, searchResolvedFilters{}, err + } + appendResolvedSearchActorIDs(params, atUsers, "atUserIds", "atOpenDingTakIds") if rt.Bool("at-me") || rt.Bool("is-at-me") { params["atMe"] = true } @@ -439,15 +544,41 @@ func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, searchResolve return params, resolvedFilters, nil } -func appendSearchActorIDs(params map[string]any, values []string, userKey, openIDKey string) { - var userIDs, openIDs []string +func resolveSearchStableActorTargets( + rt *shortcut.RuntimeContext, + values []string, +) ([]targetresolver.UserResolution, error) { + resolvedUsers := make([]targetresolver.UserResolution, 0, len(values)) for _, value := range uniqueSearchStrings(values) { - if len(value) > 0 && (value[0] == 'D' || value[0] == 'd') { - openIDs = append(openIDs, value) - } else { - userIDs = append(userIDs, value) + resolved, err := targetresolver.ResolveStableUserTarget(rt, value, targetresolver.IdentityAny) + if err != nil { + return nil, err + } + resolvedUsers = append(resolvedUsers, resolved) + } + return resolvedUsers, nil +} + +// appendResolvedSearchActorIDs prefers openDingTalkId whenever directory +// resolution supplies it because the current message-search backend reliably +// applies the open-ID field but may ignore senderUserIds. +func appendResolvedSearchActorIDs( + params map[string]any, + resolutions []targetresolver.UserResolution, + userKey, openIDKey string, +) { + var userIDs, openIDs []string + for _, resolved := range resolutions { + openID := strings.TrimSpace(resolved.Selected.OpenDingTalkID) + userID := strings.TrimSpace(resolved.Selected.UserID) + if openID != "" { + openIDs = append(openIDs, openID) + } else if userID != "" { + userIDs = append(userIDs, userID) } } + userIDs = uniqueSearchStrings(userIDs) + openIDs = uniqueSearchStrings(openIDs) if len(userIDs) > 0 { params[userKey] = userIDs } @@ -527,6 +658,79 @@ func searchScopePayload(conversationIDs []string, sourceComplete bool) map[strin } } +func filterSearchSenderScope( + messages []map[string]any, + resolutions []targetresolver.UserResolution, +) ([]map[string]any, []string) { + allowed := map[string]bool{} + hasUserIDs := false + hasOpenIDs := false + for _, resolution := range resolutions { + if value := strings.TrimSpace(resolution.Selected.UserID); value != "" { + allowed[value] = true + hasUserIDs = true + } + if value := strings.TrimSpace(resolution.Selected.OpenDingTalkID); value != "" { + allowed[value] = true + hasOpenIDs = true + } + } + + filtered := make([]map[string]any, 0, len(messages)) + unverifiable := make([]string, 0) + for _, message := range messages { + senderID := strings.TrimSpace(fmt.Sprint(chatmsg.SenderID(message))) + if senderID == "" || senderID == "" { + messageID := strings.TrimSpace(fmt.Sprint(searchMsgMessageID(message))) + if messageID == "" || messageID == "" { + messageID = "" + } + unverifiable = append(unverifiable, messageID) + continue + } + if allowed[senderID] { + filtered = append(filtered, message) + continue + } + if targetresolver.LooksLikeCurrentDOpenDingTalkID(senderID) { + if !hasOpenIDs { + unverifiable = append(unverifiable, searchScopeMessageID(message)) + } + } else if !hasUserIDs { + unverifiable = append(unverifiable, searchScopeMessageID(message)) + } + } + return filtered, uniqueSearchStrings(unverifiable) +} + +func searchScopeMessageID(message map[string]any) string { + messageID := strings.TrimSpace(fmt.Sprint(searchMsgMessageID(message))) + if messageID == "" || messageID == "" { + return "" + } + return messageID +} + +func searchSenderScopeUnverifiedError( + resolutions []targetresolver.UserResolution, + messageIDs []string, +) error { + queries := make([]string, 0, len(resolutions)) + for _, resolution := range resolutions { + queries = append(queries, resolution.Query) + } + return apperrors.NewAPI( + "搜索结果缺少 senderId,无法证明发送者过滤范围;已停止输出", + apperrors.WithReason("search_sender_scope_unverified"), + apperrors.WithDetails(map[string]any{ + "requestedSenders": uniqueSearchStrings(queries), + "unverifiableMessageIds": messageIDs, + }), + apperrors.WithRetryable(false), + apperrors.WithHint("请保留 trace_id 并检查 IM 搜索服务是否返回稳定 senderId"), + ) +} + func enrichSearchMessages(rt *shortcut.RuntimeContext, messages []map[string]any) ([]map[string]any, int, []map[string]any) { detailsByID := map[string]map[string]any{} failures := make([]map[string]any, 0) diff --git a/internal/shortcut/smart/search_msg_execution_test.go b/internal/shortcut/smart/search_msg_execution_test.go index ec83c2ff..161abe4e 100644 --- a/internal/shortcut/smart/search_msg_execution_test.go +++ b/internal/shortcut/smart/search_msg_execution_test.go @@ -15,25 +15,35 @@ import ( apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) type searchMsgExecutionCaller struct { - calls []platformCoverageCall - failSecondPage bool - failEnrichment bool - omitPagination bool - omitMgetItem bool - failPreflight bool - preflightError error - searchResponse string - wrongMgetScope bool - missingMgetCID bool - firstResponse string - mgetResponse string - numericZeroEnd bool + calls []platformCoverageCall + failSecondPage bool + failEnrichment bool + omitPagination bool + omitMgetItem bool + failPreflight bool + preflightError error + searchResponse string + wrongMgetScope bool + missingMgetCID bool + firstResponse string + mgetResponse string + numericZeroEnd bool + contactResponse string + groupResponse string + failContactKeyword string + failGroupKeyword string } +const ( + testCurrentDOpenID = "DAAAAAAAAAAAiE" + testCurrentDOpenID2 = "DAQEBAQEBAQEiE" +) + func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) { f.calls = append(f.calls, platformCoverageCall{product: product, tool: tool, args: args}) if product == "chat" && tool == "get_conversation_info" { @@ -45,10 +55,28 @@ func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool str } return searchMsgToolResult(`{"result":{"openConversationId":"` + args["openConversationId"].(string) + `"}}`), nil } + if product == "contact" && tool == "search_contact_by_key_word" { + if f.failContactKeyword != "" && args["keyword"] == f.failContactKeyword { + return nil, errors.New("fixture contact failure") + } + if f.contactResponse != "" { + return searchMsgToolResult(f.contactResponse), nil + } + keyword := args["keyword"].(string) + return searchMsgToolResult(`{"result":[{"userId":"user-` + keyword + `","openDingTalkId":"` + testCurrentDOpenID + `","name":"` + keyword + `"}],"hasMore":false}`), nil + } if product != "im" { return nil, errors.New("unexpected product") } switch tool { + case "search_groups": + if f.failGroupKeyword != "" && args["keyword"] == f.failGroupKeyword { + return nil, errors.New("fixture group failure") + } + if f.groupResponse != "" { + return searchMsgToolResult(f.groupResponse), nil + } + return searchMsgToolResult(`{"result":[{"openConversationId":"cid-resolved-group","title":"` + args["keyword"].(string) + `"}],"hasMore":false}`), nil case "search_messages": if f.searchResponse != "" { return searchMsgToolResult(f.searchResponse), nil @@ -63,12 +91,12 @@ func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool str if f.failSecondPage { return nil, errors.New("second page unavailable") } - return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m2","openConversationId":"cid-2","content":"sparse-2"}],"hasMore":false}}`), nil + return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m2","openConversationId":"cid-2","senderOpenDingTalkId":"` + testCurrentDOpenID + `","content":"sparse-2"}],"hasMore":false}}`), nil } if f.firstResponse != "" { return searchMsgToolResult(f.firstResponse), nil } - return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-1","content":"sparse-1"}],"hasMore":true,"nextCursor":"c2"}}`), nil + return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-1","senderOpenDingTalkId":"` + testCurrentDOpenID + `","content":"sparse-1"}],"hasMore":true,"nextCursor":"c2"}}`), nil case "list_messages_by_ids": if f.failEnrichment { return nil, errors.New("mget unavailable") @@ -129,13 +157,175 @@ func executeSearchMsgResult(caller *searchMsgExecutionCaller, args ...string) (m return payload, nil } +func TestSearchMsgMixedSenderClassifiesFormatWithoutIDPreflight(t *testing.T) { + tests := []string{"D-prefix-fixture-user", "d-prefix-fixture-user", "测试用户甲", "fixture-user-id"} + for _, target := range tests { + t.Run(target, func(t *testing.T) { + caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`} + payload := executeSearchMsg(t, caller, "--sender", target, "--no-enrich") + if payload["complete"] != true || len(caller.calls) != 2 { + t.Fatalf("payload=%#v calls=%#v", payload, caller.calls) + } + if caller.calls[0].product != "contact" || caller.calls[0].tool != "search_contact_by_key_word" || + caller.calls[1].tool != "search_messages" { + t.Fatalf("calls=%#v", caller.calls) + } + if got, want := caller.calls[1].args["senderOpenDingTakIds"], []string{testCurrentDOpenID}; !reflect.DeepEqual(got, want) { + t.Fatalf("senderOpenDingTakIds=%#v want=%#v", got, want) + } + }) + } + + caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`} + executeSearchMsg(t, caller, "--sender", testCurrentDOpenID, "--no-enrich") + if len(caller.calls) != 1 || caller.calls[0].tool != "search_messages" { + t.Fatalf("format-valid ID unexpectedly preflighted: %#v", caller.calls) + } +} + +func TestSearchMsgSenderScopeFiltersBackendOverReturn(t *testing.T) { + caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[ + {"openMessageId":"wanted","senderOpenDingTalkId":"DAAAAAAAAAAAiE","content":"keep"}, + {"openMessageId":"other","senderOpenDingTalkId":"DAQEBAQEBAQEiE","content":"drop"} + ],"hasMore":false}}`} + payload := executeSearchMsg(t, caller, "--sender", testCurrentDOpenID, "--no-enrich") + if payload["count"] != float64(1) { + t.Fatalf("payload=%#v", payload) + } + messages := payload["messages"].([]any) + if messages[0].(map[string]any)["messageId"] != "wanted" { + t.Fatalf("messages=%#v", messages) + } +} + +func TestCrossPlatformCoverageSearchMsgSenderScopeRejectsUnmappedIdentityFamily(t *testing.T) { + filtered, unverifiable := filterSearchSenderScope( + []map[string]any{{"openMessageId": "m-user-family", "senderUserId": "user-1"}}, + []targetresolver.UserResolution{{ + Selected: targetresolver.User{OpenDingTalkID: testCurrentDOpenID}, + }}, + ) + if len(filtered) != 0 || !reflect.DeepEqual(unverifiable, []string{"m-user-family"}) { + t.Fatalf("filtered=%#v unverifiable=%#v", filtered, unverifiable) + } +} + +func TestCrossPlatformCoverageSearchMsgResolutionFailureEdges(t *testing.T) { + for _, tc := range []struct { + name string + caller *searchMsgExecutionCaller + args []string + }{ + { + name: "group target", + caller: &searchMsgExecutionCaller{failGroupKeyword: "missing-group"}, + args: []string{"--group", "missing-group", "--no-enrich"}, + }, + { + name: "sender target", + caller: &searchMsgExecutionCaller{failContactKeyword: "missing-sender"}, + args: []string{"--sender", "missing-sender", "--no-enrich"}, + }, + { + name: "sender query", + caller: &searchMsgExecutionCaller{failContactKeyword: "missing-query"}, + args: []string{"--sender-query", "missing-query", "--no-enrich"}, + }, + { + name: "at stable id", + caller: &searchMsgExecutionCaller{failContactKeyword: "missing-at"}, + args: []string{"--at-ids", "missing-at", "--no-enrich"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := executeSearchMsgResult(tc.caller, tc.args...); err == nil { + t.Fatal("resolution failure unexpectedly succeeded") + } + }) + } +} + +func TestCrossPlatformCoverageSearchMsgSenderScopeFailureEdges(t *testing.T) { + t.Run("execute rejects senderless backend rows", func(t *testing.T) { + caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[{"content":"missing identity"}],"hasMore":false}}`} + _, err := executeSearchMsgResult(caller, "--sender", testCurrentDOpenID, "--no-enrich") + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "search_sender_scope_unverified" { + t.Fatalf("error=%#v", err) + } + }) + + resolutions := []targetresolver.UserResolution{{ + Query: "fixture-user", + Selected: targetresolver.User{UserID: "wanted-user"}, + }} + filtered, unverifiable := filterSearchSenderScope( + []map[string]any{ + {"content": "missing identity"}, + {"openMessageId": "wanted", "senderUserId": "wanted-user"}, + {"openMessageId": "open-family", "senderOpenDingTalkId": testCurrentDOpenID}, + }, + resolutions, + ) + if len(filtered) != 1 || !reflect.DeepEqual(unverifiable, []string{"", "open-family"}) { + t.Fatalf("filtered=%#v unverifiable=%#v", filtered, unverifiable) + } + if got := searchScopeMessageID(map[string]any{}); got != "" { + t.Fatalf("missing message ID=%q", got) + } + if got := searchScopeMessageID(map[string]any{"openMessageId": "message-1"}); got != "message-1" { + t.Fatalf("message ID=%q", got) + } + err := searchSenderScopeUnverifiedError( + append(resolutions, resolutions...), + []string{"message-1"}, + ) + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "search_sender_scope_unverified" { + t.Fatalf("error=%#v", err) + } +} + +func TestCrossPlatformCoverageSearchMsgExplicitDaysMetadata(t *testing.T) { + caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`} + payload := executeSearchMsg(t, caller, "--query", "fixture", "--days", "3", "--no-enrich") + coverage := payload["timeCoverage"].(map[string]any) + if coverage["source"] != "explicit_days" || coverage["days"] != float64(3) { + t.Fatalf("coverage=%#v", coverage) + } +} + +func TestCrossPlatformCoverageSearchMsgRejectsNonConversationIDAlias(t *testing.T) { + caller := &searchMsgExecutionCaller{} + _, err := executeSearchMsgResult(caller, "--query", "fixture", "--chat-id", "group-name", "--no-enrich") + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "target_type_mismatch" { + t.Fatalf("error=%#v", err) + } +} + +func TestCrossPlatformCoverageSearchMsgGroupNameAndDefaultWindowMetadata(t *testing.T) { + caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`} + payload := executeSearchMsg(t, caller, "--group", "项目讨论群", "--no-enrich") + if len(caller.calls) != 3 || caller.calls[0].tool != "search_groups" || + caller.calls[1].tool != "get_conversation_info" || caller.calls[2].tool != "search_messages" { + t.Fatalf("calls=%#v", caller.calls) + } + coverage := payload["timeCoverage"].(map[string]any) + guard := payload["conclusionGuard"].(map[string]any) + if coverage["source"] != "implicit_default" || coverage["days"] != float64(7) || + guard["absenceAcrossAllHistoryAllowed"] != false { + t.Fatalf("coverage=%#v guard=%#v", coverage, guard) + } +} + func TestCrossPlatformCoverageSearchMsgPagesAndEnrichesWithAdvancedFilters(t *testing.T) { caller := &searchMsgExecutionCaller{} payload := executeSearchMsg(t, caller, "--query", "周报", "--chat-id", "cid-1,cid-2", - "--sender", "42,Dsender", - "--at-ids", "43,Dat", + "--sender", testCurrentDOpenID, + "--at-ids", testCurrentDOpenID2, "--is-at-me", "--message-type", "text", "--only-robot", @@ -160,10 +350,8 @@ func TestCrossPlatformCoverageSearchMsgPagesAndEnrichesWithAdvancedFilters(t *te t.Fatalf("first call = %#v", first) } for key, want := range map[string]any{ - "senderUserIds": []string{"42"}, - "senderOpenDingTakIds": []string{"Dsender"}, - "atUserIds": []string{"43"}, - "atOpenDingTakIds": []string{"Dat"}, + "senderOpenDingTakIds": []string{testCurrentDOpenID}, + "atOpenDingTakIds": []string{testCurrentDOpenID2}, "messageType": "text", "onlyRobotMessages": true, "searchConvType": "group", diff --git a/internal/shortcut/targetresolver/resolver.go b/internal/shortcut/targetresolver/resolver.go index f4cf72cd..7dc165b1 100644 --- a/internal/shortcut/targetresolver/resolver.go +++ b/internal/shortcut/targetresolver/resolver.go @@ -9,6 +9,7 @@ package targetresolver import ( + "encoding/base64" stderrors "errors" "fmt" "strings" @@ -105,6 +106,274 @@ func ResolveUser(rt Reader, query string, requirement IdentityRequirement) (User return resolveUserCandidates(ExtractUsers(data), query, requirement) } +// ResolveUserTarget accepts the mixed user target used by public convenience +// flags: a natural name, userId, or current-version openDingTalkId. The local +// format check is deliberately a classifier rather than an existence check. +// Format-valid IDs go directly to the downstream business API; all other +// values use the directory resolver, which can match names and exact userIds. +func ResolveUserTarget(rt Reader, value string, requirement IdentityRequirement) (UserResolution, error) { + value = strings.TrimSpace(value) + if value == "" { + return UserResolution{}, apperrors.NewValidation( + "必须指定用户姓名、userId 或 openDingTalkId", + apperrors.WithReason("missing_target"), + apperrors.WithOrigin("client"), + apperrors.WithFailureStage("request_validation"), + apperrors.WithExecutionStarted(false), + ) + } + if !LooksLikeCurrentDOpenDingTalkID(value) { + return ResolveUser(rt, value, requirement) + } + if requirement == IdentityUserID { + return UserResolution{}, apperrors.NewValidation( + fmt.Sprintf("用户目标参数类型不匹配:%q 符合当前版本 openDingTalkId 格式,但下游只接受 userId", value), + apperrors.WithReason("target_type_mismatch"), + apperrors.WithOrigin("client"), + apperrors.WithFailureStage("target_resolution"), + apperrors.WithExecutionStarted(false), + apperrors.WithRetryable(false), + ) + } + return UserResolution{ + Status: StatusResolved, + EntityType: "user", + Query: value, + MatchType: "stable_id", + Selected: User{OpenDingTalkID: value}, + Profile: profilectx.Get(), + }, nil +} + +// ResolveSenderTarget resolves the mixed identity accepted specifically by +// sender convenience flags. Unlike the shared name resolver, it may select a +// unique candidate whose stable userId/openDingTalkId exactly equals the +// supplied value. Natural-name candidates retain the shared fail-closed +// ambiguity behavior. +func ResolveSenderTarget(rt Reader, value string, requirement IdentityRequirement) (UserResolution, error) { + value = strings.TrimSpace(value) + if value == "" || LooksLikeCurrentDOpenDingTalkID(value) { + return ResolveUserTarget(rt, value, requirement) + } + + data, err := rt.CallMCPData("contact", "search_contact_by_key_word", map[string]any{ + "keyword": value, + }) + if err != nil { + return UserResolution{}, err + } + users := filterUsersByIdentity(dedupeUsers(ExtractUsers(data)), requirement) + stableMatches := make([]User, 0, 1) + for _, user := range users { + if strings.TrimSpace(user.UserID) == value || + strings.TrimSpace(user.OpenDingTalkID) == value { + stableMatches = append(stableMatches, user) + } + } + if len(stableMatches) == 1 { + return UserResolution{ + Status: StatusResolved, + EntityType: "user", + Query: value, + MatchType: "stable_id", + Selected: stableMatches[0], + Profile: profilectx.Get(), + }, nil + } + if len(stableMatches) > 1 { + return UserResolution{}, newResolutionError(StatusAmbiguous, "user", value, stableMatches) + } + if cause := incompleteUserSearchCause(data, "通讯录搜索"); cause != "" { + return UserResolution{}, newIncompleteUserResolutionError(value, users, cause) + } + return resolveUserCandidates(users, value, requirement) +} + +// ResolveStableUserTarget accepts only an exact userId or current-version +// openDingTalkId. It is intended for flags whose contract explicitly excludes +// natural names; no unique-name fallback is allowed. +func ResolveStableUserTarget(rt Reader, value string, requirement IdentityRequirement) (UserResolution, error) { + value = strings.TrimSpace(value) + if value == "" { + return UserResolution{}, apperrors.NewValidation( + "必须指定 userId 或 openDingTalkId", + apperrors.WithReason("missing_target"), + apperrors.WithOrigin("client"), + apperrors.WithFailureStage("request_validation"), + apperrors.WithExecutionStarted(false), + ) + } + if LooksLikeCurrentDOpenDingTalkID(value) { + if requirement == IdentityUserID { + return UserResolution{}, apperrors.NewValidation( + fmt.Sprintf("用户目标参数类型不匹配:%q 是 openDingTalkId,但当前参数只接受 userId", value), + apperrors.WithReason("target_type_mismatch"), + apperrors.WithOrigin("client"), + apperrors.WithFailureStage("target_resolution"), + apperrors.WithExecutionStarted(false), + apperrors.WithRetryable(false), + ) + } + return UserResolution{ + Status: StatusResolved, + EntityType: "user", + Query: value, + MatchType: "stable_id", + Selected: User{OpenDingTalkID: value}, + Profile: profilectx.Get(), + }, nil + } + + data, err := rt.CallMCPData("contact", "search_contact_by_key_word", map[string]any{ + "keyword": value, + }) + if err != nil { + return UserResolution{}, err + } + users := filterUsersByIdentity(dedupeUsers(ExtractUsers(data)), requirement) + stableMatches := make([]User, 0, 1) + for _, user := range users { + if strings.TrimSpace(user.UserID) == value || + strings.TrimSpace(user.OpenDingTalkID) == value { + stableMatches = append(stableMatches, user) + } + } + if len(stableMatches) == 1 { + return UserResolution{ + Status: StatusResolved, + EntityType: "user", + Query: value, + MatchType: "stable_id", + Selected: stableMatches[0], + Profile: profilectx.Get(), + }, nil + } + if len(stableMatches) > 1 { + return UserResolution{}, newResolutionError(StatusAmbiguous, "user", value, stableMatches) + } + if cause := incompleteUserSearchCause(data, "通讯录搜索"); cause != "" { + return UserResolution{}, newIncompleteUserResolutionError(value, users, cause) + } + return UserResolution{}, apperrors.NewValidation( + fmt.Sprintf("%q 未精确匹配到 userId 或 openDingTalkId", value), + apperrors.WithReason("stable_user_identity_required"), + apperrors.WithOrigin("client"), + apperrors.WithFailureStage("target_resolution"), + apperrors.WithExecutionStarted(false), + apperrors.WithRetryable(false), + apperrors.WithHint("请先解析目标人员,并传入通讯录结果中原样返回的 userId 或 openDingTalkId"), + apperrors.WithDetails(map[string]any{ + "providedValue": value, + "expectedTypes": []string{"userId", "openDingTalkId"}, + }), + ) +} + +// LooksLikeCurrentDOpenDingTalkID reports whether value has the canonical +// wire format emitted by the current D-version encoder. It does not decrypt +// the value and therefore does not prove that the identity exists or is +// accessible to the current profile. +func LooksLikeCurrentDOpenDingTalkID(value string) bool { + value = strings.TrimSpace(value) + if len(value) < 2 || value[0] != 'D' { + return false + } + + encoded, ok := unescapeCurrentDOpenDingTalkID(value[1:]) + if !ok { + return false + } + ciphertext, err := base64.StdEncoding.Strict().DecodeString(encoded) + if err != nil || len(ciphertext) == 0 || len(ciphertext)%8 != 0 { + return false + } + + // Require the canonical Base64 and escape spelling. This rejects alternate + // but decodable strings and keeps the classifier aligned with the encoder. + canonical := escapeCurrentDOpenDingTalkID(base64.StdEncoding.EncodeToString(ciphertext)) + return canonical == value[1:] +} + +func unescapeCurrentDOpenDingTalkID(value string) (string, bool) { + var decoded strings.Builder + decoded.Grow(len(value)) + for i := 0; i < len(value); i++ { + current := value[i] + if current != 'i' { + if !isASCIIAlphaNumeric(current) { + return "", false + } + decoded.WriteByte(current) + continue + } + if i+1 >= len(value) { + return "", false + } + i++ + switch value[i] { + case 'i': + decoded.WriteByte('i') + case 'P': + decoded.WriteByte('+') + case 'S': + decoded.WriteByte('/') + case 'E': + decoded.WriteByte('=') + default: + return "", false + } + } + return decoded.String(), true +} + +func escapeCurrentDOpenDingTalkID(value string) string { + var encoded strings.Builder + encoded.Grow(len(value)) + for i := 0; i < len(value); i++ { + switch value[i] { + case 'i': + encoded.WriteString("ii") + case '+': + encoded.WriteString("iP") + case '/': + encoded.WriteString("iS") + case '=': + encoded.WriteString("iE") + default: + encoded.WriteByte(value[i]) + } + } + return encoded.String() +} + +func isASCIIAlphaNumeric(value byte) bool { + return value >= '0' && value <= '9' || + value >= 'A' && value <= 'Z' || + value >= 'a' && value <= 'z' +} + +// ValidateExplicitOpenDingTalkID applies the current-version format contract +// to an explicitly typed ID flag. Unlike mixed targets, an invalid explicit ID +// must fail locally instead of falling back to a name or userId lookup. +func ValidateExplicitOpenDingTalkID(flagName, value string) error { + if value == strings.TrimSpace(value) && LooksLikeCurrentDOpenDingTalkID(value) { + return nil + } + return apperrors.NewValidation( + fmt.Sprintf("%s 收到的值不符合当前 D 版本 openDingTalkId 格式", flagName), + apperrors.WithReason("target_type_mismatch"), + apperrors.WithOrigin("client"), + apperrors.WithFailureStage("request_validation"), + apperrors.WithExecutionStarted(false), + apperrors.WithRetryable(false), + apperrors.WithHint("请传通讯录或消息结果中原样返回的 openDingTalkId;姓名或 userId 请使用对应的混合目标参数"), + apperrors.WithDetails(map[string]any{ + "flag": flagName, + "expectedType": "current_d_openDingTalkId", + }), + ) +} + // ResolveEnterpriseUser resolves an organization member through the same // name-calibrated enterprise search used by `dws aisearch person`. Unlike the // contact search, this source understands organization nicknames and aliases. diff --git a/internal/shortcut/targetresolver/resolver_test.go b/internal/shortcut/targetresolver/resolver_test.go index 4e81b49d..2da5e817 100644 --- a/internal/shortcut/targetresolver/resolver_test.go +++ b/internal/shortcut/targetresolver/resolver_test.go @@ -686,3 +686,278 @@ func TestCrossPlatformCoverageResolverCompletionBranches(t *testing.T) { } }) } + +func TestCrossPlatformCoverageLooksLikeCurrentDOpenDingTalkID(t *testing.T) { + tests := []struct { + name string + value string + want bool + }{ + {name: "canonical current D ciphertext", value: "DAAAAAAAAAAAiE", want: true}, + {name: "synthetic current D ciphertext with escaped plus", value: "DiPiPiPiPiPiPiPiPiPiP8iE", want: true}, + {name: "synthetic current D ciphertext with escaped literal i", value: "DYmJiiYmJiiYmIiE", want: true}, + {name: "surrounding whitespace", value: " DAAAAAAAAAAAiE ", want: true}, + {name: "empty", value: "", want: false}, + {name: "prefix only", value: "D", want: false}, + {name: "lowercase prefix", value: "dAAAAAAAAAAAiE", want: false}, + {name: "natural uppercase D fixture", value: "D-prefix-fixture-user", want: false}, + {name: "natural lowercase d fixture", value: "d-prefix-fixture-user", want: false}, + {name: "invalid escape", value: "DAAAAAiDAAAAAiE", want: false}, + {name: "non alphanumeric body", value: "DAAAAA/AAAAAiE", want: false}, + {name: "invalid base64", value: "Dabc", want: false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := LooksLikeCurrentDOpenDingTalkID(tc.value); got != tc.want { + t.Fatalf("LooksLikeCurrentDOpenDingTalkID(%q) = %v, want %v", tc.value, got, tc.want) + } + }) + } +} + +func TestResolveUserTargetUsesFormatClassificationWithoutIDPreflight(t *testing.T) { + const openID = "DAAAAAAAAAAAiE" + calls := 0 + resolved, err := ResolveUserTarget(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) { + calls++ + return nil, stderrors.New("unexpected remote call") + }), openID, IdentityAny) + if err != nil { + t.Fatal(err) + } + if calls != 0 || resolved.MatchType != "stable_id" || resolved.Selected.OpenDingTalkID != openID { + t.Fatalf("calls=%d resolved=%#v", calls, resolved) + } +} + +func TestCrossPlatformCoverageResolveSenderTargetScopesStableIDPreferenceToSenderInputs(t *testing.T) { + calls := 0 + reader := resolverReaderFunc(func(product, tool string, params map[string]any) (map[string]any, error) { + calls++ + if product != "contact" || tool != "search_contact_by_key_word" { + t.Fatalf("unexpected tool %s/%s", product, tool) + } + query := fmt.Sprint(params["keyword"]) + switch query { + case "D-prefix-fixture-user": + return map[string]any{"result": []any{map[string]any{ + "userId": "fixture-user-d-upper", "openDingTalkId": "D-directory-value", "name": "D-prefix-fixture-user", + }}}, nil + case "d-prefix-fixture-user": + return map[string]any{"result": []any{map[string]any{ + "userId": "fixture-user-d-lower", "openDingTalkId": "D-directory-value-2", "name": "d-prefix-fixture-user", + }}}, nil + case "fixture-user-id": + return map[string]any{"result": []any{ + map[string]any{"userId": "other", "name": "相似候选"}, + map[string]any{"userId": "fixture-user-id", "openDingTalkId": "D-directory-value-3", "name": "测试目标用户"}, + }}, nil + default: + t.Fatalf("unexpected query %q", query) + return nil, nil + } + }) + + for _, query := range []string{"D-prefix-fixture-user", "d-prefix-fixture-user"} { + _, err := ResolveSenderTarget(reader, query, IdentityAny) + if err != nil { + t.Fatalf("%s: %v", query, err) + } + } + + _, err := ResolveUser(reader, "fixture-user-id", IdentityAny) + var ambiguous *apperrors.Error + if !stderrors.As(err, &ambiguous) || ambiguous.Reason != "resolution_ambiguous" { + t.Fatalf("shared name resolver error = %#v, want resolution_ambiguous", err) + } + + resolved, err := ResolveSenderTarget(reader, "fixture-user-id", IdentityAny) + if err != nil { + t.Fatal(err) + } + if resolved.MatchType != "stable_id" || resolved.Selected.UserID != "fixture-user-id" { + t.Fatalf("exact sender userId resolution = %#v", resolved) + } + if calls != 4 { + t.Fatalf("calls=%d", calls) + } +} + +func TestValidateExplicitOpenDingTalkIDNeverFallsBack(t *testing.T) { + if err := ValidateExplicitOpenDingTalkID("--open-dingtalk-id", "DAAAAAAAAAAAiE"); err != nil { + t.Fatalf("valid format: %v", err) + } + err := ValidateExplicitOpenDingTalkID("--open-dingtalk-id", "D-prefix-fixture-user") + var typed *apperrors.Error + if !stderrors.As(err, &typed) || typed.Reason != "target_type_mismatch" { + t.Fatalf("error=%#v", err) + } + err = ValidateExplicitOpenDingTalkID("--open-dingtalk-id", " DAAAAAAAAAAAiE ") + if !stderrors.As(err, &typed) || typed.Reason != "target_type_mismatch" { + t.Fatalf("whitespace-wrapped explicit ID error=%#v", err) + } +} + +func TestCrossPlatformCoverageResolveStableUserTargetRejectsNaturalNameButAcceptsExactUserID(t *testing.T) { + reader := resolverReaderFunc(func(product, tool string, params map[string]any) (map[string]any, error) { + if product != "contact" || tool != "search_contact_by_key_word" { + t.Fatalf("unexpected tool %s/%s", product, tool) + } + return map[string]any{"result": []any{ + map[string]any{"userId": "fixture-user-id", "openDingTalkId": "DAAAAAAAAAAAiE", "name": "测试用户甲"}, + }}, nil + }) + + resolved, err := ResolveStableUserTarget(reader, "fixture-user-id", IdentityAny) + if err != nil { + t.Fatal(err) + } + if resolved.MatchType != "stable_id" || resolved.Selected.UserID != "fixture-user-id" { + t.Fatalf("exact userId resolution = %#v", resolved) + } + + _, err = ResolveStableUserTarget(reader, "测试用户甲", IdentityAny) + var typed *apperrors.Error + if !stderrors.As(err, &typed) || typed.Reason != "stable_user_identity_required" { + t.Fatalf("natural name error = %#v", err) + } +} + +func TestResolveStableUserTargetRoutesCurrentDWithoutDirectoryLookup(t *testing.T) { + const openID = "DAAAAAAAAAAAiE" + calls := 0 + resolved, err := ResolveStableUserTarget(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) { + calls++ + return nil, stderrors.New("unexpected remote call") + }), openID, IdentityAny) + if err != nil { + t.Fatal(err) + } + if calls != 0 || resolved.Selected.OpenDingTalkID != openID { + t.Fatalf("calls=%d resolved=%#v", calls, resolved) + } +} + +func TestStableIdentityExactMatchWinsEvenWhenDirectoryPageIsIncomplete(t *testing.T) { + reader := resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) { + return map[string]any{ + "result": []any{ + map[string]any{"userId": "fixture-user-id", "openDingTalkId": "DAAAAAAAAAAAiE", "name": "测试目标用户"}, + map[string]any{"userId": "other", "name": "其他候选"}, + }, + "hasMore": true, + }, nil + }) + for _, resolve := range []struct { + name string + fn func(Reader, string, IdentityRequirement) (UserResolution, error) + }{ + {name: "sender", fn: ResolveSenderTarget}, + {name: "stable-only", fn: ResolveStableUserTarget}, + } { + t.Run(resolve.name, func(t *testing.T) { + resolved, err := resolve.fn(reader, "fixture-user-id", IdentityAny) + if err != nil { + t.Fatal(err) + } + if resolved.MatchType != "stable_id" || resolved.Selected.UserID != "fixture-user-id" { + t.Fatalf("resolved=%#v", resolved) + } + }) + } +} + +func TestCrossPlatformCoverageMixedIdentityResolutionFailureEdges(t *testing.T) { + unexpectedReader := resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) { + return nil, stderrors.New("directory unavailable") + }) + + for _, tc := range []struct { + name string + resolve func(Reader, string, IdentityRequirement) (UserResolution, error) + value string + requirement IdentityRequirement + wantReason string + }{ + {name: "mixed empty", resolve: ResolveUserTarget, wantReason: "missing_target"}, + {name: "mixed open id requires user id", resolve: ResolveUserTarget, value: "DAAAAAAAAAAAiE", requirement: IdentityUserID, wantReason: "target_type_mismatch"}, + {name: "stable empty", resolve: ResolveStableUserTarget, wantReason: "missing_target"}, + {name: "stable open id requires user id", resolve: ResolveStableUserTarget, value: "DAAAAAAAAAAAiE", requirement: IdentityUserID, wantReason: "target_type_mismatch"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := tc.resolve(unexpectedReader, tc.value, tc.requirement) + var typed *apperrors.Error + if !stderrors.As(err, &typed) || typed.Reason != tc.wantReason { + t.Fatalf("error=%#v, want reason %q", err, tc.wantReason) + } + }) + } + + if _, err := ResolveUserTarget(unexpectedReader, "ordinary-user", IdentityAny); err == nil || err.Error() != "directory unavailable" { + t.Fatalf("mixed directory error=%v", err) + } + if _, err := ResolveSenderTarget(unexpectedReader, "ordinary-user", IdentityAny); err == nil || err.Error() != "directory unavailable" { + t.Fatalf("sender directory error=%v", err) + } + if _, err := ResolveStableUserTarget(unexpectedReader, "ordinary-user", IdentityAny); err == nil || err.Error() != "directory unavailable" { + t.Fatalf("stable directory error=%v", err) + } +} + +func TestCrossPlatformCoverageStableIdentityAmbiguousAndIncompleteEdges(t *testing.T) { + duplicateReader := resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) { + return map[string]any{"result": []any{ + map[string]any{"userId": "duplicate", "openDingTalkId": "D-one", "name": "甲"}, + map[string]any{"userId": "other", "openDingTalkId": "duplicate", "name": "乙"}, + }}, nil + }) + for _, resolve := range []struct { + name string + fn func(Reader, string, IdentityRequirement) (UserResolution, error) + }{ + {name: "sender", fn: ResolveSenderTarget}, + {name: "stable", fn: ResolveStableUserTarget}, + } { + t.Run(resolve.name+" ambiguous", func(t *testing.T) { + _, err := resolve.fn(duplicateReader, "duplicate", IdentityAny) + var typed *apperrors.Error + if !stderrors.As(err, &typed) || typed.Reason != "resolution_ambiguous" { + t.Fatalf("error=%#v", err) + } + }) + } + + incompleteReader := resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) { + return map[string]any{ + "result": []any{map[string]any{"userId": "other", "name": "其他"}}, + "hasMore": true, + }, nil + }) + for _, resolve := range []struct { + name string + fn func(Reader, string, IdentityRequirement) (UserResolution, error) + }{ + {name: "sender", fn: ResolveSenderTarget}, + {name: "stable", fn: ResolveStableUserTarget}, + } { + t.Run(resolve.name+" incomplete", func(t *testing.T) { + _, err := resolve.fn(incompleteReader, "missing", IdentityAny) + var typed *apperrors.Error + if !stderrors.As(err, &typed) || typed.Reason != "resolution_incomplete" { + t.Fatalf("error=%#v", err) + } + }) + } +} + +func TestCrossPlatformCoverageCurrentDCodecEdges(t *testing.T) { + if _, ok := unescapeCurrentDOpenDingTalkID("abcdi"); ok { + t.Fatal("dangling escape unexpectedly accepted") + } + if decoded, ok := unescapeCurrentDOpenDingTalkID("iS"); !ok || decoded != "/" { + t.Fatalf("slash escape decoded=%q ok=%v", decoded, ok) + } + if got := escapeCurrentDOpenDingTalkID("i+/="); got != "iiiPiSiE" { + t.Fatalf("escaped=%q", got) + } +} diff --git a/test/mock_mcp/mock_mcp_smoke_test.go b/test/mock_mcp/mock_mcp_smoke_test.go index 58e5127b..d84e704f 100644 --- a/test/mock_mcp/mock_mcp_smoke_test.go +++ b/test/mock_mcp/mock_mcp_smoke_test.go @@ -33,6 +33,7 @@ import ( const ( mockMCPSmokeHelperEnv = "DWS_MOCK_MCP_SMOKE_HELPER" mockMCPSmokeCAEnv = "DWS_MOCK_MCP_SMOKE_CA_FILE" + mockCurrentDOpenID = "DAAAAAAAAAAAiE" ) type recordedToolCall struct { @@ -293,7 +294,7 @@ func TestMultiIME2E_NaturalTargetsCompletenessAndWriteBoundaries(t *testing.T) { stdout, stderr, err := runCLI(t, env, "--token", "ci-smoke-token", "--format", "json", "chat", "+messages-send", "--as", "user", - "--user-query", "张三", "--text", "你好", "--yes", + "--user-query", "测试用户甲", "--text", "你好", "--yes", ) if err != nil { t.Fatalf("natural send failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr) @@ -302,7 +303,7 @@ func TestMultiIME2E_NaturalTargetsCompletenessAndWriteBoundaries(t *testing.T) { if len(calls) != 2 || calls[0].tool != "search_contact_by_key_word" || calls[1].tool != "send_personal_message" { t.Fatalf("calls = %#v, want resolve + one send", calls) } - if calls[1].arguments["receiverOpenDingTalkId"] != "D-user-1" { + if calls[1].arguments["receiverOpenDingTalkId"] != mockCurrentDOpenID { t.Fatalf("send target = %#v", calls[1].arguments) } assertMultiIMCallsAreLocalAndAuthorized(t, calls) @@ -473,7 +474,7 @@ func TestMultiIME2E_NaturalTargetsCompletenessAndWriteBoundaries(t *testing.T) { t.Fatalf("messages = %#v", payload["messages"]) } message, _ := messages[0].(map[string]any) - if message["messageId"] != "msg-1" || message["senderId"] != "D-sender" || message["senderType"] != "user" { + if message["messageId"] != "msg-1" || message["senderId"] != mockCurrentDOpenID || message["senderType"] != "user" { t.Fatalf("projected message = %#v", message) } }) @@ -483,7 +484,7 @@ func TestMultiIME2E_NaturalTargetsCompletenessAndWriteBoundaries(t *testing.T) { stdout, stderr, err := runCLI(t, env, "--token", "ci-smoke-token", "--format", "json", "chat", "+chat-create", "--name", "新群", - "--member-query", "张三", "--dry-run", "--yes", + "--member-query", "测试用户甲", "--dry-run", "--yes", ) if err != nil { t.Fatalf("chat-create dry-run failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr) @@ -505,7 +506,7 @@ func TestMultiIME2E_NaturalTargetsCompletenessAndWriteBoundaries(t *testing.T) { "--token", "ci-smoke-token", "--format", "json", "chat", "+messages-reply", "--conversation-id", "cid-1", "--message-id", "msg-1", - "--ref-sender", "D-sender", "--text", "收到", + "--ref-sender", mockCurrentDOpenID, "--text", "收到", "--idempotency-key", "reply-key", "--yes", ) if err != nil { @@ -532,7 +533,7 @@ func multiIMMockResponse(tool string, arguments map[string]any, mcpBaseURL, reso if arguments["keyword"] == "同名用户" { return `{"result":[{"name":"同名用户","userId":"u1","openDingTalkId":"D1"},{"name":"同名用户","userId":"u2","openDingTalkId":"D2"}]}` } - return `{"result":[{"name":"张三","userId":"user-1","openDingTalkId":"D-user-1"}]}` + return `{"result":[{"name":"测试用户甲","userId":"user-1","openDingTalkId":"` + mockCurrentDOpenID + `"}]}` case "search_groups": if arguments["keyword"] == "资源群" { return `{"result":[{"title":"资源群","openConversationId":"cid-resources"}]}` @@ -540,9 +541,9 @@ func multiIMMockResponse(tool string, arguments map[string]any, mcpBaseURL, reso return `{"result":[{"title":"项目群","openConversationId":"cid-1"}]}` case "list_conversation_message_v2": if arguments["openconversation_id"] == "cid-resources" { - return `{"result":{"hasMore":false,"messages":[{"openMessageId":"msg-resource","openConversationId":"cid-resources","sender":{"name":"张三","openDingTalkId":"D-sender","senderType":"user"},"msgType":"file","content":"{\"mediaId\":\"@media-1\"}","createTime":"2026-08-03 10:00:00"}]}}` + return `{"result":{"hasMore":false,"messages":[{"openMessageId":"msg-resource","openConversationId":"cid-resources","sender":{"name":"测试用户甲","openDingTalkId":"` + mockCurrentDOpenID + `","senderType":"user"},"msgType":"file","content":"{\"mediaId\":\"@media-1\"}","createTime":"2026-08-03 10:00:00"}]}}` } - return `{"result":{"hasMore":false,"messages":[{"openMessageId":"msg-1","openConversationId":"cid-1","sender":{"name":"张三","openDingTalkId":"D-sender","senderType":"user"},"msgType":"text","content":"进度正常","createTime":"2026-08-03 10:00:00"}]}}` + return `{"result":{"hasMore":false,"messages":[{"openMessageId":"msg-1","openConversationId":"cid-1","sender":{"name":"测试用户甲","openDingTalkId":"` + mockCurrentDOpenID + `","senderType":"user"},"msgType":"text","content":"进度正常","createTime":"2026-08-03 10:00:00"}]}}` case "get_resource_download_url": body, _ := json.Marshal(map[string]any{"result": map[string]any{ "resourceUrl": resourceURL, @@ -561,7 +562,7 @@ func multiIMMockResponse(tool string, arguments map[string]any, mcpBaseURL, reso if arguments["keyword"] == "分页失败" && fmt.Sprint(arguments["cursor"]) == "c2" { return `{"success":false,"code":"MOCK_PAGE_FAILURE","message":"simulated second page failure"}` } - return `{"result":{"messages":[{"openMessageId":"search-msg-1","openConversationId":"cid-1","senderOpenDingTalkId":"D-sender","content":"first page"}],"hasMore":true,"nextCursor":"c2"}}` + return `{"result":{"messages":[{"openMessageId":"search-msg-1","openConversationId":"cid-1","senderOpenDingTalkId":"` + mockCurrentDOpenID + `","content":"first page"}],"hasMore":true,"nextCursor":"c2"}}` case "get_current_user_profile": return `{"result":[{"orgEmployeeModel":{"userId":"self-user"}}]}` case "create_group_conversation": From 12e663269208a1539d7bce39a145cd0dd89e5463 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A0=A9=E6=9C=9D?= Date: Fri, 14 Aug 2026 01:00:52 +0800 Subject: [PATCH 82/86] fix(chat): preserve sender identity uncertainty --- internal/shortcut/smart/chat_messages.go | 68 +++++--- internal/shortcut/smart/chat_messages_test.go | 150 ++++++++++++++---- internal/shortcut/smart/search_msg.go | 54 ++++--- .../smart/search_msg_execution_test.go | 125 ++++++++++++--- internal/shortcut/targetresolver/resolver.go | 130 +++++++++------ .../shortcut/targetresolver/resolver_test.go | 148 +++++++++++------ 6 files changed, 480 insertions(+), 195 deletions(-) diff --git a/internal/shortcut/smart/chat_messages.go b/internal/shortcut/smart/chat_messages.go index 83c94868..92ebfbff 100644 --- a/internal/shortcut/smart/chat_messages.go +++ b/internal/shortcut/smart/chat_messages.go @@ -57,7 +57,7 @@ var ChatMessages = shortcut.Shortcut{ Command: "+chat-messages", Product: "chat", Description: "读取指定群聊或单聊的消息记录,支持有界全量分页与原子 JSON 导出", - Intent: "当你要读取或导出一个指定群聊或单聊的消息记录时使用;--sender 是可选过滤条件,可传姓名、userId 或 openDingTalkId,唯一解析后按 senderId 筛选同一次读取结果;不传时原样读取会话且不查询发送者身份。sender 仅是展示名,身份只比较稳定 senderId;" + + Intent: "当你要读取或导出一个指定群聊或单聊的消息记录时使用;--sender 是可选过滤条件,可传姓名、userId 或 openDingTalkId:姓名优先唯一解析,稳定 ID 精确路由;通讯录无法分类时仍按原值 userId 筛选,但无稳定 senderId 命中不得作完整否定结论。不传时原样读取会话且不查询发送者身份。sender 仅是展示名,身份只比较稳定 senderId;" + "群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" + "省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --time/--direction,范围模式可用公开可选的 --start/--end/--order(兼容 --start-time/--end-time/--sort),范围语义为 [start,end)。" + "全量读取用 --page-all,并由 --page-limit/--max-items 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" + @@ -83,7 +83,7 @@ var ChatMessages = shortcut.Shortcut{ }, Selection: contract.SelectionSpec{ AgentSummary: "读取指定群聊或单聊的消息记录,支持有界全量分页与原子 JSON 导出", - UseWhen: []string{"当你要读取或导出一个指定群聊或单聊的消息记录时使用;--sender 是可选过滤条件,可传姓名、userId 或 openDingTalkId,唯一解析后按 senderId 筛选同一次读取结果;不传时原样读取会话且不查询发送者身份。sender 仅是展示名,身份只比较稳定 senderId;" + + UseWhen: []string{"当你要读取或导出一个指定群聊或单聊的消息记录时使用;--sender 是可选过滤条件,可传姓名、userId 或 openDingTalkId:姓名优先唯一解析,稳定 ID 精确路由;通讯录无法分类时仍按原值 userId 筛选,但无稳定 senderId 命中不得作完整否定结论。不传时原样读取会话且不查询发送者身份。sender 仅是展示名,身份只比较稳定 senderId;" + "群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" + "省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --time/--direction,范围模式可用公开可选的 --start/--end/--order(兼容 --start-time/--end-time/--sort),范围语义为 [start,end)。" + "全量读取用 --page-all,并由 --page-limit/--max-items 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" + @@ -104,7 +104,7 @@ var ChatMessages = shortcut.Shortcut{ {Name: "user", Type: shortcut.FlagString, Desc: "单聊对方的 userId,与 --group 互斥"}, {Name: "user-query", Type: shortcut.FlagString, Desc: "按姓名解析唯一 openDingTalkId 的兼容入口", Hidden: true}, {Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊对方的 openDingTalkId,与 --group/--user 互斥"}, - {Name: "sender", Type: shortcut.FlagStringSlice, Desc: "单个或多个发送者姓名、userId 或 openDingTalkId;自动唯一解析并筛选同一次会话读取结果"}, + {Name: "sender", Type: shortcut.FlagStringSlice, Desc: "单个或多个发送者姓名、userId 或 openDingTalkId;姓名唯一解析,稳定 ID 精确路由,通讯录故障不阻断原值 userId 筛选"}, {Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "显式按姓名唯一解析发送者的兼容入口(可选,可重复或逗号分隔)"}, {Name: "time", Type: shortcut.FlagString, Desc: "时间边界,如 \"2025-03-01 00:00:00\";--time 必须是 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD;省略时从当前时间向前读取最近消息"}, {Name: "start", Type: shortcut.FlagString, Desc: "范围开始时间(可选、包含),支持 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD"}, @@ -230,8 +230,6 @@ type chatMessagesSenderFilter struct { inputs []string inputMode string stableIDs map[string]bool - hasUserIDs bool - hasOpenIDs bool resolutions []targetresolver.UserResolution resolutionFailure map[string]any resolutionErr error @@ -300,7 +298,7 @@ func resolveOptionalChatMessagesSenderFilter(rt *shortcut.RuntimeContext) chatMe } filter.resolutions = resolutions - filter.hasUserIDs, filter.hasOpenIDs = addChatMessagesResolvedSenderIDs(filter.stableIDs, resolutions) + addChatMessagesResolvedSenderIDs(filter.stableIDs, resolutions) filter.applied = true return filter } @@ -319,20 +317,15 @@ func uniqueChatMessageTargets(values []string) []string { return result } -func addChatMessagesResolvedSenderIDs(ids map[string]bool, resolutions []targetresolver.UserResolution) (bool, bool) { - hasUserIDs := false - hasOpenIDs := false +func addChatMessagesResolvedSenderIDs(ids map[string]bool, resolutions []targetresolver.UserResolution) { for _, resolution := range resolutions { if identity := strings.TrimSpace(resolution.Selected.UserID); identity != "" { ids[identity] = true - hasUserIDs = true } if identity := strings.TrimSpace(resolution.Selected.OpenDingTalkID); identity != "" { ids[identity] = true - hasOpenIDs = true } } - return hasUserIDs, hasOpenIDs } // applyOptionalChatMessagesSenderFilter fails closed when an explicitly @@ -401,14 +394,6 @@ func applyOptionalChatMessagesSenderFilter( } if filter.stableIDs[identity] { filtered = append(filtered, item) - continue - } - if targetresolver.LooksLikeCurrentDOpenDingTalkID(identity) { - if !filter.hasOpenIDs { - unverifiableMessageIDs = append(unverifiableMessageIDs, chatMessagesMessageID(item)) - } - } else if !filter.hasUserIDs { - unverifiableMessageIDs = append(unverifiableMessageIDs, chatMessagesMessageID(item)) } } if len(unverifiableMessageIDs) > 0 { @@ -433,12 +418,27 @@ func applyOptionalChatMessagesSenderFilter( payload["complete"] = false payload["partial"] = len(filtered) > 0 } + unverifiedSenderInputs := chatMessagesUnverifiedSenderInputs(filtered, filter.resolutions) + if len(unverifiedSenderInputs) > 0 { + failures, _ := payload["failures"].([]map[string]any) + failures = append(failures, map[string]any{ + "stage": "sender_identity_verification", + "inputs": unverifiedSenderInputs, + "error": "通讯录未能确认这些混合发送者参数是姓名还是 userId;已按精确 userId 过滤,但不能据此作完整否定结论", + }) + payload["failures"] = failures + payload["failedCount"] = len(failures) + payload["complete"] = false + payload["partial"] = len(filtered) > 0 + } payload["messages"] = projectChatMessages(filtered, !rt.Bool("no-reactions")) payload["count"] = len(filtered) payload["resolvedFilters"] = map[string]any{"senders": filter.resolutions} filterStatus := "applied" if filter.scopeErr != nil { filterStatus = "scope_unverified" + } else if len(unverifiedSenderInputs) > 0 { + filterStatus = "identity_unverified" } payload["senderFilter"] = map[string]any{ "requested": true, @@ -447,6 +447,9 @@ func applyOptionalChatMessagesSenderFilter( "applied": true, "status": filterStatus, } + if len(unverifiedSenderInputs) > 0 { + payload["senderFilter"].(map[string]any)["unverifiedInputs"] = unverifiedSenderInputs + } identityResult := map[string]any{ "status": "evaluated", "nameComparisonAllowed": false, @@ -457,17 +460,32 @@ func applyOptionalChatMessagesSenderFilter( identityResult["status"] = "scope_unverified" identityResult["negativeConclusionAllowed"] = false identityResult["hint"] = "部分消息缺少稳定 senderId,当前结果不能用于断言该人员没有发过消息。" + } else if len(unverifiedSenderInputs) > 0 { + identityResult["status"] = "identity_unverified" + identityResult["negativeConclusionAllowed"] = false + identityResult["hint"] = "当前混合发送者参数未能经通讯录确认;已按原值 userId 精确比较,若无命中仍不能断言该人员没有发过消息。" } payload["identityResult"] = identityResult return filtered } -func chatMessagesMessageID(item map[string]any) string { - messageID := chatmsg.StableMessageID(item) - if messageID == "" { - return "" +func chatMessagesUnverifiedSenderInputs( + messages []map[string]any, + resolutions []targetresolver.UserResolution, +) []string { + observed := map[string]bool{} + for _, message := range messages { + if senderID := strings.TrimSpace(fmt.Sprint(chatmsg.SenderID(message))); senderID != "" && senderID != "" { + observed[senderID] = true + } } - return messageID + values := make([]string, 0) + for _, resolution := range resolutions { + if targetresolver.IsUnverifiedUserIDResolution(resolution) && !observed[resolution.Selected.UserID] { + values = append(values, resolution.Query) + } + } + return uniqueChatMessageTargets(values) } func attachUnfilteredSenderIdentitySemantics(payload map[string]any, filter chatMessagesSenderFilter) { diff --git a/internal/shortcut/smart/chat_messages_test.go b/internal/shortcut/smart/chat_messages_test.go index 66ca0679..24201f12 100644 --- a/internal/shortcut/smart/chat_messages_test.go +++ b/internal/shortcut/smart/chat_messages_test.go @@ -27,6 +27,7 @@ import ( apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) @@ -578,14 +579,13 @@ func TestCrossPlatformCoverageChatMessagesAdditionalValidationAndHelpers(t *test } } -func TestChatMessagesSenderScopeRejectsUnmappedIdentityFamily(t *testing.T) { +func TestChatMessagesSenderScopeIgnoresNonMatchingIdentityFamily(t *testing.T) { filter := chatMessagesSenderFilter{ - requested: true, - applied: true, - inputs: []string{"DAAAAAAAAAAAiE"}, - inputMode: "sender", - stableIDs: map[string]bool{"DAAAAAAAAAAAiE": true}, - hasOpenIDs: true, + requested: true, + applied: true, + inputs: []string{"DAAAAAAAAAAAiE"}, + inputMode: "sender", + stableIDs: map[string]bool{"DAAAAAAAAAAAiE": true}, } payload := map[string]any{ "complete": true, @@ -594,26 +594,106 @@ func TestChatMessagesSenderScopeRejectsUnmappedIdentityFamily(t *testing.T) { filtered := applyOptionalChatMessagesSenderFilter( chatMessagesRuntimeForTest(t, nil), payload, - []map[string]any{{"openMessageId": "m-user-family", "senderUserId": "user-1"}}, + []map[string]any{ + {"openMessageId": "wanted", "senderOpenDingTalkId": "DAAAAAAAAAAAiE"}, + {"openMessageId": "other-family", "senderUserId": "other-user"}, + }, &filter, ) - if len(filtered) != 0 || filter.scopeErr == nil || payload["complete"] != false { + if len(filtered) != 1 || filter.scopeErr != nil || payload["complete"] != true || payload["count"] != 1 { t.Fatalf("filtered=%#v filter=%#v payload=%#v", filtered, filter, payload) } } func TestCrossPlatformCoverageChatMessagesSenderFilterFailureEdges(t *testing.T) { - t.Run("direct resolution stops after first error", func(t *testing.T) { + t.Run("unrelated unique directory candidate never replaces the supplied user id", func(t *testing.T) { + fake := &platformCoverageCaller{ + contactSearchResult: `{"result":[{"userId":"other-user","name":"其他用户"}],"hasMore":false}`, + } + helpers.InitDeps(fake) + filter := resolveOptionalChatMessagesSenderFilter(chatMessagesRuntimeForTest(t, map[string]string{ + "sender": "fixture-user-id", + })) + if !filter.applied || !filter.stableIDs["fixture-user-id"] || filter.stableIDs["other-user"] { + t.Fatalf("filter=%#v", filter) + } + }) + + t.Run("direct user ids continue when directory is unavailable", func(t *testing.T) { fake := &platformCoverageCaller{failTool: "contact/search_contact_by_key_word"} helpers.InitDeps(fake) filter := resolveOptionalChatMessagesSenderFilter(chatMessagesRuntimeForTest(t, map[string]string{ "sender": testCurrentDOpenID + ",fixture-name,ignored-name", })) - if filter.applied || filter.resolutionErr == nil || len(fake.calls) != 1 { + if !filter.applied || filter.resolutionErr != nil || len(fake.calls) != 2 || + !filter.stableIDs["fixture-name"] || !filter.stableIDs["ignored-name"] { t.Fatalf("filter=%#v calls=%#v", filter, fake.calls) } }) + t.Run("unverified mixed sender becomes verified by an exact sender id match", func(t *testing.T) { + payload := map[string]any{"complete": true, "failures": []map[string]any{}} + resolution := targetresolver.UserResolution{ + Status: targetresolver.StatusResolved, + EntityType: "user", + Query: "stable-user-id", + MatchType: "unverified_user_id", + Selected: targetresolver.User{UserID: "stable-user-id"}, + } + filter := chatMessagesSenderFilter{ + requested: true, + applied: true, + inputs: []string{"stable-user-id"}, + inputMode: "sender", + stableIDs: map[string]bool{"stable-user-id": true}, + resolutions: []targetresolver.UserResolution{resolution}, + } + filtered := applyOptionalChatMessagesSenderFilter( + chatMessagesRuntimeForTest(t, nil), payload, + []map[string]any{ + {"openMessageId": "wanted", "senderUserId": "stable-user-id"}, + {"openMessageId": "other", "senderUserId": "other-user"}, + }, &filter, + ) + if len(filtered) != 1 || payload["complete"] != true || len(payload["failures"].([]map[string]any)) != 0 { + t.Fatalf("filtered=%#v payload=%#v", filtered, payload) + } + identity := payload["identityResult"].(map[string]any) + if identity["status"] != "evaluated" || identity["negativeConclusionAllowed"] != true { + t.Fatalf("identityResult=%#v", identity) + } + }) + + t.Run("unverified mixed sender without a match blocks complete negative conclusion", func(t *testing.T) { + payload := map[string]any{"complete": true, "failures": []map[string]any{}} + resolution := targetresolver.UserResolution{ + Status: targetresolver.StatusResolved, + EntityType: "user", + Query: "possibly-a-name", + MatchType: "unverified_user_id", + Selected: targetresolver.User{UserID: "possibly-a-name"}, + } + filter := chatMessagesSenderFilter{ + requested: true, + applied: true, + inputs: []string{"possibly-a-name"}, + inputMode: "sender", + stableIDs: map[string]bool{"possibly-a-name": true}, + resolutions: []targetresolver.UserResolution{resolution}, + } + filtered := applyOptionalChatMessagesSenderFilter( + chatMessagesRuntimeForTest(t, nil), payload, + []map[string]any{{"openMessageId": "other", "senderUserId": "other-user"}}, &filter, + ) + if len(filtered) != 0 || payload["complete"] != false || payload["failedCount"] != 1 { + t.Fatalf("filtered=%#v payload=%#v", filtered, payload) + } + identity := payload["identityResult"].(map[string]any) + if identity["status"] != "identity_unverified" || identity["negativeConclusionAllowed"] != false { + t.Fatalf("identityResult=%#v", identity) + } + }) + t.Run("dedupes blanks and repeated values", func(t *testing.T) { got := uniqueChatMessageTargets([]string{"", " ", "one", "one", " two "}) if want := []string{"one", "two"}; !reflect.DeepEqual(got, want) { @@ -634,15 +714,14 @@ func TestCrossPlatformCoverageChatMessagesSenderFilterFailureEdges(t *testing.T) } }) - t.Run("rejects missing and unmapped identity families", func(t *testing.T) { + t.Run("rejects only missing identities", func(t *testing.T) { payload := map[string]any{"complete": true, "failures": []map[string]any{}} filter := chatMessagesSenderFilter{ - requested: true, - applied: true, - inputs: []string{"fixture"}, - inputMode: "sender", - stableIDs: map[string]bool{"wanted-user": true}, - hasUserIDs: true, + requested: true, + applied: true, + inputs: []string{"fixture"}, + inputMode: "sender", + stableIDs: map[string]bool{"wanted-user": true}, } filtered := applyOptionalChatMessagesSenderFilter( chatMessagesRuntimeForTest(t, nil), @@ -654,26 +733,43 @@ func TestCrossPlatformCoverageChatMessagesSenderFilterFailureEdges(t *testing.T) }, &filter, ) - if len(filtered) != 1 || filter.scopeErr == nil || chatMessagesMessageID(map[string]any{}) != "" || chatMessagesMessageID(filtered[0]) != "wanted" { + if len(filtered) != 1 || filter.scopeErr == nil { t.Fatalf("filtered=%#v filter=%#v payload=%#v", filtered, filter, payload) } }) - t.Run("rejects user identity when only open ids were resolved", func(t *testing.T) { + t.Run("ignores user identity when only open ids were resolved", func(t *testing.T) { payload := map[string]any{"complete": true, "failures": []map[string]any{}} filter := chatMessagesSenderFilter{ - requested: true, - applied: true, - inputs: []string{testCurrentDOpenID}, - inputMode: "sender", - stableIDs: map[string]bool{testCurrentDOpenID: true}, - hasOpenIDs: true, + requested: true, + applied: true, + inputs: []string{testCurrentDOpenID}, + inputMode: "sender", + stableIDs: map[string]bool{testCurrentDOpenID: true}, } applyOptionalChatMessagesSenderFilter( chatMessagesRuntimeForTest(t, nil), payload, []map[string]any{{"senderUserId": "other-user"}}, &filter, ) - if filter.scopeErr == nil { + if filter.scopeErr != nil || payload["complete"] != true { + t.Fatalf("filter=%#v", filter) + } + }) + + t.Run("ignores open identity when only user ids were resolved", func(t *testing.T) { + payload := map[string]any{"complete": true, "failures": []map[string]any{}} + filter := chatMessagesSenderFilter{ + requested: true, + applied: true, + inputs: []string{"wanted-user"}, + inputMode: "sender", + stableIDs: map[string]bool{"wanted-user": true}, + } + applyOptionalChatMessagesSenderFilter( + chatMessagesRuntimeForTest(t, nil), payload, + []map[string]any{{"senderOpenDingTalkId": testCurrentDOpenID}}, &filter, + ) + if filter.scopeErr != nil || payload["complete"] != true { t.Fatalf("filter=%#v", filter) } }) diff --git a/internal/shortcut/smart/search_msg.go b/internal/shortcut/smart/search_msg.go index 2224d542..a3f6c011 100644 --- a/internal/shortcut/smart/search_msg.go +++ b/internal/shortcut/smart/search_msg.go @@ -35,7 +35,7 @@ import ( // search hits through list_messages_by_ids in chunks of 50. A later-page or // enrichment failure never turns a partial result into a false success: the // output carries an explicit failure ledger and complete=false. -const searchMsgIntent = "当你要按关键词、发送者、@对象、消息类型、机器人来源或会话范围组合搜索 IM 消息时使用;可搜索单个、多个或全部会话。--group/--groups 接受群名或 openConversationId,--sender/--senders 接受姓名、userId 或 openDingTalkId,并统一唯一解析为稳定 ID。默认查询近 7 天,也可指定精确起止时间和输出顺序。" + +const searchMsgIntent = "当你要按关键词、发送者、@对象、消息类型、机器人来源或会话范围组合搜索 IM 消息时使用;可搜索单个、多个或全部会话。--group/--groups 接受群名或 openConversationId,--sender/--senders 接受姓名、userId 或 openDingTalkId:姓名优先唯一解析,稳定 ID 精确路由;通讯录无法分类时仍按原值 userId 执行,但无稳定 senderId 命中不得作完整否定结论。默认查询近 7 天,也可指定精确起止时间和输出顺序。" + "显式指定会话时会先验证 CID,再执行有界全局扫描并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID。" + "--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。" + "--download-resources 使用安全本地路径、默认不覆盖和原子落盘。" @@ -86,8 +86,8 @@ var SearchMsg = shortcut.Shortcut{ {Name: "groups", Type: shortcut.FlagStringSlice, Desc: "多个群名或 openConversationId;可混合输入并逐项唯一解析"}, {Name: "chat-id", Type: shortcut.FlagStringSlice, Desc: "--groups 的 lark-cli 对齐别名;只接受 openConversationId"}, {Name: "chat-query", Type: shortcut.FlagStringSlice, Desc: "显式按群名唯一解析的兼容入口(可选,可重复或逗号分隔)"}, - {Name: "senders", Type: shortcut.FlagStringSlice, Desc: "多个发送者姓名、userId 或 openDingTalkId;可混合输入并逐项唯一解析"}, - {Name: "sender", Type: shortcut.FlagStringSlice, Desc: "单个或多个发送者姓名、userId 或 openDingTalkId;--senders 的兼容别名"}, + {Name: "senders", Type: shortcut.FlagStringSlice, Desc: "多个发送者姓名、userId 或 openDingTalkId;姓名唯一解析,稳定 ID 精确路由,通讯录故障不阻断原值 userId 查询"}, + {Name: "sender", Type: shortcut.FlagStringSlice, Desc: "单个或多个发送者姓名、userId 或 openDingTalkId;--senders 的兼容别名,保留同样的三态解析与安全降级语义"}, {Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "显式按姓名唯一解析的兼容入口(可选,可重复或逗号分隔)"}, {Name: "at-me", Type: shortcut.FlagBool, Desc: "只搜索 @我 的消息"}, {Name: "is-at-me", Type: shortcut.FlagBool, Desc: "--at-me 的 lark-cli 对齐别名"}, @@ -278,6 +278,15 @@ var SearchMsg = shortcut.Shortcut{ return searchSenderScopeUnverifiedError(resolvedFilters.Senders, unverifiableMessageIDs) } } + unverifiedSenderInputs := searchUnverifiedSenderInputs(messages, resolvedFilters.Senders) + if len(unverifiedSenderInputs) > 0 { + failures = append(failures, map[string]any{ + "stage": "sender_identity_verification", + "inputs": unverifiedSenderInputs, + "error": "通讯录未能确认这些混合发送者参数是姓名还是 userId;已按精确 userId 执行,但不能据此作完整否定结论", + }) + complete = false + } order := strings.ToLower(strings.TrimSpace(rt.StrFirst("order", "sort"))) if order == "" { @@ -309,11 +318,15 @@ var SearchMsg = shortcut.Shortcut{ } if len(resolvedFilters.Senders) > 0 { payload["senderScope"] = map[string]any{ - "targetsResolved": true, + "targetsResolved": len(unverifiedSenderInputs) == 0, "filterApplied": true, "filterMode": "server_and_client", "resultsWithinScope": true, } + if len(unverifiedSenderInputs) > 0 { + payload["senderScope"].(map[string]any)["status"] = "identity_unverified" + payload["senderScope"].(map[string]any)["unverifiedInputs"] = unverifiedSenderInputs + } } if scopedSearch { payload["scope"] = searchScopePayload(requestedConversationIDs, paginationKnown && !hasMore) @@ -663,16 +676,12 @@ func filterSearchSenderScope( resolutions []targetresolver.UserResolution, ) ([]map[string]any, []string) { allowed := map[string]bool{} - hasUserIDs := false - hasOpenIDs := false for _, resolution := range resolutions { if value := strings.TrimSpace(resolution.Selected.UserID); value != "" { allowed[value] = true - hasUserIDs = true } if value := strings.TrimSpace(resolution.Selected.OpenDingTalkID); value != "" { allowed[value] = true - hasOpenIDs = true } } @@ -690,25 +699,28 @@ func filterSearchSenderScope( } if allowed[senderID] { filtered = append(filtered, message) - continue - } - if targetresolver.LooksLikeCurrentDOpenDingTalkID(senderID) { - if !hasOpenIDs { - unverifiable = append(unverifiable, searchScopeMessageID(message)) - } - } else if !hasUserIDs { - unverifiable = append(unverifiable, searchScopeMessageID(message)) } } return filtered, uniqueSearchStrings(unverifiable) } -func searchScopeMessageID(message map[string]any) string { - messageID := strings.TrimSpace(fmt.Sprint(searchMsgMessageID(message))) - if messageID == "" || messageID == "" { - return "" +func searchUnverifiedSenderInputs( + messages []map[string]any, + resolutions []targetresolver.UserResolution, +) []string { + observed := map[string]bool{} + for _, message := range messages { + if senderID := strings.TrimSpace(fmt.Sprint(chatmsg.SenderID(message))); senderID != "" && senderID != "" { + observed[senderID] = true + } } - return messageID + values := make([]string, 0) + for _, resolution := range resolutions { + if targetresolver.IsUnverifiedUserIDResolution(resolution) && !observed[resolution.Selected.UserID] { + values = append(values, resolution.Query) + } + } + return uniqueSearchStrings(values) } func searchSenderScopeUnverifiedError( diff --git a/internal/shortcut/smart/search_msg_execution_test.go b/internal/shortcut/smart/search_msg_execution_test.go index 161abe4e..574042cd 100644 --- a/internal/shortcut/smart/search_msg_execution_test.go +++ b/internal/shortcut/smart/search_msg_execution_test.go @@ -198,15 +198,25 @@ func TestSearchMsgSenderScopeFiltersBackendOverReturn(t *testing.T) { } } -func TestCrossPlatformCoverageSearchMsgSenderScopeRejectsUnmappedIdentityFamily(t *testing.T) { +func TestCrossPlatformCoverageSearchMsgSenderScopeIgnoresNonMatchingIdentityFamily(t *testing.T) { + caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[ + {"openMessageId":"wanted","senderOpenDingTalkId":"DAAAAAAAAAAAiE","content":"keep"}, + {"openMessageId":"other-family","senderUserId":"other-user","content":"drop"} + ],"hasMore":false}}`} + payload := executeSearchMsg(t, caller, "--sender", testCurrentDOpenID, "--no-enrich") + if payload["complete"] != true || payload["count"] != float64(1) || payload["failedCount"] != float64(0) { + t.Fatalf("payload=%#v", payload) + } + filtered, unverifiable := filterSearchSenderScope( - []map[string]any{{"openMessageId": "m-user-family", "senderUserId": "user-1"}}, - []targetresolver.UserResolution{{ - Selected: targetresolver.User{OpenDingTalkID: testCurrentDOpenID}, - }}, + []map[string]any{ + {"openMessageId": "wanted-user", "senderUserId": "wanted-user"}, + {"openMessageId": "other-open-family", "senderOpenDingTalkId": testCurrentDOpenID}, + }, + []targetresolver.UserResolution{{Selected: targetresolver.User{UserID: "wanted-user"}}}, ) - if len(filtered) != 0 || !reflect.DeepEqual(unverifiable, []string{"m-user-family"}) { - t.Fatalf("filtered=%#v unverifiable=%#v", filtered, unverifiable) + if len(filtered) != 1 || len(unverifiable) != 0 { + t.Fatalf("reverse family filtered=%#v unverifiable=%#v", filtered, unverifiable) } } @@ -221,21 +231,11 @@ func TestCrossPlatformCoverageSearchMsgResolutionFailureEdges(t *testing.T) { caller: &searchMsgExecutionCaller{failGroupKeyword: "missing-group"}, args: []string{"--group", "missing-group", "--no-enrich"}, }, - { - name: "sender target", - caller: &searchMsgExecutionCaller{failContactKeyword: "missing-sender"}, - args: []string{"--sender", "missing-sender", "--no-enrich"}, - }, { name: "sender query", caller: &searchMsgExecutionCaller{failContactKeyword: "missing-query"}, args: []string{"--sender-query", "missing-query", "--no-enrich"}, }, - { - name: "at stable id", - caller: &searchMsgExecutionCaller{failContactKeyword: "missing-at"}, - args: []string{"--at-ids", "missing-at", "--no-enrich"}, - }, } { t.Run(tc.name, func(t *testing.T) { if _, err := executeSearchMsgResult(tc.caller, tc.args...); err == nil { @@ -245,6 +245,89 @@ func TestCrossPlatformCoverageSearchMsgResolutionFailureEdges(t *testing.T) { } } +func TestCrossPlatformCoverageSearchMsgStableUserIDContinuesWhenDirectoryIsUnavailable(t *testing.T) { + t.Run("unrelated unique directory candidate never replaces the supplied user id", func(t *testing.T) { + caller := &searchMsgExecutionCaller{ + contactResponse: `{"result":[{"userId":"other-user","name":"其他用户"}],"hasMore":false}`, + searchResponse: `{"result":{"messages":[ + {"openMessageId":"wanted","senderUserId":"fixture-user-id","content":"keep"}, + {"openMessageId":"other","senderUserId":"other-user","content":"drop"} + ],"hasMore":false}}`, + } + payload := executeSearchMsg(t, caller, "--sender", "fixture-user-id", "--no-enrich") + if len(caller.calls) != 2 || caller.calls[0].tool != "search_contact_by_key_word" || + caller.calls[1].tool != "search_messages" { + t.Fatalf("calls=%#v", caller.calls) + } + if got := caller.calls[1].args["senderUserIds"]; !reflect.DeepEqual(got, []string{"fixture-user-id"}) { + t.Fatalf("senderUserIds=%#v args=%#v", got, caller.calls[1].args) + } + if _, exists := caller.calls[1].args["senderOpenDingTakIds"]; exists { + t.Fatalf("unrelated candidate leaked into request: %#v", caller.calls[1].args) + } + if payload["complete"] != true || payload["count"] != float64(1) { + t.Fatalf("payload=%#v", payload) + } + }) + + t.Run("mixed sender preserves positive matches and blocks complete negative conclusion", func(t *testing.T) { + caller := &searchMsgExecutionCaller{ + failContactKeyword: "stable-user-id", + searchResponse: `{"result":{"messages":[ + {"openMessageId":"wanted","senderUserId":"stable-user-id","content":"keep"}, + {"openMessageId":"other","senderUserId":"other-user","content":"drop"} + ],"hasMore":false}}`, + } + payload := executeSearchMsg(t, caller, "--sender", "stable-user-id", "--no-enrich") + if len(caller.calls) != 2 || caller.calls[0].tool != "search_contact_by_key_word" || + caller.calls[1].tool != "search_messages" { + t.Fatalf("calls=%#v", caller.calls) + } + if got := caller.calls[1].args["senderUserIds"]; !reflect.DeepEqual(got, []string{"stable-user-id"}) { + t.Fatalf("senderUserIds=%#v", got) + } + if payload["count"] != float64(1) || payload["complete"] != true || payload["failedCount"] != float64(0) { + t.Fatalf("payload=%#v", payload) + } + scope := payload["senderScope"].(map[string]any) + if _, exists := scope["status"]; exists || scope["targetsResolved"] != true { + t.Fatalf("senderScope=%#v", scope) + } + }) + + t.Run("mixed sender without a stable id match cannot produce a complete negative conclusion", func(t *testing.T) { + caller := &searchMsgExecutionCaller{ + failContactKeyword: "possibly-a-name", + searchResponse: `{"result":{"messages":[],"hasMore":false}}`, + } + payload := executeSearchMsg(t, caller, "--sender", "possibly-a-name", "--no-enrich") + if payload["count"] != float64(0) || payload["complete"] != false || payload["failedCount"] != float64(1) { + t.Fatalf("payload=%#v", payload) + } + scope := payload["senderScope"].(map[string]any) + if scope["status"] != "identity_unverified" || scope["targetsResolved"] != false { + t.Fatalf("senderScope=%#v", scope) + } + }) + + t.Run("id-only at target bypasses directory", func(t *testing.T) { + caller := &searchMsgExecutionCaller{ + failContactKeyword: "stable-at-user-id", + searchResponse: `{"result":{"messages":[],"hasMore":false}}`, + } + payload := executeSearchMsg(t, caller, "--at-ids", "stable-at-user-id", "--no-enrich") + if len(caller.calls) != 1 || caller.calls[0].tool != "search_messages" { + t.Fatalf("calls=%#v", caller.calls) + } + if got := caller.calls[0].args["atUserIds"]; !reflect.DeepEqual(got, []string{"stable-at-user-id"}) { + t.Fatalf("atUserIds=%#v", got) + } + if payload["complete"] != true { + t.Fatalf("payload=%#v", payload) + } + }) +} + func TestCrossPlatformCoverageSearchMsgSenderScopeFailureEdges(t *testing.T) { t.Run("execute rejects senderless backend rows", func(t *testing.T) { caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[{"content":"missing identity"}],"hasMore":false}}`} @@ -267,15 +350,9 @@ func TestCrossPlatformCoverageSearchMsgSenderScopeFailureEdges(t *testing.T) { }, resolutions, ) - if len(filtered) != 1 || !reflect.DeepEqual(unverifiable, []string{"", "open-family"}) { + if len(filtered) != 1 || !reflect.DeepEqual(unverifiable, []string{""}) { t.Fatalf("filtered=%#v unverifiable=%#v", filtered, unverifiable) } - if got := searchScopeMessageID(map[string]any{}); got != "" { - t.Fatalf("missing message ID=%q", got) - } - if got := searchScopeMessageID(map[string]any{"openMessageId": "message-1"}); got != "message-1" { - t.Fatalf("message ID=%q", got) - } err := searchSenderScopeUnverifiedError( append(resolutions, resolutions...), []string{"message-1"}, diff --git a/internal/shortcut/targetresolver/resolver.go b/internal/shortcut/targetresolver/resolver.go index 7dc165b1..c800e9fe 100644 --- a/internal/shortcut/targetresolver/resolver.go +++ b/internal/shortcut/targetresolver/resolver.go @@ -70,6 +70,16 @@ type UserResolution struct { Profile string `json:"profile,omitempty"` } +const matchTypeUnverifiedUserID = "unverified_user_id" + +// IsUnverifiedUserIDResolution reports a mixed sender value that could not be +// classified by the directory and is therefore being tried as an exact userId. +// Consumers may use positive senderId equality, but must not claim a negative +// result is complete because the original value may instead have been a name. +func IsUnverifiedUserIDResolution(resolution UserResolution) bool { + return resolution.MatchType == matchTypeUnverifiedUserID +} + // Chat is the public identity returned by group resolution. type Chat struct { OpenConversationID string `json:"openConversationId"` @@ -148,8 +158,12 @@ func ResolveUserTarget(rt Reader, value string, requirement IdentityRequirement) // ResolveSenderTarget resolves the mixed identity accepted specifically by // sender convenience flags. Unlike the shared name resolver, it may select a // unique candidate whose stable userId/openDingTalkId exactly equals the -// supplied value. Natural-name candidates retain the shared fail-closed -// ambiguity behavior. +// supplied value. Natural-name resolution accepts only exact names and retains +// fail-closed ambiguity behavior; a lone unrelated directory candidate must +// never replace the supplied value. If the directory cannot classify a value +// and userId is accepted downstream, the original value is retained as an +// unverified userId candidate so a valid stable ID is not blocked by directory +// availability. func ResolveSenderTarget(rt Reader, value string, requirement IdentityRequirement) (UserResolution, error) { value = strings.TrimSpace(value) if value == "" || LooksLikeCurrentDOpenDingTalkID(value) { @@ -160,7 +174,10 @@ func ResolveSenderTarget(rt Reader, value string, requirement IdentityRequiremen "keyword": value, }) if err != nil { - return UserResolution{}, err + if requirement == IdentityOpenDingTalkID { + return UserResolution{}, err + } + return unverifiedSenderUserID(value), nil } users := filterUsersByIdentity(dedupeUsers(ExtractUsers(data)), requirement) stableMatches := make([]User, 0, 1) @@ -184,14 +201,51 @@ func ResolveSenderTarget(rt Reader, value string, requirement IdentityRequiremen return UserResolution{}, newResolutionError(StatusAmbiguous, "user", value, stableMatches) } if cause := incompleteUserSearchCause(data, "通讯录搜索"); cause != "" { - return UserResolution{}, newIncompleteUserResolutionError(value, users, cause) + if requirement == IdentityOpenDingTalkID { + return UserResolution{}, newIncompleteUserResolutionError(value, users, cause) + } + return unverifiedSenderUserID(value), nil } - return resolveUserCandidates(users, value, requirement) + exactNameMatches := make([]User, 0, 1) + for _, user := range users { + if strings.EqualFold(strings.TrimSpace(user.Name), value) { + exactNameMatches = append(exactNameMatches, user) + } + } + if len(exactNameMatches) == 1 { + return UserResolution{ + Status: StatusResolved, + EntityType: "user", + Query: value, + MatchType: "exact", + Selected: exactNameMatches[0], + Profile: profilectx.Get(), + }, nil + } + if len(exactNameMatches) > 1 { + return UserResolution{}, newResolutionError(StatusAmbiguous, "user", value, exactNameMatches) + } + if requirement == IdentityOpenDingTalkID { + return UserResolution{}, newResolutionError(StatusNotFound, "user", value, users) + } + return unverifiedSenderUserID(value), nil } -// ResolveStableUserTarget accepts only an exact userId or current-version -// openDingTalkId. It is intended for flags whose contract explicitly excludes -// natural names; no unique-name fallback is allowed. +func unverifiedSenderUserID(value string) UserResolution { + return UserResolution{ + Status: StatusResolved, + EntityType: "user", + Query: value, + MatchType: matchTypeUnverifiedUserID, + Selected: User{UserID: value}, + Profile: profilectx.Get(), + } +} + +// ResolveStableUserTarget accepts only a userId or current-version +// openDingTalkId. Because callers use it only for ID-only flags, a non-D value +// is already explicitly typed as userId and can be passed through without a +// directory availability dependency. func ResolveStableUserTarget(rt Reader, value string, requirement IdentityRequirement) (UserResolution, error) { value = strings.TrimSpace(value) if value == "" { @@ -223,50 +277,24 @@ func ResolveStableUserTarget(rt Reader, value string, requirement IdentityRequir Profile: profilectx.Get(), }, nil } - - data, err := rt.CallMCPData("contact", "search_contact_by_key_word", map[string]any{ - "keyword": value, - }) - if err != nil { - return UserResolution{}, err + if requirement == IdentityOpenDingTalkID { + return UserResolution{}, apperrors.NewValidation( + fmt.Sprintf("用户目标参数类型不匹配:%q 是 userId,但当前参数只接受 openDingTalkId", value), + apperrors.WithReason("target_type_mismatch"), + apperrors.WithOrigin("client"), + apperrors.WithFailureStage("target_resolution"), + apperrors.WithExecutionStarted(false), + apperrors.WithRetryable(false), + ) } - users := filterUsersByIdentity(dedupeUsers(ExtractUsers(data)), requirement) - stableMatches := make([]User, 0, 1) - for _, user := range users { - if strings.TrimSpace(user.UserID) == value || - strings.TrimSpace(user.OpenDingTalkID) == value { - stableMatches = append(stableMatches, user) - } - } - if len(stableMatches) == 1 { - return UserResolution{ - Status: StatusResolved, - EntityType: "user", - Query: value, - MatchType: "stable_id", - Selected: stableMatches[0], - Profile: profilectx.Get(), - }, nil - } - if len(stableMatches) > 1 { - return UserResolution{}, newResolutionError(StatusAmbiguous, "user", value, stableMatches) - } - if cause := incompleteUserSearchCause(data, "通讯录搜索"); cause != "" { - return UserResolution{}, newIncompleteUserResolutionError(value, users, cause) - } - return UserResolution{}, apperrors.NewValidation( - fmt.Sprintf("%q 未精确匹配到 userId 或 openDingTalkId", value), - apperrors.WithReason("stable_user_identity_required"), - apperrors.WithOrigin("client"), - apperrors.WithFailureStage("target_resolution"), - apperrors.WithExecutionStarted(false), - apperrors.WithRetryable(false), - apperrors.WithHint("请先解析目标人员,并传入通讯录结果中原样返回的 userId 或 openDingTalkId"), - apperrors.WithDetails(map[string]any{ - "providedValue": value, - "expectedTypes": []string{"userId", "openDingTalkId"}, - }), - ) + return UserResolution{ + Status: StatusResolved, + EntityType: "user", + Query: value, + MatchType: "stable_id", + Selected: User{UserID: value}, + Profile: profilectx.Get(), + }, nil } // LooksLikeCurrentDOpenDingTalkID reports whether value has the canonical diff --git a/internal/shortcut/targetresolver/resolver_test.go b/internal/shortcut/targetresolver/resolver_test.go index 2da5e817..e52a89a3 100644 --- a/internal/shortcut/targetresolver/resolver_test.go +++ b/internal/shortcut/targetresolver/resolver_test.go @@ -783,6 +783,87 @@ func TestCrossPlatformCoverageResolveSenderTargetScopesStableIDPreferenceToSende } } +func TestCrossPlatformCoverageResolveSenderTargetNeverSubstitutesUnrelatedUniqueDirectoryCandidate(t *testing.T) { + resolved, err := ResolveSenderTarget(resolverReaderFunc(func(product, tool string, params map[string]any) (map[string]any, error) { + if product != "contact" || tool != "search_contact_by_key_word" || params["keyword"] != "fixture-user-id" { + t.Fatalf("unexpected call %s/%s %#v", product, tool, params) + } + return map[string]any{ + "result": []any{map[string]any{ + "userId": "other-user", + "name": "其他用户", + }}, + "hasMore": false, + }, nil + }), "fixture-user-id", IdentityAny) + if err != nil { + t.Fatal(err) + } + if !IsUnverifiedUserIDResolution(resolved) || resolved.Selected.UserID != "fixture-user-id" { + t.Fatalf("resolution=%#v", resolved) + } +} + +func TestCrossPlatformCoverageResolveSenderTargetOpenIDFailureEdges(t *testing.T) { + t.Run("directory failure", func(t *testing.T) { + wantErr := stderrors.New("directory unavailable") + _, err := ResolveSenderTarget(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) { + return nil, wantErr + }), "fixture-sender", IdentityOpenDingTalkID) + if !stderrors.Is(err, wantErr) { + t.Fatalf("error=%#v, want directory error", err) + } + }) + + t.Run("incomplete directory result", func(t *testing.T) { + _, err := ResolveSenderTarget(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) { + return map[string]any{ + "result": []any{map[string]any{ + "openDingTalkId": "fixture-open-id-1", + "name": "测试候选用户", + }}, + "hasMore": true, + }, nil + }), "fixture-sender", IdentityOpenDingTalkID) + var typed *apperrors.Error + if !stderrors.As(err, &typed) || typed.Reason != "resolution_incomplete" { + t.Fatalf("error=%#v, want resolution_incomplete", err) + } + }) + + t.Run("multiple exact names remain ambiguous", func(t *testing.T) { + _, err := ResolveSenderTarget(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) { + return map[string]any{ + "result": []any{ + map[string]any{"openDingTalkId": "fixture-open-id-1", "name": "测试同名用户"}, + map[string]any{"openDingTalkId": "fixture-open-id-2", "name": "测试同名用户"}, + }, + "hasMore": false, + }, nil + }), "测试同名用户", IdentityOpenDingTalkID) + var typed *apperrors.Error + if !stderrors.As(err, &typed) || typed.Reason != "resolution_ambiguous" { + t.Fatalf("error=%#v, want resolution_ambiguous", err) + } + }) + + t.Run("no exact open id or name match", func(t *testing.T) { + _, err := ResolveSenderTarget(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) { + return map[string]any{ + "result": []any{map[string]any{ + "openDingTalkId": "fixture-open-id-1", + "name": "其他测试用户", + }}, + "hasMore": false, + }, nil + }), "fixture-sender", IdentityOpenDingTalkID) + var typed *apperrors.Error + if !stderrors.As(err, &typed) || typed.Reason != "resolution_not_found" { + t.Fatalf("error=%#v, want resolution_not_found", err) + } + }) +} + func TestValidateExplicitOpenDingTalkIDNeverFallsBack(t *testing.T) { if err := ValidateExplicitOpenDingTalkID("--open-dingtalk-id", "DAAAAAAAAAAAiE"); err != nil { t.Fatalf("valid format: %v", err) @@ -798,29 +879,20 @@ func TestValidateExplicitOpenDingTalkIDNeverFallsBack(t *testing.T) { } } -func TestCrossPlatformCoverageResolveStableUserTargetRejectsNaturalNameButAcceptsExactUserID(t *testing.T) { - reader := resolverReaderFunc(func(product, tool string, params map[string]any) (map[string]any, error) { - if product != "contact" || tool != "search_contact_by_key_word" { - t.Fatalf("unexpected tool %s/%s", product, tool) - } - return map[string]any{"result": []any{ - map[string]any{"userId": "fixture-user-id", "openDingTalkId": "DAAAAAAAAAAAiE", "name": "测试用户甲"}, - }}, nil +func TestCrossPlatformCoverageResolveStableUserTargetPassesExplicitUserIDWithoutDirectory(t *testing.T) { + calls := 0 + reader := resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) { + calls++ + return nil, stderrors.New("directory unavailable") }) resolved, err := ResolveStableUserTarget(reader, "fixture-user-id", IdentityAny) if err != nil { t.Fatal(err) } - if resolved.MatchType != "stable_id" || resolved.Selected.UserID != "fixture-user-id" { + if calls != 0 || resolved.MatchType != "stable_id" || resolved.Selected.UserID != "fixture-user-id" { t.Fatalf("exact userId resolution = %#v", resolved) } - - _, err = ResolveStableUserTarget(reader, "测试用户甲", IdentityAny) - var typed *apperrors.Error - if !stderrors.As(err, &typed) || typed.Reason != "stable_user_identity_required" { - t.Fatalf("natural name error = %#v", err) - } } func TestResolveStableUserTargetRoutesCurrentDWithoutDirectoryLookup(t *testing.T) { @@ -883,6 +955,7 @@ func TestCrossPlatformCoverageMixedIdentityResolutionFailureEdges(t *testing.T) {name: "mixed open id requires user id", resolve: ResolveUserTarget, value: "DAAAAAAAAAAAiE", requirement: IdentityUserID, wantReason: "target_type_mismatch"}, {name: "stable empty", resolve: ResolveStableUserTarget, wantReason: "missing_target"}, {name: "stable open id requires user id", resolve: ResolveStableUserTarget, value: "DAAAAAAAAAAAiE", requirement: IdentityUserID, wantReason: "target_type_mismatch"}, + {name: "stable user id requires open id", resolve: ResolveStableUserTarget, value: "ordinary-user", requirement: IdentityOpenDingTalkID, wantReason: "target_type_mismatch"}, } { t.Run(tc.name, func(t *testing.T) { _, err := tc.resolve(unexpectedReader, tc.value, tc.requirement) @@ -896,11 +969,13 @@ func TestCrossPlatformCoverageMixedIdentityResolutionFailureEdges(t *testing.T) if _, err := ResolveUserTarget(unexpectedReader, "ordinary-user", IdentityAny); err == nil || err.Error() != "directory unavailable" { t.Fatalf("mixed directory error=%v", err) } - if _, err := ResolveSenderTarget(unexpectedReader, "ordinary-user", IdentityAny); err == nil || err.Error() != "directory unavailable" { - t.Fatalf("sender directory error=%v", err) + resolved, err := ResolveSenderTarget(unexpectedReader, "ordinary-user", IdentityAny) + if err != nil || !IsUnverifiedUserIDResolution(resolved) || resolved.Selected.UserID != "ordinary-user" { + t.Fatalf("sender fallback resolution=%#v error=%v", resolved, err) } - if _, err := ResolveStableUserTarget(unexpectedReader, "ordinary-user", IdentityAny); err == nil || err.Error() != "directory unavailable" { - t.Fatalf("stable directory error=%v", err) + resolved, err = ResolveStableUserTarget(unexpectedReader, "ordinary-user", IdentityAny) + if err != nil || resolved.MatchType != "stable_id" || resolved.Selected.UserID != "ordinary-user" { + t.Fatalf("stable passthrough resolution=%#v error=%v", resolved, err) } } @@ -911,20 +986,10 @@ func TestCrossPlatformCoverageStableIdentityAmbiguousAndIncompleteEdges(t *testi map[string]any{"userId": "other", "openDingTalkId": "duplicate", "name": "乙"}, }}, nil }) - for _, resolve := range []struct { - name string - fn func(Reader, string, IdentityRequirement) (UserResolution, error) - }{ - {name: "sender", fn: ResolveSenderTarget}, - {name: "stable", fn: ResolveStableUserTarget}, - } { - t.Run(resolve.name+" ambiguous", func(t *testing.T) { - _, err := resolve.fn(duplicateReader, "duplicate", IdentityAny) - var typed *apperrors.Error - if !stderrors.As(err, &typed) || typed.Reason != "resolution_ambiguous" { - t.Fatalf("error=%#v", err) - } - }) + _, err := ResolveSenderTarget(duplicateReader, "duplicate", IdentityAny) + var typed *apperrors.Error + if !stderrors.As(err, &typed) || typed.Reason != "resolution_ambiguous" { + t.Fatalf("ambiguous sender error=%#v", err) } incompleteReader := resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) { @@ -933,20 +998,9 @@ func TestCrossPlatformCoverageStableIdentityAmbiguousAndIncompleteEdges(t *testi "hasMore": true, }, nil }) - for _, resolve := range []struct { - name string - fn func(Reader, string, IdentityRequirement) (UserResolution, error) - }{ - {name: "sender", fn: ResolveSenderTarget}, - {name: "stable", fn: ResolveStableUserTarget}, - } { - t.Run(resolve.name+" incomplete", func(t *testing.T) { - _, err := resolve.fn(incompleteReader, "missing", IdentityAny) - var typed *apperrors.Error - if !stderrors.As(err, &typed) || typed.Reason != "resolution_incomplete" { - t.Fatalf("error=%#v", err) - } - }) + resolved, err := ResolveSenderTarget(incompleteReader, "missing", IdentityAny) + if err != nil || !IsUnverifiedUserIDResolution(resolved) || resolved.Selected.UserID != "missing" { + t.Fatalf("incomplete sender fallback=%#v error=%v", resolved, err) } } From bcd91aca1fdb404b100fa88edcd341dbfcec303f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A0=A9=E6=9C=9D?= Date: Fri, 14 Aug 2026 01:10:17 +0800 Subject: [PATCH 83/86] test(chat): align time defaults with current open ID format --- internal/helpers/chat_message_search_test.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/helpers/chat_message_search_test.go b/internal/helpers/chat_message_search_test.go index c0bcc370..2b1d1ccf 100644 --- a/internal/helpers/chat_message_search_test.go +++ b/internal/helpers/chat_message_search_test.go @@ -748,15 +748,15 @@ func TestCrossPlatformCoverageChatMessageListDefaultTimeUsesShanghaiLocation(t * }, { name: "user open DingTalk ID fallback", - args: []string{"message", "list", "--user", "D-user", "--limit", "50"}, + args: []string{"message", "list", "--user", helperCurrentDOpenID, "--limit", "50"}, wantTool: "list_individual_chat_message", - wantTarget: map[string]any{"openDingTalkId": "D-user"}, + wantTarget: map[string]any{"openDingTalkId": helperCurrentDOpenID}, }, { name: "open DingTalk ID", - args: []string{"message", "list", "--open-dingtalk-id", "D-open", "--limit", "50"}, + args: []string{"message", "list", "--open-dingtalk-id", helperCurrentDOpenID, "--limit", "50"}, wantTool: "list_individual_chat_message", - wantTarget: map[string]any{"openDingTalkId": "D-open"}, + wantTarget: map[string]any{"openDingTalkId": helperCurrentDOpenID}, }, { name: "direct user", @@ -766,9 +766,9 @@ func TestCrossPlatformCoverageChatMessageListDefaultTimeUsesShanghaiLocation(t * }, { name: "direct open DingTalk ID", - args: []string{"message", "list-direct", "--open-dingtalk-id", "D-direct", "--limit", "50"}, + args: []string{"message", "list-direct", "--open-dingtalk-id", helperCurrentDOpenID, "--limit", "50"}, wantTool: "list_individual_chat_message", - wantTarget: map[string]any{"openDingTalkId": "D-direct"}, + wantTarget: map[string]any{"openDingTalkId": helperCurrentDOpenID}, }, } From be001949e40cb5786aefaac334ff444cd8d00c64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A0=A9=E6=9C=9D?= Date: Fri, 14 Aug 2026 01:31:16 +0800 Subject: [PATCH 84/86] test(chat): complete sender routing coverage --- internal/shortcut/smart/chat_messages_test.go | 14 +++++++++++++ internal/shortcut/smart/search_msg.go | 16 ++++++-------- .../smart/search_msg_execution_test.go | 12 +++++++++++ .../shortcut/targetresolver/resolver_test.go | 21 ++++++++++++++++--- 4 files changed, 50 insertions(+), 13 deletions(-) diff --git a/internal/shortcut/smart/chat_messages_test.go b/internal/shortcut/smart/chat_messages_test.go index 24201f12..aab4bf87 100644 --- a/internal/shortcut/smart/chat_messages_test.go +++ b/internal/shortcut/smart/chat_messages_test.go @@ -782,6 +782,20 @@ func TestCrossPlatformCoverageChatMessagesSenderFilterFailureEdges(t *testing.T) } func TestCrossPlatformCoverageChatMessagesSenderFailureOutputEdges(t *testing.T) { + t.Run("ambiguous direct sender prioritizes output error", func(t *testing.T) { + caller := &platformCoverageCaller{contactSearchResult: `{"result":[ + {"userId":"fixture-user-1","name":"测试同名发送者"}, + {"userId":"fixture-user-2","name":"测试同名发送者"} + ],"hasMore":false}`} + helpers.InitDeps(caller) + root := newPlatformCoverageRoot() + root.SetOut(chatMessagesFailWriter{}) + root.SetArgs([]string{"chat", "+chat-messages", "--conversation-id", "cid", "--sender", "测试同名发送者"}) + if err := root.Execute(); err == nil || err.Error() != "fixture output failure" { + t.Fatalf("error=%v", err) + } + }) + for _, tc := range []struct { name string sender string diff --git a/internal/shortcut/smart/search_msg.go b/internal/shortcut/smart/search_msg.go index a3f6c011..73f8bbc6 100644 --- a/internal/shortcut/smart/search_msg.go +++ b/internal/shortcut/smart/search_msg.go @@ -515,10 +515,7 @@ func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, searchResolve resolvedFilters.Senders = append(resolvedFilters.Senders, resolvedUsers...) } appendResolvedSearchActorIDs(params, resolvedFilters.Senders, "senderUserIds", "senderOpenDingTakIds") - atUsers, err := resolveSearchStableActorTargets(rt, rt.StrSlice("at-ids")) - if err != nil { - return nil, searchResolvedFilters{}, err - } + atUsers := resolveSearchStableActorTargets(rt, rt.StrSlice("at-ids")) appendResolvedSearchActorIDs(params, atUsers, "atUserIds", "atOpenDingTakIds") if rt.Bool("at-me") || rt.Bool("is-at-me") { params["atMe"] = true @@ -560,16 +557,15 @@ func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, searchResolve func resolveSearchStableActorTargets( rt *shortcut.RuntimeContext, values []string, -) ([]targetresolver.UserResolution, error) { +) []targetresolver.UserResolution { resolvedUsers := make([]targetresolver.UserResolution, 0, len(values)) for _, value := range uniqueSearchStrings(values) { - resolved, err := targetresolver.ResolveStableUserTarget(rt, value, targetresolver.IdentityAny) - if err != nil { - return nil, err - } + // IdentityAny accepts every non-empty value as exactly one stable ID + // family, and uniqueSearchStrings has already removed empty values. + resolved, _ := targetresolver.ResolveStableUserTarget(rt, value, targetresolver.IdentityAny) resolvedUsers = append(resolvedUsers, resolved) } - return resolvedUsers, nil + return resolvedUsers } // appendResolvedSearchActorIDs prefers openDingTalkId whenever directory diff --git a/internal/shortcut/smart/search_msg_execution_test.go b/internal/shortcut/smart/search_msg_execution_test.go index 574042cd..1768c849 100644 --- a/internal/shortcut/smart/search_msg_execution_test.go +++ b/internal/shortcut/smart/search_msg_execution_test.go @@ -245,6 +245,18 @@ func TestCrossPlatformCoverageSearchMsgResolutionFailureEdges(t *testing.T) { } } +func TestCrossPlatformCoverageSearchMsgRejectsAmbiguousDirectSender(t *testing.T) { + caller := &searchMsgExecutionCaller{contactResponse: `{"result":[ + {"userId":"fixture-user-1","name":"测试同名发送者"}, + {"userId":"fixture-user-2","name":"测试同名发送者"} + ],"hasMore":false}`} + _, err := executeSearchMsgResult(caller, "--sender", "测试同名发送者", "--no-enrich") + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "resolution_ambiguous" { + t.Fatalf("error=%#v, want resolution_ambiguous", err) + } +} + func TestCrossPlatformCoverageSearchMsgStableUserIDContinuesWhenDirectoryIsUnavailable(t *testing.T) { t.Run("unrelated unique directory candidate never replaces the supplied user id", func(t *testing.T) { caller := &searchMsgExecutionCaller{ diff --git a/internal/shortcut/targetresolver/resolver_test.go b/internal/shortcut/targetresolver/resolver_test.go index e52a89a3..a1be9b77 100644 --- a/internal/shortcut/targetresolver/resolver_test.go +++ b/internal/shortcut/targetresolver/resolver_test.go @@ -715,7 +715,7 @@ func TestCrossPlatformCoverageLooksLikeCurrentDOpenDingTalkID(t *testing.T) { } } -func TestResolveUserTargetUsesFormatClassificationWithoutIDPreflight(t *testing.T) { +func TestCrossPlatformCoverageResolveUserTargetUsesFormatClassificationWithoutIDPreflight(t *testing.T) { const openID = "DAAAAAAAAAAAiE" calls := 0 resolved, err := ResolveUserTarget(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) { @@ -783,6 +783,21 @@ func TestCrossPlatformCoverageResolveSenderTargetScopesStableIDPreferenceToSende } } +func TestCrossPlatformCoverageResolveSenderTargetRoutesCurrentDWithoutDirectoryLookup(t *testing.T) { + const openID = "DAAAAAAAAAAAiE" + calls := 0 + resolved, err := ResolveSenderTarget(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) { + calls++ + return nil, stderrors.New("unexpected directory call") + }), openID, IdentityAny) + if err != nil { + t.Fatal(err) + } + if calls != 0 || resolved.MatchType != "stable_id" || resolved.Selected.OpenDingTalkID != openID { + t.Fatalf("calls=%d resolved=%#v", calls, resolved) + } +} + func TestCrossPlatformCoverageResolveSenderTargetNeverSubstitutesUnrelatedUniqueDirectoryCandidate(t *testing.T) { resolved, err := ResolveSenderTarget(resolverReaderFunc(func(product, tool string, params map[string]any) (map[string]any, error) { if product != "contact" || tool != "search_contact_by_key_word" || params["keyword"] != "fixture-user-id" { @@ -864,7 +879,7 @@ func TestCrossPlatformCoverageResolveSenderTargetOpenIDFailureEdges(t *testing.T }) } -func TestValidateExplicitOpenDingTalkIDNeverFallsBack(t *testing.T) { +func TestCrossPlatformCoverageValidateExplicitOpenDingTalkIDNeverFallsBack(t *testing.T) { if err := ValidateExplicitOpenDingTalkID("--open-dingtalk-id", "DAAAAAAAAAAAiE"); err != nil { t.Fatalf("valid format: %v", err) } @@ -895,7 +910,7 @@ func TestCrossPlatformCoverageResolveStableUserTargetPassesExplicitUserIDWithout } } -func TestResolveStableUserTargetRoutesCurrentDWithoutDirectoryLookup(t *testing.T) { +func TestCrossPlatformCoverageResolveStableUserTargetRoutesCurrentDWithoutDirectoryLookup(t *testing.T) { const openID = "DAAAAAAAAAAAiE" calls := 0 resolved, err := ResolveStableUserTarget(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) { From c9d47839688a8d6a12cd42b67a0a47cc6e95bd97 Mon Sep 17 00:00:00 2001 From: chichuan Date: Fri, 14 Aug 2026 10:07:58 +0800 Subject: [PATCH 85/86] fix(drive): recheck source identity after PUT and reject symlink status root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups to the latest CR: * push/sync uploads (`pushUploadFilePinned`): the PUT-time check pinned inode, size, and mtime before dispatch but nothing rechecked the source after PUT succeeded — only the root itself. An editor overwrite, truncate-rewrite, or mmap-in-place during transfer would land a mixed old/new byte stream in OSS and still be committed, corrupting the remote file in overwrite/local-wins. Now stat the still-open handle again before `commit_upload`; any change in inode/size/mtime aborts the commit. Post-PUT stat failures also abort. * status root (`walkLocalTree`): `filepath.WalkDir` refuses to follow the root when it is itself a directory symlink and reports it as a non-regular entry, so the walker silently returned an empty local index and status flagged every remote file as `new_remote`. Fail closed before the walk: the root must be a real directory; symlinks and non-directories are rejected with a clear message. A `statusRootLstat` seam keeps the rejection regressible on platforms that cannot create directory symlinks (Windows without admin). Both fixes come with `TestCrossPlatformCoverage*` regressions and take the platform coverage gate from 99.9356% back to 100.0000% (1553 statements). --- internal/helpers/drive_push.go | 13 ++++ .../drive_push_sync_final_coverage_test.go | 78 ++++++++++++++++++- internal/helpers/drive_status.go | 20 ++++- .../helpers/drive_status_coverage_test.go | 45 +++++++++++ 4 files changed, 153 insertions(+), 3 deletions(-) diff --git a/internal/helpers/drive_push.go b/internal/helpers/drive_push.go index 37eaec56..e5e64e25 100644 --- a/internal/helpers/drive_push.go +++ b/internal/helpers/drive_push.go @@ -741,6 +741,19 @@ func pushUploadFilePinned(ctx context.Context, spaceID, parentID, overwriteFileI if err := pushPutOpenedFile(ctx, resourceURL, ossHeaders, file, local.Size); err != nil { return err } + // OSS 传输期间若源文件被原地改写(编辑器覆盖、截断重写、mmap), + // PUT 里发送的可能是新旧内容的混合体,root.verify() 又不检查文件本身。 + // commit 前对已打开句柄再取一次身份,与 PUT 前的 inode/size/mtime 逐项比对; + // 任一变化都拒绝 commit,避免 overwrite/local-wins 场景把混合内容覆盖到远端。 + after, err := pushStatOpenedFile(file) + if err != nil { + return fmt.Errorf("上传后读取本地上传文件身份失败: %w", err) + } + if !os.SameFile(info, after) || + after.Size() != info.Size() || + after.ModTime().UnixMilli() != info.ModTime().UnixMilli() { + return fmt.Errorf("本地上传目标 %q 在传输期间被修改,已中止提交", local.RelPath) + } // OSS 传输期间若命令行根已被替换,不提交这个上传会话;已发送的字节仍来自 // 固定根内的原文件句柄,不可能读取替代路径或根外内容。 return root.verify() diff --git a/internal/helpers/drive_push_sync_final_coverage_test.go b/internal/helpers/drive_push_sync_final_coverage_test.go index 13aaf929..715e4362 100644 --- a/internal/helpers/drive_push_sync_final_coverage_test.go +++ b/internal/helpers/drive_push_sync_final_coverage_test.go @@ -13,6 +13,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" ) @@ -183,9 +184,82 @@ func TestCrossPlatformCoverageDrivePushFinalPinnedUploadGuards(t *testing.T) { t.Fatalf("second verification error=%v", err) } }) -} -func TestCrossPlatformCoverageDrivePushFinalWalkAndCommandGates(t *testing.T) { + // PUT 期间源文件被原地改写(编辑器覆盖、截断重写、mmap)时,PUT 已把混合内容 + // 送到 OSS;如果 commit 只看根身份而不复核文件本身,overwrite/local-wins 会把 + // 半新半旧的字节流覆盖到远端。commit 前必须再取一次源身份,size/mtime/inode + // 任一变化都要拒绝提交。 + for _, mutation := range []struct { + name string + mutate func(t *testing.T, dir string) + }{ + {"size grows during PUT", func(t *testing.T, dir string) { + path := filepath.Join(dir, "a.txt") + if err := os.WriteFile(path, []byte("payload-mutated-mid-transfer"), 0o600); err != nil { + t.Fatal(err) + } + }}, + {"mtime advances during PUT", func(t *testing.T, dir string) { + path := filepath.Join(dir, "a.txt") + future := time.Now().Add(2 * time.Second) + if err := os.Chtimes(path, future, future); err != nil { + t.Fatal(err) + } + }}, + } { + t.Run("mid transfer source modification: "+mutation.name, func(t *testing.T) { + rootPath, root, local := openDriveFinalCoverageRoot(t) + caller := pushOKCaller(nil) + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive"}) + + putCalls := 0 + testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { + putCalls++ + mutation.mutate(t, rootPath) + return nil + }) + + err := pushUploadFilePinned(context.Background(), "", "P", "", "a.txt", root, local) + if err == nil || !strings.Contains(err.Error(), "在传输期间被修改") { + t.Fatalf("PUT-time mutation must abort commit, got err=%v", err) + } + if putCalls != 1 { + t.Fatalf("PUT must run exactly once, got %d", putCalls) + } + if commits := caller.callsFor("commit_upload"); len(commits) != 0 { + t.Fatalf("commit_upload must not run after mid-transfer mutation, got %v", commits) + } + }) + // PUT 之后再取源身份可能因文件被移动/删除失败:那也必须拒绝 commit,防止 + // 一个不能被验证的传输被提交到远端。 + t.Run("mid transfer stat failure aborts commit", func(t *testing.T) { + _, root, local := openDriveFinalCoverageRoot(t) + caller := pushOKCaller(nil) + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}}) + testseam.Swap(t, &os.Args, []string{"dws", "drive"}) + + calls := 0 + testseam.Swap(t, &pushStatOpenedFile, func(file *os.File) (os.FileInfo, error) { + calls++ + if calls == 1 { + return file.Stat() + } + return nil, errDriveFinalCoverage + }) + testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { + return nil + }) + + err := pushUploadFilePinned(context.Background(), "", "P", "", "a.txt", root, local) + if err == nil || !strings.Contains(err.Error(), "上传后读取本地上传文件身份失败") { + t.Fatalf("post-PUT stat failure must abort commit, got %v", err) + } + if commits := caller.callsFor("commit_upload"); len(commits) != 0 { + t.Fatalf("commit_upload must not run after post-PUT stat failure, got %v", commits) + } + }) + } t.Run("command scan errors", func(t *testing.T) { for _, command := range []string{"push", "sync"} { t.Run(command, func(t *testing.T) { diff --git a/internal/helpers/drive_status.go b/internal/helpers/drive_status.go index 4955dccf..586c004c 100644 --- a/internal/helpers/drive_status.go +++ b/internal/helpers/drive_status.go @@ -466,14 +466,32 @@ var md5File = func(path string) (string, error) { return base64.StdEncoding.EncodeToString(h.Sum(nil)), nil } +// statusRootLstat 是根类型校验的注入 seam;测试通过 testseam.Swap 注入不同的 +// FileInfo 来复现 Windows 上无法创建的符号链接场景。 +var statusRootLstat = os.Lstat + // walkLocalTree 递归遍历本地目录,只收集常规文件(跳过符号链接、设备文件等)。 // rel_path 统一使用 / 作为分隔符,相对于 root。 // // 这里只记录路径与 mtime,不计算 MD5:本地 hash 采用惰性策略,仅在文件双端 // 都存在且非 quick 模式时,由 compareTrees 按需计算(见 judgeFileMatch)。 func walkLocalTree(root string) (map[string]*localFile, error) { + // filepath.WalkDir 不会跟随根本身的符号链接:若 root 是指向目录的 symlink, + // WalkDir 只以 symlink 身份触发一次回调,随后 !IsRegular 分支静默跳过,最终 + // 返回空索引,让 status 把所有远端文件报成 new_remote。fail-closed 于遍历前: + // 根必须是真实目录,任何其它类型都必须报错。 + info, err := statusRootLstat(root) + if err != nil { + return nil, fmt.Errorf("读取本地根目录身份失败: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("本地根目录 %q 是符号链接,请传入真实目录路径", root) + } + if !info.IsDir() { + return nil, fmt.Errorf("本地根目录 %q 不是目录", root) + } files := make(map[string]*localFile) - err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { return walkLocalTreeEntry(root, path, d, err, files) }) if err != nil { diff --git a/internal/helpers/drive_status_coverage_test.go b/internal/helpers/drive_status_coverage_test.go index 3e9a3869..afe13dcf 100644 --- a/internal/helpers/drive_status_coverage_test.go +++ b/internal/helpers/drive_status_coverage_test.go @@ -10,6 +10,7 @@ import ( "runtime" "strings" "testing" + "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" @@ -300,6 +301,50 @@ func TestCrossPlatformCoverageWalkLocalTree_missingRootErrors(t *testing.T) { } } +// 根路径是指向目录的符号链接时,filepath.WalkDir 只把根本身当成一次 !IsRegular +// 回调静默跳过,结果是空索引,而 status 会把所有远端文件误报为 new_remote。 +// 必须在遍历前 fail-closed。这里通过 seam 注入,兼顾无法真实创建 symlink 的平台。 +func TestCrossPlatformCoverageWalkLocalTree_rejectsSymlinkRootViaSeam(t *testing.T) { + testseam.Swap(t, &statusRootLstat, func(string) (os.FileInfo, error) { + return fakeSymlinkFileInfo{}, nil + }) + if _, err := walkLocalTree(t.TempDir()); err == nil || !strings.Contains(err.Error(), "符号链接") { + t.Fatalf("symlink root must fail closed, got %v", err) + } +} + +// 真实 symlink 复现:非 Windows 平台可以直接创建,进一步保证 seam 与真实语义一致。 +func TestCrossPlatformCoverageWalkLocalTree_rejectsSymlinkRootReal(t *testing.T) { + target := t.TempDir() + link := filepath.Join(t.TempDir(), "root-link") + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + if _, err := walkLocalTree(link); err == nil || !strings.Contains(err.Error(), "符号链接") { + t.Fatalf("symlink root must fail closed, got %v", err) + } +} + +// 根路径既非目录也非符号链接(例如普通文件被误传)时,也必须报错而非空索引。 +func TestCrossPlatformCoverageWalkLocalTree_rejectsNonDirectoryRoot(t *testing.T) { + file := filepath.Join(t.TempDir(), "not-a-dir") + mustWrite(t, file, "x") + if _, err := walkLocalTree(file); err == nil || !strings.Contains(err.Error(), "不是目录") { + t.Fatalf("file root must fail closed, got %v", err) + } +} + +// fakeSymlinkFileInfo 是一个 Mode()&os.ModeSymlink != 0 的 os.FileInfo。Windows +// 上创建目录符号链接需要管理员权限,测试通过它绕过创建步骤。 +type fakeSymlinkFileInfo struct{} + +func (fakeSymlinkFileInfo) Name() string { return "root-link" } +func (fakeSymlinkFileInfo) Size() int64 { return 0 } +func (fakeSymlinkFileInfo) Mode() os.FileMode { return os.ModeSymlink | 0o755 } +func (fakeSymlinkFileInfo) ModTime() time.Time { return time.Time{} } +func (fakeSymlinkFileInfo) IsDir() bool { return false } +func (fakeSymlinkFileInfo) Sys() any { return nil } + // 非常规文件(FIFO/符号链接目标缺失等)不计入本地索引。 func TestCrossPlatformCoverageWalkLocalTree_skipsIrregularEntries(t *testing.T) { dir := t.TempDir() From 0c2a9cb2b37d94de2434a6e4b6f11833b28d09d3 Mon Sep 17 00:00:00 2001 From: chichuan Date: Fri, 14 Aug 2026 10:31:34 +0800 Subject: [PATCH 86/86] test(drive): cover walkLocalTree's WalkDir error path via a seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new root-type guard shifted `filepath.WalkDir`'s outer error branch into the diff, and neither the macOS nor the Windows runner reaches it naturally — raising Windows coverage to 99.9365% and blocking the gate. Add a `statusWalkDir` seam and a `TestCrossPlatformCoverage` regression that swaps in a WalkDir returning a sentinel error, asserting it is surfaced unchanged. Verified locally: changed code coverage back to 100.0000%. --- internal/helpers/drive_status.go | 7 ++++++- internal/helpers/drive_status_coverage_test.go | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/internal/helpers/drive_status.go b/internal/helpers/drive_status.go index 586c004c..adacdae5 100644 --- a/internal/helpers/drive_status.go +++ b/internal/helpers/drive_status.go @@ -470,6 +470,11 @@ var md5File = func(path string) (string, error) { // FileInfo 来复现 Windows 上无法创建的符号链接场景。 var statusRootLstat = os.Lstat +// statusWalkDir 是本地目录遍历的注入 seam。改造根类型校验后,filepath.WalkDir +// 的错误分支(walkLocalTreeEntry 之外的外层错误)在 macOS/Windows 上都难以自然 +// 触发,需要 seam 让该分支可以确定性回归。 +var statusWalkDir = filepath.WalkDir + // walkLocalTree 递归遍历本地目录,只收集常规文件(跳过符号链接、设备文件等)。 // rel_path 统一使用 / 作为分隔符,相对于 root。 // @@ -491,7 +496,7 @@ func walkLocalTree(root string) (map[string]*localFile, error) { return nil, fmt.Errorf("本地根目录 %q 不是目录", root) } files := make(map[string]*localFile) - err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + err = statusWalkDir(root, func(path string, d fs.DirEntry, err error) error { return walkLocalTreeEntry(root, path, d, err, files) }) if err != nil { diff --git a/internal/helpers/drive_status_coverage_test.go b/internal/helpers/drive_status_coverage_test.go index afe13dcf..c750695d 100644 --- a/internal/helpers/drive_status_coverage_test.go +++ b/internal/helpers/drive_status_coverage_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "io" + "io/fs" "os" "path/filepath" "runtime" @@ -325,6 +326,20 @@ func TestCrossPlatformCoverageWalkLocalTree_rejectsSymlinkRootReal(t *testing.T) } } +// 根类型校验通过后 WalkDir 内部返回的错误必须原样上抛,不能被吞掉;根消失或 +// 权限变更之类的情形在 Linux/Windows runner 上都难以稳定复现,用 seam 注入。 +func TestCrossPlatformCoverageWalkLocalTree_propagatesWalkDirError(t *testing.T) { + testseam.Swap(t, &statusWalkDir, func(string, fs.WalkDirFunc) error { + return errWalkDirSentinel + }) + dir := t.TempDir() + if _, err := walkLocalTree(dir); !errors.Is(err, errWalkDirSentinel) { + t.Fatalf("walk dir error must be surfaced, got %v", err) + } +} + +var errWalkDirSentinel = errors.New("simulated walk dir failure") + // 根路径既非目录也非符号链接(例如普通文件被误传)时,也必须报错而非空索引。 func TestCrossPlatformCoverageWalkLocalTree_rejectsNonDirectoryRoot(t *testing.T) { file := filepath.Join(t.TempDir(), "not-a-dir")