Compare commits

..
Author SHA1 Message Date
Dennis 1d3a2976cf fix(wiki): document search parameter adapter 2026-08-15 00:04:54 +08:00
Dennis 600404abd0 fix(wiki): require interactive e2e confirmation 2026-08-14 23:32:43 +08:00
Dennis 247926d0fa fix(wiki): enforce auto-page item cap 2026-08-14 23:02:59 +08:00
Dennis 9ef2a4e652 fix(wiki): publish executable shortcut examples 2026-08-14 22:18:53 +08:00
Dennis d4daf9525c fix(wiki): verify copied node identity 2026-08-14 22:18:51 +08:00
Dennis 63a89e68fa test(wiki): lock confirmation before remote calls 2026-08-14 22:18:49 +08:00
Dennis 29b73a7d5e fix(wiki): close shortcut review gaps 2026-08-14 22:18:47 +08:00
Dennis 3488e11129 docs(wiki): keep review product-neutral 2026-08-14 22:18:45 +08:00
Dennis 596bdce3a1 feat(wiki): align and harden shortcut workflows 2026-08-14 22:18:43 +08:00
github-actions[bot] 58eea98f6c Merge pull request #1013 from DingTalk-Real-AI/codex/chat-reference-card-hardening
fix(chat): split references and harden card updates
2026-08-14 19:52:22 +08:00
栩朝 b53b84616e fix(cli): match ambiguous from flag exactly 2026-08-14 19:32:54 +08:00
栩朝 15a2fea0dc fix(chat): split references and harden card updates
Split chat message and group references by task, update intent routing and context budget, distinguish accepted card updates from verified writes, and explain the ambiguous chat --from flag.
2026-08-14 18:38:31 +08:00
github-actions[bot] d8da9a2e9f Merge pull request #1011 from DingTalk-Real-AI/ci-coverage-speedup
ci: shard full-suite coverage and cache merge-base profile
2026-08-14 18:32:09 +08:00
chichuan 1a6ae856ec Merge branch 'main' into ci-coverage-speedup 2026-08-14 18:16:14 +08:00
chichuan 9c6407ae74 ci: align baseline coverage cache paths 2026-08-14 18:06:49 +08:00
github-actions[bot] b9b8cc2c77 Merge pull request #954 from xlb1130/fix/85200556-im-id-flags-v3
fix(chat): converge IM ID flags
2026-08-14 09:44:45 +00:00
xlb1130 e02fdbdc8f Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-14 17:29:22 +08:00
github-actions[bot] ce529c9337 chore: update beta formula for v1.0.59-beta.1 [skip ci] 2026-08-14 09:20:43 +00:00
xlb1130 6952b22f45 Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-14 16:39:56 +08:00
chichuan 3aa06e32fa ci: shard full-suite coverage and cache merge-base profile
The Coverage context was the PR critical path (~17 min end to end):
coverage-current re-ran the whole suite serially (-p 1, ~13 min) and
coverage-baseline re-ran it again at the merge-base (~13 min) although
that profile is a pure function of the base commit.

- coverage-current now owns only the scoped (standard-tier) profile;
  full-suite candidate profiles come from a 5-way shard matrix
  (app/cli/generators/helpers/remaining) that keeps -p 1 inside each
  shard on isolated runners. scripts/ci/test-packages.sh list-coverage
  defines the shards and verify proves the union equals the previous
  single-run package set exactly once.
- the aggregate Coverage job reassembles the disjoint shard profiles
  into coverage.txt before make coverage-gate, failing closed when a
  shard file is missing, so gate semantics (100% changed-code +
  scope-matched overall non-regression) are byte-compatible.
- coverage-baseline restores the merge-base full-suite profile from an
  exact-key cache (merge-base SHA + resolved Go version) written by the
  last green main push; any miss falls back to recomputing in the
  merge-base worktree. Exact key only - no prefix fallback, a near-miss
  profile would compare the candidate against the wrong commit.
- new contract tests pin the shard matrix, the assembly step, the
  exact-key cache pair, and the absence of restore-keys; the package
  plan test also covers the coverage shard partition.
2026-08-14 16:24:00 +08:00
chichuan 97fc783cc0 Merge pull request #1010 from DingTalk-Real-AI/codex/changelog-v1.0.59-beta.1
docs: seal changelog for v1.0.59-beta.1
2026-08-14 16:23:53 +08:00
chichuan a18b1e5fe4 docs: seal changelog for v1.0.59-beta.1 2026-08-14 16:11:55 +08:00
xlb1130 b17e030d1f Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-14 15:42:27 +08:00
github-actions[bot] 03258ca045 Merge pull request #899 from DingTalk-Real-AI/fix/drive-latest-incomplete-scan
fix(drive): --latest 扫描不完整时拒绝产出 Top-N 并杜绝 sortTime 泄露
2026-08-14 07:18:38 +00:00
xlb1130 afd8422580 Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-14 15:16:17 +08:00
chichuan 4b3e0e5046 Merge branch 'main' into fix/drive-latest-incomplete-scan 2026-08-14 14:52:33 +08:00
chichuan a6f69a06ce fix(drive): --latest 扫描不完整时拒绝产出 Top-N 并杜绝 sortTime 泄露
P1-a sortTime 泄露进输出契约 —— 采集端无条件写内部排序字段 sortTime,而 emit 仅在单层(reqDepth==1)经 stripDriveDepthDecorations 整体剥离。depth>1 的所有路径都把 sortTime 漏进 stdout;#971 引入的 --type/时间区间过滤同样读该字段,泄露面随之扩大。修法:在 emitDriveDepthResult 尾部无条件 delete,一处覆盖正常 emit / SIGINT 取消 / unrecoverable partial 三条路径。采集端保持不动(内部字段,排序与筛选时才读)。

P1-b 不完整扫描仍以退出码 0 产出「Top-N」 —— 尾部拒绝 guard 只拦全局截断,不拦递归途中目录读取失败;后者把可恢复失败记进 errs[] 后照常 emit,Top-N 落在漏扫子树的不完整集合上却冒充全局最新。修法:guard 扩为 latest>0 && (truncated || len(errs)>0),走新增 driveLatestIncompleteError(LATEST_SCAN_TRUNCATED / LATEST_SCAN_INCOMPLETE 双 token,二者同真时都带,目录失败详情排在截断之前);unrecoverable 分支在 latest>0 时不吐 partial,直接回根因错误。

恢复命令必须能原样复现原候选集:driveLatestScope 快照查询域(--workspace / --space-id)、扫描根(--folder)与全部过滤条件(--pattern / --type / --start / --end),缺任一项,用户照抄后就在另一个集合上取 Top-N,看起来成功却答非所问。扫描根取 runDriveListDepth 实际使用的 rootFolderID 而非重读 flag:用户可能传 URL,解析后的 ID 才是真正被扫的目标。「按原范围重跑」原样带回原 --folder,原调用在空间根时不带。

拒绝产出后 errors[] 不再进 stdout,目录名与服务端错误文本从 JSON(编码会转义)挪进纯文本 stderr —— 原样透传会让 ANSI/OSC 序列被终端执行,可清屏、伪造彩色成功、隐藏后续输出、改窗口标题,Agent 场景还会污染上下文。改为复用仓库既有的 output.SanitizeForTerminal(canonical 实现在 pkg/validate),再把它按设计保留的换行与制表符折成空格。Reason 无需处理:它是 classifyDriveDepthReason 的固定三值映射。latest=0 的既有路径仍把原值放进 errors[] JSON,不受影响。

Windows 下恢复命令的注入面:POSIX 单引号在 cmd.exe 里不是引用,--space-id 传入 sp-7 加 & 加 whoami 时,单引号包裹后的片段粘贴进 cmd 仍会执行 whoami;而唯一做真 shell 往返验证的测试被 build tag 排除在 Windows 之外。不采用「按目标 shell 生成引用」的路线:cmd.exe 的双引号挡不住 %VAR% 展开,PowerShell 的内嵌单引号写法又与 POSIX 不同,且生成命令时无法知道用户会粘贴进哪个 shell。改为平台分流 —— POSIX 构建继续单引号内联;Windows 构建只内联全部由白名单字符组成的值,含元字符的值不进命令,降级为占位符加 strconv.Quote 展示行并标注非可执行(与 internal/auth 展示 profile 标识的既有做法同一思路)。安全性由此不再依赖引用是否正确,而依赖「不受信任的值不进入可执行命令」这个更强的不变量。

顺带修掉白名单里的一个漏洞:% 原本免引用(当初为 URL 的 %20),但 cmd.exe 会无条件展开 %VAR%,于是 %PATH% 这类值会被判为安全并原样内联。% 已移除,POSIX 侧只是多一对无害引号;并新增逐字符断言,锁定白名单不含 POSIX sh / PowerShell / cmd.exe 三套元字符,同时作为该缺陷的回归锁。

两条平台策略写成与构建平台无关的纯函数,平台文件只做一行编译期绑定,因此 Windows 形态能在 POSIX 机器上端到端验证 —— 否则该分支在 POSIX 上永不可达,平台覆盖率门禁会直接报未覆盖(第一版实测 97.3451%)。另做了一次本地全量模拟:临时把 POSIX 绑定切到 Windows 策略后跑全部测试,唯一失败的是专门断言绑定的那条,据此确认没有断言会在 Windows runner 误报,并借此修掉两条原本只在 POSIX 下成立的断言。

SIGINT 取消路径刻意不套用该防线:取消由用户主动发起、退出码 130 已明确告知结果不完整,partial 是用户的预期产物。已加注释说明并补测试锁定该契约。

skill 文档(mono/multi 两份 drive.md)原在过滤章节声明「触顶截断 truncated=true、退出码 0」,同章节又说明可与 --latest 组合 —— 组合后该描述不再成立,故补一条拒绝产出的说明,并注明 Windows 下的占位符形态,避免 agent 按旧契约预期退出码或误解析。

测试命名统一 TestCrossPlatformCoverage 前缀:平台覆盖率门禁 run-platform-coverage-gate.sh 只跑匹配 ^(TestAllShortcuts|TestCrossPlatformCoverage) 的测试。本 PR 因新增带平台名的 go:build 文件被判定 platform_sensitive,Coverage (macOS) / (Windows) 由 SKIPPED 转为实跑;不带该前缀时新增语句在平台 profile 里是零覆盖,实测 69.0476%,改名后 100.0000%(当前 114 条语句仍为 100%)。已在测试文件头写明该前缀是门禁约定而非命名风格。

发布说明按 .changes fragment 机制落在 .changes/899-drive-latest-incomplete-scan.md,不改 CHANGELOG.md。
2026-08-14 14:02:36 +08:00
github-actions[bot] 2016e7f6dc Merge pull request #992 from afterglxw/feat/global-dws
feat/global dws
2026-08-14 13:38:12 +08:00
余辉 95986bbfc5 Merge remote-tracking branch 'origin/main' into feat/global-dws 2026-08-14 13:05:43 +08:00
余辉 322077be89 fix(auth): preserve explicit MCP override on intl login 2026-08-14 13:05:32 +08:00
长真 a7a0a97115 test(chat): align open id fixtures with current format 2026-08-14 12:25:07 +08:00
xlb1130 cbaa8c9bf5 Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-14 11:39:54 +08:00
长真 0f5ecb609b fix(cli): restore audit join user guard 2026-08-14 11:38:26 +08:00
github-actions[bot] 5094c63755 Merge pull request #971 from DingTalk-Real-AI/feat/drive-sync-family
feat(drive): add local/Drive folder status, pull, push and sync
2026-08-14 11:11:46 +08:00
余辉 4a78e7c1d9 fix(auth): reconcile managed MCP login region 2026-08-14 10:56:47 +08:00
chichuan 0c2a9cb2b3 test(drive): cover walkLocalTree's WalkDir error path via a seam
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%.
2026-08-14 10:31:34 +08:00
chichuan c9d4783968 fix(drive): recheck source identity after PUT and reject symlink status root
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).
2026-08-14 10:07:58 +08:00
余辉 2116122c95 Merge remote-tracking branch 'origin/main' into feat/global-dws
# Conflicts:
#	internal/app/root_help_test.go
2026-08-14 10:04:25 +08:00
chichuan 4c3450792a Merge branch 'main' into feat/drive-sync-family 2026-08-14 08:35:35 +08:00
github-actions[bot] f55f9bc3a6 Merge pull request #998 from DingTalk-Real-AI/codex/open-dingtalk-id-format-routing
fix(chat): harden openDingTalkId target routing
2026-08-14 01:48:08 +08:00
长真 d0e6aba319 fix(cli): cover alias exclude guard branches 2026-08-14 00:23:18 +08:00
xlb1130 b0b18986b1 Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-14 00:10:33 +08:00
xlb1130 abecb0dee1 Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-13 23:28:09 +08:00
长真 d17f50b9de fix(cli): keep real flags out of alias blocked list 2026-08-13 23:26:08 +08:00
chichuan 2734e3e1ce test(drive): cover fs.WalkDir callback error short-circuit
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.
2026-08-13 22:22:28 +08:00
xlb1130 10417396f1 Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-13 22:15:34 +08:00
chichuan 41a3724e9e Merge branch 'main' into feat/drive-sync-family 2026-08-13 22:07:16 +08:00
chichuan 97b6022017 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%.
2026-08-13 21:35:48 +08:00
xlb1130 410a63ea9a Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-13 20:59:34 +08:00
长真 58c382efb7 Merge remote-tracking branch 'origin/fix/85200556-im-id-flags-v3' into fix/85200556-im-id-flags-v3 2026-08-13 20:57:29 +08:00
长真 3598586bc0 fix(cli): block plural id flag normalization 2026-08-13 20:56:43 +08:00
chichuan 504db23823 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).
2026-08-13 20:55:06 +08:00
xlb1130 b066a14f0c Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-13 20:35:07 +08:00
chichuan 8984a1c454 Merge branch 'main' into feat/drive-sync-family 2026-08-13 20:09:49 +08:00
xlb1130 94d4b5dcc9 Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-13 18:58:13 +08:00
长真 5cbf18713a docs(changes): expand chat im flag migration note 2026-08-13 18:57:44 +08:00
长真 78d94380e7 docs(changes): note chat im id flag migration 2026-08-13 18:54:00 +08:00
余辉 4b555515cd Merge remote-tracking branch 'origin/main' into feat/global-dws 2026-08-13 18:11:38 +08:00
余辉 ec83d8ff53 fix(auth): harden international login routing 2026-08-13 18:10:23 +08:00
chichuan 671a41437d Merge branch 'main' into feat/drive-sync-family 2026-08-13 17:58:26 +08:00
余辉 9f1b3e8254 Merge remote-tracking branch 'origin/main' into feat/global-dws 2026-08-13 17:09:16 +08:00
xlb1130 c718b051c2 Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-13 16:34:16 +08:00
xlb1130 6a93f14e0a Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-13 16:29:35 +08:00
长真 913b7cf9a9 chore(cli): refresh generated param aliases 2026-08-13 16:18:11 +08:00
余辉 c3a3b59ad2 Merge remote-tracking branch 'fork/feat/global-dws' into feat/global-dws 2026-08-13 16:11:20 +08:00
余辉 5b0cd561ff Merge remote-tracking branch 'origin/main' into feat/global-dws 2026-08-13 16:09:08 +08:00
余辉 6b3f2e29bd docs: add international region usage guide 2026-08-13 16:08:35 +08:00
afterglxw c2c260b3a8 Merge branch 'main' into feat/global-dws 2026-08-13 15:56:35 +08:00
chichuan 8c00068364 fix(drive): harden folder mirror safety 2026-08-13 15:49:59 +08:00
xlb1130 a354144412 Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-13 15:40:39 +08:00
长真 d525648b45 fix(chat): support read-status conversation aliases 2026-08-13 15:24:27 +08:00
余辉 427d0cc1fc docs: add international region release note 2026-08-13 15:00:00 +08:00
余辉 1f6010f998 aicr endpoint bugfix 2026-08-13 14:50:58 +08:00
余辉 d9ba74aac0 compatible with global auth 2026-08-13 14:49:09 +08:00
余辉 90070840f1 compatible with global auth 2026-08-13 14:46:34 +08:00
余辉 14818775c5 DWS support global 2026-08-13 14:46:34 +08:00
长真 f3f1174407 chore(ci): rerun pr checks 2026-08-13 13:36:42 +08:00
xlb1130 ce6d5fb538 Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-13 13:11:23 +08:00
长真 43882bf959 fix(chat): preserve schema compatibility for im flags 2026-08-13 13:02:34 +08:00
xlb1130 55c6a09bbc Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-13 11:58:53 +08:00
chichuan a55880ce82 fix(drive): reject unsafe remote names 2026-08-13 11:10:53 +08:00
xlb1130 286376df93 Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-13 10:54:21 +08:00
chichuan 88cd453db6 Merge branch 'main' into feat/drive-sync-family 2026-08-13 10:38:37 +08:00
chichuan 6fdf6e0678 fix(drive): reject sync path type conflicts 2026-08-13 10:01:10 +08:00
xlb1130 b8deec9087 Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-12 23:47:39 +08:00
长真 dbee2de1d5 fix(chat): align im id flag migration scope 2026-08-12 23:45:05 +08:00
chichuan 3e27af8e21 Merge remote-tracking branch 'origin/main' into feat/drive-sync-family 2026-08-12 23:11:48 +08:00
chichuan 4d13905cb8 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.
2026-08-12 23:06:55 +08:00
chichuan 18c8e8390c 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.
2026-08-12 22:30:09 +08:00
chichuan ae6d9aa16d 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.
2026-08-12 22:04:57 +08:00
chichuan 357b0955b1 Merge remote-tracking branch 'origin/main' into feat/drive-sync-family
# Conflicts:
#	skills/multi/dingtalk-drive/SKILL.md
2026-08-12 21:33:52 +08:00
长真 a55bd9bff8 fix(chat): complete pending id flag migrations 2026-08-12 20:53:53 +08:00
chichuan 1dabfa1dc6 fix(drive): keep pull partial results on stdout 2026-08-12 20:48:45 +08:00
xlb1130 516bd5d99c Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-12 20:09:09 +08:00
chichuan 9818f7779a Merge branch 'main' into feat/drive-sync-family 2026-08-12 20:08:13 +08:00
长真 65a00b497b fix(chat): migrate audit join validation id flag 2026-08-12 20:06:09 +08:00
chichuan 0e856f5a6e test(drive): cover dry-run collisions on Linux 2026-08-12 19:25:14 +08:00
chichuan e08fb484a8 fix(drive): make folder dry-run side-effect free 2026-08-12 19:02:56 +08:00
chichuan 2df3b99e26 Merge remote-tracking branch 'origin/main' into feat/drive-sync-family 2026-08-12 18:31:15 +08:00
chichuan 21c6581975 docs(drive): keep confirmation out of examples 2026-08-12 18:31:09 +08:00
长真 3e4a3fb9d9 Merge remote-tracking branch 'origin/fix/85200556-im-id-flags-v3' into fix/85200556-im-id-flags-v3 2026-08-12 18:06:56 +08:00
长真 1f1c27d68f fix(chat): restore audit join group flag 2026-08-12 18:06:10 +08:00
chichuan f2a3025f41 test(drive): cover Windows sync branches 2026-08-12 17:52:38 +08:00
chichuan 5282a55a54 test(drive): make MD5 failure coverage portable 2026-08-12 17:24:54 +08:00
xlb1130 1b8ca149cb Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-12 17:03:29 +08:00
chichuan 59978d9c06 Merge remote-tracking branch 'origin/main' into feat/drive-sync-family 2026-08-12 16:49:05 +08:00
chichuan 8c25736f39 Merge remote-tracking branch 'origin/main' into feat/drive-sync-family 2026-08-12 16:38:26 +08:00
chichuan dacf166935 fix(drive): require confirmation for folder sync writes 2026-08-12 16:34:10 +08:00
chichuan 54aefaaf60 test(drive): use testseam for seam swaps and expose tests to platform coverage runners 2026-08-12 15:22:08 +08:00
chichuan 0a90c0350d docs(changelog): move release note to a .changes fragment 2026-08-12 14:26:45 +08:00
chichuan 654b740532 Merge branch 'main' into feat/drive-sync-family 2026-08-12 14:25:49 +08:00
chichuan 6910bda9c7 refactor(drive): drop unreachable fixed-point guard in symlink escape check 2026-08-12 14:09:35 +08:00
chichuan f256d7a43c refactor(drive): add case-detection seam, split Windows guards, extract walk callbacks 2026-08-12 13:57:48 +08:00
chichuan 488411615f test(drive): cover parent-folder cascades and keep-both rollback paths 2026-08-12 13:38:06 +08:00
chichuan 01c1428b66 test(drive): cover sync family end-to-end paths and error branches 2026-08-12 13:33:09 +08:00
chichuan f6a699227e Merge branch 'main' into feat/drive-sync-family 2026-08-12 12:44:39 +08:00
chichuan 185fbb1544 chore(schema): record drive sync leaves as reviewed pending-review exclusions 2026-08-12 12:43:44 +08:00
长真 b6c508acdf fix(chat): canonicalize send-card id flags 2026-08-12 11:49:14 +08:00
chichuan 1d4c51a4d3 feat(drive): add local/Drive folder status, pull, push and sync 2026-08-12 11:37:47 +08:00
xlb1130 9472f4a1d9 Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-12 10:48:44 +08:00
xlb1130 90e27c4b86 Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-12 10:41:36 +08:00
长真 132dea9aaa fix(chat): hide remaining im id aliases 2026-08-11 22:51:16 +08:00
长真 5034c332fe fix(chat): converge im id flags 2026-08-11 22:38:37 +08:00
162 changed files with 21902 additions and 2025 deletions
+7
View File
@@ -0,0 +1,7 @@
---
category: Changed
---
- **Chat IM ID flags** (#954) — standardizes chat command entry points on `--conversation-id` for conversation IDs and `--message-id` for message IDs, so help, Schema, and Agent recommendations use the same canonical flags.
- **Legacy chat flag compatibility** (#954) — keeps older chat IM ID flags such as `--group`, `--id`, `--chat`, `--open-conversation-id`, `--msg-id`, and `--open-message-id` working as compatibility aliases where applicable, while hiding migrated aliases from recommended help and Schema surfaces.
- **Chat group bots target flag** (#954) — keeps `dws chat group bots` on the visible `--group` flag; this command does not register `--group-name`, and `--group` accepts either an openConversationId or a uniquely resolved group name.
@@ -0,0 +1,6 @@
---
category: Fixed
---
- **Chat card update evidence** — distinguishes an accepted update request from an independently verified visible update, preserving the real `bizId` and warning callers not to repeat an unverified write.
- **Chat command guidance** — splits message and group references by task and explains that `--from` is ambiguous between sender and time-range intent.
@@ -0,0 +1,20 @@
---
category: Fixed
---
- **Drive `--latest` refuses incomplete Top-N** (#899) — `dws drive list --latest` used to
exit 0 with a "Top-N" computed over a partially scanned tree whenever a directory read
failed mid-recursion (permission denied, API error), letting an incomplete set pose as the
globally newest files. Truncation at the 2000-item scan cap and mid-recursion directory
failures now both fail closed (`LATEST_SCAN_TRUNCATED` / `LATEST_SCAN_INCOMPLETE`), report
the first failing folder with its depth and reason, and emit a recovery command that
reproduces the original candidate set — query domain, `--folder`, `--pattern`, `--type`,
`--start` and `--end` are all carried over. On POSIX shells each user-supplied value is
quoted so a URL query string or a shell metacharacter cannot change how the copied command
parses. On Windows no quoting form is safe for both `cmd.exe` and PowerShell, so values
containing metacharacters are not inlined at all: the command carries a placeholder and the
original value is shown on a separate line marked as data rather than an executable command.
Unrecoverable errors under `--latest` return the root cause instead of a partial result.
Remote-controlled folder names and server error text are stripped of ANSI escapes and
control characters before they reach the plain-text stderr message. The internal `sortTime`
sort key no longer leaks into `drive list --depth` output on any path.
@@ -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.
@@ -0,0 +1,5 @@
---
category: Added
---
- **International DingTalk region support** — adds `.io` login and MCP routing, pre-release endpoint overrides, and profile-aware gateway selection while preserving the existing `.com` flow.
+9
View File
@@ -0,0 +1,9 @@
---
category: Added
---
- **Wiki Shortcut workflows** — publishes 20 reviewed space, member, node, and
activity shortcuts with strict collection validation, cursor handling,
write-terminal evidence, safe read-backs where the backend supports them,
task-oriented routing, and documented backend
boundaries.
+173 -38
View File
@@ -911,7 +911,7 @@ jobs:
coverage-current:
name: Coverage (current)
needs: lint
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' }}
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
@@ -926,10 +926,6 @@ jobs:
with:
go-version-file: go.mod
- name: Install archive tooling
if: needs.lint.outputs.full_suite == 'true'
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Resolve authoritative coverage base
env:
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
@@ -951,41 +947,33 @@ jobs:
- name: Build
run: make build
- name: Run current unit tests with coverage
- name: Run scoped unit tests with coverage
shell: bash
env:
DWS_PACKAGE_VERSION: 0.0.0-test
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
run: |
set -euo pipefail
if [ "$FULL_SUITE" = true ]; then
changed_output="$(
./scripts/ci/changed-test-packages.sh \
changed "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
)"
impacted_output="$(
./scripts/ci/changed-test-packages.sh \
list "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
)"
if [ -z "$changed_output" ] || [ -z "$impacted_output" ]; then
printf 'mode: atomic\n' > coverage.txt
echo "No buildable Go package needs scoped coverage." \
>> "$GITHUB_STEP_SUMMARY"
else
mapfile -t changed_packages <<< "$changed_output"
mapfile -t impacted_packages <<< "$impacted_output"
coverpkg="$(IFS=,; echo "${changed_packages[*]}")"
go test -count=1 -p 1 \
-coverpkg="$coverpkg" \
-coverprofile=coverage.txt \
-covermode=atomic \
./ ./cmd/... ./internal/... ./skills/...
else
changed_output="$(
./scripts/ci/changed-test-packages.sh \
changed "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
)"
impacted_output="$(
./scripts/ci/changed-test-packages.sh \
list "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
)"
if [ -z "$changed_output" ] || [ -z "$impacted_output" ]; then
printf 'mode: atomic\n' > coverage.txt
echo "No buildable Go package needs scoped coverage." \
>> "$GITHUB_STEP_SUMMARY"
else
mapfile -t changed_packages <<< "$changed_output"
mapfile -t impacted_packages <<< "$impacted_output"
coverpkg="$(IFS=,; echo "${changed_packages[*]}")"
go test -count=1 -p 1 \
-coverpkg="$coverpkg" \
-coverprofile=coverage.txt \
-covermode=atomic \
"${impacted_packages[@]}"
fi
"${impacted_packages[@]}"
fi
if [ "$(wc -l < coverage.txt)" -gt 1 ]; then
go tool cover -func=coverage.txt
@@ -998,6 +986,66 @@ jobs:
path: coverage.txt
retention-days: 1
coverage-current-full:
name: "Coverage (current: ${{ matrix.shard }})"
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
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shard:
- app
- cli
- generators
- helpers
- remaining
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Build
run: make build
# Each shard keeps -p 1 so the authoritative measurement stays serial
# inside one instrumented process group; shards run on isolated runners,
# and scripts/ci/test-packages.sh verify proves the shard union equals
# the previous single full-suite package set exactly once.
- name: Run current shard tests with coverage
shell: bash
env:
DWS_PACKAGE_VERSION: 0.0.0-test
COVERAGE_SHARD: ${{ matrix.shard }}
run: |
set -euo pipefail
package_output="$(./scripts/ci/test-packages.sh list-coverage "$COVERAGE_SHARD")"
test -n "$package_output"
mapfile -t packages <<< "$package_output"
test "${#packages[@]}" -gt 0
go test -count=1 -p 1 \
-coverprofile="coverage-shard-$COVERAGE_SHARD.txt" \
-covermode=atomic \
"${packages[@]}"
go tool cover -func="coverage-shard-$COVERAGE_SHARD.txt" | tail -n 1
- name: Upload current shard coverage profile
uses: actions/upload-artifact@v4
with:
name: coverage-current-shard-${{ matrix.shard }}
path: coverage-shard-${{ matrix.shard }}.txt
retention-days: 1
coverage-supporting:
name: Coverage (supporting)
needs: lint
@@ -1051,14 +1099,11 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Set up Go
id: setup-go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install archive tooling
if: needs.lint.outputs.full_suite == 'true'
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Resolve authoritative coverage base
env:
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
@@ -1076,7 +1121,34 @@ jobs:
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
# The merge-base full-suite profile is a pure function of the base
# commit. Reuse the profile published by the last green push run of
# exactly that commit instead of re-running the whole suite; any key
# mismatch falls back to authoritative recomputation. Exact key only,
# never prefix fallback: a near-miss profile would compare the
# candidate against the wrong commit.
- name: Restore cached merge-base coverage profile
id: baseline-cache
if: needs.lint.outputs.full_suite == 'true'
uses: actions/cache/restore@v4
with:
path: coverage-cache.txt
key: dws-coverage-full-v2-${{ env.COVERAGE_BASE_REF }}-go${{ steps.setup-go.outputs.go-version }}
- name: Materialize cached merge-base coverage profile
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit == 'true'
run: |
set -eu
test -s coverage-cache.txt
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
cp coverage-cache.txt coverage-base.txt
- name: Install archive tooling
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true'
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Run baseline unit tests with coverage
if: steps.baseline-cache.outputs.cache-hit != 'true'
shell: bash
env:
DWS_PACKAGE_VERSION: 0.0.0-test
@@ -1125,6 +1197,21 @@ jobs:
fi
)
- name: Prepare merge-base coverage profile cache
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true'
run: |
set -eu
test -s coverage-base.txt
test "$(head -n 1 coverage-base.txt)" = "mode: atomic"
cp coverage-base.txt coverage-cache.txt
- name: Save merge-base coverage profile cache
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: coverage-cache.txt
key: dws-coverage-full-v2-${{ env.COVERAGE_BASE_REF }}-go${{ steps.setup-go.outputs.go-version }}
- name: Upload baseline coverage profile
uses: actions/upload-artifact@v4
with:
@@ -1137,6 +1224,7 @@ jobs:
needs:
- lint
- coverage-current
- coverage-current-full
- coverage-supporting
- coverage-baseline
- coverage-darwin
@@ -1152,6 +1240,7 @@ jobs:
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
PLATFORM_SENSITIVE: ${{ needs.lint.outputs.platform_sensitive }}
CURRENT_RESULT: ${{ needs.coverage-current.result }}
CURRENT_FULL_RESULT: ${{ needs.coverage-current-full.result }}
SUPPORTING_RESULT: ${{ needs.coverage-supporting.result }}
BASELINE_RESULT: ${{ needs.coverage-baseline.result }}
DARWIN_RESULT: ${{ needs.coverage-darwin.result }}
@@ -1159,6 +1248,7 @@ jobs:
run: |
failed=0
current_expected=success
current_full_expected=skipped
supporting_expected=skipped
baseline_expected=success
native_expected=skipped
@@ -1166,6 +1256,8 @@ jobs:
current_expected=skipped
baseline_expected=skipped
elif [ "$FULL_SUITE" = true ]; then
current_expected=skipped
current_full_expected=success
supporting_expected=success
fi
if [ "$CHANGELOG_ONLY" != true ] &&
@@ -1176,6 +1268,7 @@ jobs:
for profile in \
"current:$CURRENT_RESULT:$current_expected" \
"current shards:$CURRENT_FULL_RESULT:$current_full_expected" \
"supporting:$SUPPORTING_RESULT:$supporting_expected" \
"baseline:$BASELINE_RESULT:$baseline_expected"
do
@@ -1211,6 +1304,7 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Set up Go
id: setup-go
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
uses: actions/setup-go@v5
with:
@@ -1234,11 +1328,12 @@ jobs:
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
- name: Download current coverage profile
- name: Download current coverage profiles
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
uses: actions/download-artifact@v4
with:
name: coverage-current-profile
pattern: coverage-current-*
merge-multiple: true
path: .
- name: Download supporting coverage profiles
@@ -1255,6 +1350,26 @@ jobs:
name: coverage-baseline-profile
path: .
# Shard profiles cover disjoint package sets, so their block-level
# concatenation is the same candidate profile one serial run produced.
# Every expected shard must be present; a missing shard would silently
# shrink the scope-matched overall comparison.
- name: Assemble full-suite coverage profile
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true'
shell: bash
run: |
set -euo pipefail
test ! -f coverage.txt
for shard in app cli generators helpers remaining; do
profile="coverage-shard-$shard.txt"
test -f "$profile"
test "$(head -n 1 "$profile")" = "mode: atomic"
done
printf 'mode: atomic\n' > coverage.txt
for shard in app cli generators helpers remaining; do
tail -n +2 "coverage-shard-$shard.txt" >> coverage.txt
done
- name: Enforce coverage gate
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
env:
@@ -1275,6 +1390,26 @@ jobs:
COVERAGE_ADDITIONAL_DIFF_PROFILE="$additional_profile" \
make coverage-gate BASE_REF="$COVERAGE_BASE_REF"
# Publish this push's full-suite profile as the merge-base cache for
# future PRs whose merge-base is exactly this commit. Saved only after
# the gate passed so a broken run never becomes a baseline. Both producer
# and consumer use coverage-cache.txt because the cache version includes
# the configured path as well as the compression tool.
- name: Prepare push coverage profile as merge-base cache
if: github.event_name == 'push' && needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true'
run: |
set -eu
test -s coverage.txt
test "$(head -n 1 coverage.txt)" = "mode: atomic"
cp coverage.txt coverage-cache.txt
- name: Save push coverage profile as merge-base cache
if: github.event_name == 'push' && needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true'
uses: actions/cache/save@v4
with:
path: coverage-cache.txt
key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go.outputs.go-version }}
- name: Generate coverage report
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
run: |
+64
View File
@@ -6,6 +6,70 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.59-beta.1] - 2026-08-14
### 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.
- **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.
- **International DingTalk region support** — adds `.io` login and MCP routing, pre-release endpoint overrides, and profile-aware gateway selection while preserving the existing `.com` flow.
### Changed
- **Chat identity routing** — validates explicit `openDingTalkId` inputs and improves name, `userId`, and `openDingTalkId` routing for message shortcuts.
### Fixed
- **Drive `--latest` refuses incomplete Top-N** (#899) — `dws drive list --latest` used to
exit 0 with a "Top-N" computed over a partially scanned tree whenever a directory read
failed mid-recursion (permission denied, API error), letting an incomplete set pose as the
globally newest files. Truncation at the 2000-item scan cap and mid-recursion directory
failures now both fail closed (`LATEST_SCAN_TRUNCATED` / `LATEST_SCAN_INCOMPLETE`), report
the first failing folder with its depth and reason, and emit a recovery command that
reproduces the original candidate set — query domain, `--folder`, `--pattern`, `--type`,
`--start` and `--end` are all carried over. On POSIX shells each user-supplied value is
quoted so a URL query string or a shell metacharacter cannot change how the copied command
parses. On Windows no quoting form is safe for both `cmd.exe` and PowerShell, so values
containing metacharacters are not inlined at all: the command carries a placeholder and the
original value is shown on a separate line marked as data rather than an executable command.
Unrecoverable errors under `--latest` return the root cause instead of a partial result.
Remote-controlled folder names and server error text are stripped of ANSI escapes and
control characters before they reach the plain-text stderr message. The internal `sortTime`
sort key no longer leaks into `drive list --depth` output on any path.
- **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.
- **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.
- **Doc and Drive parameter aliases** — normalizes reviewed identifier, pagination, path, version, and role synonyms while blocking ambiguous values before dispatch.
## [1.0.58] - 2026-08-13
This release promotes the sealed `v1.0.58-beta.6` contents to stable.
+11 -11
View File
@@ -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.6"
version "1.0.59-beta.1"
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.6/dws-darwin-arm64.tar.gz"
sha256 "8f55497b84113f81b318e087c723016a02eded1cb5784cbd0811fe527d5852ca"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-darwin-arm64.tar.gz"
sha256 "36a30f3496e0f759c15c0b09f67dbd23b8ecdfff2eebe572f88125b26485830f"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-darwin-amd64.tar.gz"
sha256 "b1762f1640310fb4100634fe54d9baace46384bb270c59148fe306275554d491"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-darwin-amd64.tar.gz"
sha256 "e7a04906380efd8da88cd112e6a512bb6470a3956dc370150037ed6e314db445"
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.6/dws-linux-arm64.tar.gz"
sha256 "55393310ef0e1f24ea2c0dc22f00c0eb3b343edc2deb18d0db7838cda65af25d"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-linux-arm64.tar.gz"
sha256 "f59ab055f3e841e4cebc964ae3ef969668475548abaaf8bede44afdca9a3e28d"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-linux-amd64.tar.gz"
sha256 "3830f77d09b4da4aa39f0c08d772ff3fa1ffb837eb15bb417f97edbccb073b7d"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-linux-amd64.tar.gz"
sha256 "2c8f919489d958c7d49262615e81faac70a9fbcae2d589ab54a0bb3c5700a057"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-skills.zip"
sha256 "f304a883a4f9e938b26a44692cd5a8d3d8704ba70ee7f33ba7c288434da72b6f"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-skills.zip"
sha256 "25f4a7e1d01fa4d771d79201b34b11ee8a24182bdcdc94bfb98d2bd5845bed3b"
end
def install
+1 -1
View File
@@ -18,7 +18,7 @@ help:
@printf "Available targets:\n"
@printf " make build - Build the dws CLI binary\n"
@printf " make test - Run the Go test suite\n"
@printf " make test-plan - Verify every default Go package belongs to one CI test shard\n"
@printf " make test-plan - Verify CI test and full-suite coverage package plans cover their scopes exactly once\n"
@printf " make test-auth-legacy-compat - Run stable legacy authentication compatibility regressions\n"
@printf " make lint - Run formatting checks, go vet, and staticcheck\n"
@printf " make format-check - Check all repository Go source files with gofmt\n"
+1
View File
@@ -786,6 +786,7 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
## Reference & Docs
- [International DingTalk (`.io`) guide](./docs/international-region-guide.md) — international login, domestic/international profile switching, isolated testing, and troubleshooting
- [Command Index](./docs/command-index.md) — every runtime command with description and when-to-use guidance
- [Reference](./docs/reference.md) — environment variables, exit codes, output formats, shell completion
- [Architecture](./docs/architecture.md) — static endpoint pipeline, command surface, transport layer
+1
View File
@@ -777,6 +777,7 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
## 参考与文档
- [国际版(`.io`)使用手册](./docs/international-region-guide.zh-CN.md) — 国际版登录、国内/国际 profile 切换、隔离验证与排障
- [命令索引](./docs/command-index.md) — 全部运行时命令,带描述与使用场景
- [参考手册](./docs/reference.md) — 环境变量、退出码、输出格式、Shell 补全
- [架构设计](./docs/architecture.md) — 静态端点管道、命令面、Transport 层
+15 -2
View File
@@ -201,8 +201,21 @@ base_ref=$(git merge-base HEAD origin/main)
standard PR, CI derives changed packages and their reverse-dependency test
closure, then generates candidate and merge-base profiles with the same test
scope and `coverpkg`. High-risk and protected-main runs use the complete
profiles. Supporting and (when platform-selected) native profiles are
generated before the aggregate `Coverage` context evaluates them. The
profiles. The complete candidate profile is produced by disjoint per-shard
helper jobs (`scripts/ci/test-packages.sh list-coverage`, kept serial with
`-p 1` inside each shard; `verify` proves the shard union equals the
full-suite scope exactly once) and concatenated in the aggregate job before
enforcement. The complete merge-base profile is restored from an exact-key
cache written by the last green `main` push of that same commit (key:
merge-base SHA plus resolved Go version); any miss falls back to recomputing
it in a merge-base worktree. The trusted `main` producer and PR consumer use
the same dedicated cache profile path because GitHub includes that path in the
cache version; the runtime-facing candidate and baseline filenames remain
separate. Near-miss reuse is forbidden — the caches carry no prefix restore
keys, because a neighbouring commit's profile would compare the candidate
against the wrong baseline. Supporting and (when
platform-selected) native profiles are generated before the aggregate
`Coverage` context evaluates them. The
aggregate and native gates require 100% coverage for changed executable Go
statements. Overall coverage remains an unrounded, zero-tolerance,
scope-matched merge-base non-regression check. Candidate and baseline profiles
+154
View File
@@ -0,0 +1,154 @@
# International DingTalk (`.io`) Guide
This guide explains how to log in to the international DingTalk region and run DWS commands against `*.dingtalk.io` services.
## Region behavior
- `dws auth login --intl` creates or refreshes an international login using the `.io` login, OAuth, and MCP services.
- Omitting `--intl` keeps the existing domestic `.com` behavior.
- `--intl` is a login option, not a global option for business commands. After login, commands such as `contact`, `calendar`, and `doc` derive the region from the selected Token/profile.
- Each new Token records its login region. Switching profiles therefore switches the official DingTalk gateway region automatically.
- `--international` is a compatibility alias. Prefer `--intl` in new scripts.
For the complete Chinese guide, see [DWS 国际版(DingTalk `.io`)使用手册](./international-region-guide.zh-CN.md).
## Check availability
```bash
dws auth login --help
```
The help output must include `--intl` and `--international`.
When validating a source checkout, build it first and use `./dws` so an older binary on `PATH` is not invoked accidentally:
```bash
make build
./dws auth login --help
```
## Log in
Browser login:
```bash
dws auth login --intl
```
Device flow for SSH, containers, and headless environments:
```bash
dws auth login --intl --device
```
User OAuth with custom application credentials:
```bash
dws auth login --intl \
--client-id <APP_KEY> \
--client-secret <APP_SECRET>
```
This mode still requires the user to complete OAuth authorization in a browser; it is not a userless `client_credentials` login. The application must be configured on the international developer platform with the required callback and permissions. Never commit an AppSecret to source control or include it in logs.
## Verify the login
```bash
dws auth status --format json
dws profile list --format json
dws contact user get-self
```
The last command is a read-only smoke check. If the organization has not enabled CLI access, an organization administrator must enable it or approve the access request on the international developer platform.
## Use domestic and international profiles together
```bash
# Domestic (.com)
dws auth login
# International (.io)
dws auth login --intl
# Find the stable profile selectors
dws profile list --format json
```
Persistently switch profiles:
```bash
dws profile switch <corpId>:<userId>
```
Toggle back to the previous profile:
```bash
dws profile switch -
```
Select a profile for one command without changing the default:
```bash
dws --profile <corpId>:<userId> contact user get-self
```
Do not add `--intl` to business commands. DWS routes official endpoints from the selected profile's Token region.
## Isolated smoke testing
Use a separate configuration directory to avoid changing the normal `~/.dws` login state:
```bash
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth login --intl
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth status --format json
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws contact user get-self
```
Use the same `DWS_CONFIG_DIR` for every command. Use `./dws` for a source build and `dws` for an installed release.
## Pre-release overrides (maintainers only)
Normal international users need only `--intl`; they should not set `--pre-url` or `--mcp-url`.
Maintainers can test the pre-release login/MCP pair with:
```bash
dws auth login --intl --pre-url https://pre-login.dingtalk.io
```
A corresponding `pre-mcp.*` URL is also accepted, and DWS derives the paired `pre-login.*` / `pre-mcp.*` bases. `--mcp-url` explicitly overrides the MCP base URL for that login.
Pre-release services may require internal network access or allowlisted accounts. `--pre-url` is intended primarily for the MCP-managed credential flow. Do not combine it with direct custom `--client-id/--client-secret` mode unless the pre-release API contract explicitly supports that combination.
## Troubleshooting
### The browser still opens a `.com` page
1. Run `dws auth login --help` and confirm `--intl` is present.
2. For a source checkout, use `./dws` instead of an older installed binary.
3. Confirm the executed command is `dws auth login --intl`.
### A business command appears to use the wrong region
Run `dws profile list --format json`, then switch with the exact `<corpId>:<userId>` selector or use the global `--profile` option. For a legacy Token created before region metadata existed, reauthorize it with `dws auth login --intl` for an international account or `dws auth login` for a domestic account.
### Login succeeds but the command reports missing permission
This normally means the organization has not enabled CLI access or the application lacks a required permission. It does not by itself indicate a region-routing failure.
### Should I edit `~/.dws/mcp_url` manually?
No. Normal users should establish the login with `dws auth login` or `dws auth login --intl`. DWS then routes official endpoints from the selected Token/profile. Manual configuration is reserved for maintainers who explicitly control the target environment.
## Command reference
| Scenario | Command |
|---|---|
| Domestic browser login | `dws auth login` |
| International browser login | `dws auth login --intl` |
| International device login | `dws auth login --intl --device` |
| Check auth state | `dws auth status --format json` |
| List profiles | `dws profile list --format json` |
| Persistently switch profile | `dws profile switch <corpId>:<userId>` |
| Toggle to previous profile | `dws profile switch -` |
| Select a profile once | `dws --profile <corpId>:<userId> <command>` |
+185
View File
@@ -0,0 +1,185 @@
# DWS 国际版(DingTalk `.io`)使用手册
本手册适用于使用钉钉国际版账号登录并调用国际站服务的用户。
## 核心规则
- `dws auth login --intl` 创建或刷新国际版登录,使用 `*.dingtalk.io` 登录、鉴权和 MCP 服务。
- 不传 `--intl` 时仍使用国内钉钉 `*.dingtalk.com`,原有链路保持不变。
- `--intl` 只用于登录命令。登录完成后,`contact`、`calendar`、`doc` 等业务命令不需要再传该参数。
- 每个 Token 会记录登录区域。执行业务命令时,DWS 根据当前或 `--profile` 指定的账号自动选择 `.com` 或 `.io` 网关。
- `--international` 是 `--intl` 的兼容别名;新脚本推荐使用较短的 `--intl`。
## 确认当前版本支持国际版
运行:
```bash
dws auth login --help
```
帮助中应包含:
```text
--intl
--international
```
从源码分支验证时,先在仓库根目录构建,并始终使用本次构建的 `./dws`,避免误用系统中已安装的旧版本:
```bash
make build
./dws auth login --help
```
## 国际版登录
### 浏览器登录
```bash
dws auth login --intl
```
DWS 会打开国际版登录页面。完成扫码或账号授权后,登录结果会保存为本机 profile。
### 设备码登录
适用于 SSH、容器或没有可用浏览器的环境:
```bash
dws auth login --intl --device
```
按照终端提示,在另一台可打开浏览器的设备上完成授权。
### 使用自有应用凭证完成用户 OAuth
```bash
dws auth login --intl \
--client-id <APP_KEY> \
--client-secret <APP_SECRET>
```
该模式仍然需要用户在浏览器中完成 OAuth 授权,不是无用户授权的 `client_credentials` 登录。应用必须在国际版开放平台正确配置回调地址和所需权限。不要在命令历史、日志或 PR 中提交真实的 AppSecret。
## 验证登录和业务调用
查看当前登录状态:
```bash
dws auth status --format json
```
列出本机全部账号并找到当前 profile:
```bash
dws profile list --format json
```
执行一个只读命令验证国际链路,例如:
```bash
dws contact user get-self
```
登录状态正常但业务命令提示组织未开通 CLI 时,需要由国际版组织管理员在国际版开发者平台开启 CLI 访问或完成授权审批。
## 国内版和国际版账号并存
可以在同一台机器上分别登录国内版和国际版账号:
```bash
# 国内版(.com)
dws auth login
# 国际版(.io)
dws auth login --intl
# 查看稳定的 profile 选择器
dws profile list --format json
```
持久切换账号:
```bash
dws profile switch <corpId>:<userId>
```
切回上一个账号:
```bash
dws profile switch -
```
只为单次命令指定账号,不修改默认账号:
```bash
dws --profile <corpId>:<userId> contact user get-self
```
DWS 会按照选中 profile 的 Token 区域自动选择 `.com` 或 `.io`,不需要在业务命令上追加 `--intl`。
## 使用独立配置目录进行验证
如果不希望测试登录影响日常使用的 `~/.dws`,可以指定独立配置目录:
```bash
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth login --intl
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth status --format json
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws contact user get-self
```
请在三条命令中使用同一个 `DWS_CONFIG_DIR`。验证源码分支时使用 `./dws`;验证已安装版本时可改为 `dws`。
## 预发参数(仅维护者)
普通国际版用户只需要 `--intl`,不要配置 `--pre-url` 或 `--mcp-url`。
维护者验证预发登录/MCP 链路时可以使用:
```bash
dws auth login --intl --pre-url https://pre-login.dingtalk.io
```
也可以传入对应的 `pre-mcp.*` 地址;DWS 会推导配套的 `pre-login.*` / `pre-mcp.*` 地址。`--mcp-url` 用于显式覆盖本次登录的 MCP base URL。
预发环境可能只对内网或特定测试账号开放。`--pre-url` 主要服务于 MCP 托管凭证登录流程;除非预发 API 契约已经明确支持,否则不要把它与自有 `--client-id/--client-secret` 直连模式组合使用。
## 常见问题
### 仍然打开 `.com` 登录页面
1. 运行 `dws auth login --help`,确认当前二进制包含 `--intl`。
2. 从源码验证时使用 `./dws`,不要误用 PATH 中的旧版本。
3. 确认实际执行的是 `dws auth login --intl`,而不是普通 `dws auth login`。
### 业务命令似乎使用了错误区域
先检查当前账号:
```bash
dws profile list --format json
```
然后使用精确的 `<corpId>:<userId>` 切换或通过全局 `--profile` 单次指定。对于在区域字段引入前生成的历史 Token,建议使用正确的登录方式重新授权:国际账号执行 `dws auth login --intl`,国内账号执行 `dws auth login`。
### 登录成功但提示没有权限
这通常是组织 CLI 准入或应用授权问题,不代表区域路由失败。请确认目标组织已开启 CLI 访问,并且当前应用拥有命令所需权限。
### 是否需要手工修改 `~/.dws/mcp_url`
不需要。正常使用应通过 `dws auth login` 或 `dws auth login --intl` 建立登录态;业务命令会根据选中的 Token/profile 自动路由。手工修改配置只适用于明确了解目标环境的维护者调试场景。
## 命令速查
| 场景 | 命令 |
|---|---|
| 国内版浏览器登录 | `dws auth login` |
| 国际版浏览器登录 | `dws auth login --intl` |
| 国际版设备码登录 | `dws auth login --intl --device` |
| 查看登录状态 | `dws auth status --format json` |
| 查看所有账号 | `dws profile list --format json` |
| 持久切换账号 | `dws profile switch <corpId>:<userId>` |
| 切回上一个账号 | `dws profile switch -` |
| 单次指定账号 | `dws --profile <corpId>:<userId> <command>` |
+197 -4
View File
@@ -1,6 +1,6 @@
{
"generated_at": "2026-08-12T00:10:44.511794",
"count": 399,
"generated_at": "2026-08-14T13:50:36.324505",
"count": 418,
"results": [
{
"suite": "semantic",
@@ -3659,11 +3659,204 @@
"status": "real-ok"
},
{
"suite": "read",
"suite": "semantic",
"service": "wiki",
"command": "+delete-space",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "删除前读取影响目标,经高风险确认后只接受 success=true 终态。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+feed-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "补充知识库协作动态查询,严格验证 feeds 并保留游标。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+member-add",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "支持 1-30 个 userId 与四类角色;严格要求写接口终态,并明确后端无法提供精确成员读回。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+member-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证成员数组并公开真实单次上限 50;后端无游标时不伪造 page-all。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+member-remove",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "支持批量 userId 移除;严格要求写接口终态,并明确后端无法提供精确成员读回。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+member-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "补充成员角色更新;严格要求写接口终态,并明确后端无法提供精确成员读回。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+move",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "同一入口覆盖 Wiki 内移动和我的文档在线节点入 Wiki,并校验目标 workspace/folder。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+move-to-drive",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "同步移动 Wiki 节点到我的文档并读回验证 workspace 变化,免去异步任务轮询。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+node-copy",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "高风险确认后复制,必须取得新 nodeId 并读回副本,避免空响应被当作成功。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+node-create",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "支持七种 Wiki 节点类型,创建后要求 nodeId 并读取元数据验证。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+node-delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "删除前读取并核对 workspace,经高风险确认后要求 success=true 终态证据。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+node-get",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "统一节点 ID 或在线文档 URL 的元数据读取,补充文档域属性视角。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+node-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格区分显式空目录与假空成功,稳定投影节点并保留 nextCursor/hasMore。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+node-search",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "补充库内关键词和扩展名搜索,并拒绝假空结果。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+resolve-space",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "把关键词搜索收敛为唯一 workspaceId;零命中与多命中显式分流,绝不猜测。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+space-create",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "创建后要求 workspaceId 并通过空间详情读回验证真实落库。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+space-get",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "补充知识库详情入口,并要求 workspaceId 业务证据。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+space-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格区分显式空知识库列表与缺失、畸形或内部错误响应,并保留真实分页证据。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+space-search",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按关键词搜索知识库并拒绝把缺失业务数组误报为零命中。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+wiki-new-doc",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按空间名精确唯一解析、创建在线文档并读回验证;零命中和歧义均显式失败。",
"availability": "available"
}
]
}
+117
View File
@@ -0,0 +1,117 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>DWS Wiki Shortcut 全景评审</title>
<style>
:root{--ink:#14213d;--muted:#5c677d;--line:#dbe4f0;--paper:#fff;--bg:#f3f7fb;--blue:#1769e0;--cyan:#00a6a6;--green:#178746;--amber:#a45b00;--red:#b42318;--shadow:0 14px 34px rgba(20,33,61,.08)}
*{box-sizing:border-box}body{margin:0;overflow-x:hidden;background:linear-gradient(150deg,#edf5ff 0,#f8fbff 45%,#eef8f5 100%);color:var(--ink);font:15px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif}
main,.card,.two>*{min-width:0}main{width:min(1180px,calc(100% - 32px));margin:28px auto 72px}.hero,.card{background:rgba(255,255,255,.96);border:1px solid var(--line);border-radius:22px;box-shadow:var(--shadow)}
.hero{padding:38px;background:radial-gradient(circle at 95% 0,#dff8f3,transparent 36%),linear-gradient(135deg,#fff,#f5f9ff)}h1{font-size:34px;line-height:1.2;margin:0 0 10px}.lead{font-size:17px;color:var(--muted);max-width:900px}.meta{display:flex;gap:10px;flex-wrap:wrap;margin-top:20px}.pill{border:1px solid #cbd9ea;border-radius:999px;padding:5px 11px;background:#fff;font-size:13px}
.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:18px 0}.metric{padding:20px}.metric b{display:block;font-size:31px;color:var(--blue)}.metric span{color:var(--muted)}
section{margin-top:22px}.card{padding:26px}h2{font-size:23px;margin:0 0 14px}h3{font-size:17px;margin:22px 0 8px}.callout{border-left:4px solid var(--blue);background:#f2f7ff;padding:14px 16px;border-radius:8px}.warn{border-color:var(--amber);background:#fff8eb}.ok{border-color:var(--green);background:#effbf4}
table{width:100%;border-collapse:collapse;font-size:14px}th,td{text-align:left;vertical-align:top;border-bottom:1px solid var(--line);padding:11px 9px}th{color:#41516b;background:#f7f9fc;position:sticky;top:0}code{background:#edf2f8;border-radius:5px;padding:2px 5px;color:#24466e}.tag{display:inline-block;border-radius:999px;padding:2px 8px;font-size:12px;font-weight:650;white-space:nowrap}.full{background:#e6f6ec;color:#116436}.partial{background:#fff0d5;color:#875000}.extra{background:#e8f1ff;color:#1854a5}.fixed{background:#f1eaff;color:#6338a5}
.toolbar{display:flex;flex-wrap:wrap;gap:10px;margin:12px 0}.toolbar input,.toolbar select{border:1px solid #bdcada;border-radius:10px;padding:9px 11px;background:#fff;min-width:min(220px,100%);max-width:100%;flex:1 1 220px}.matrix{max-height:620px;overflow:auto;border:1px solid var(--line);border-radius:12px}.two{display:grid;grid-template-columns:1fr 1fr;gap:18px}.small{color:var(--muted);font-size:13px}ul{padding-left:20px}.footer{color:var(--muted);text-align:center;margin-top:22px}@media(max-width:850px){.grid,.two{grid-template-columns:1fr 1fr}.hero{padding:25px}}@media(max-width:560px){.grid,.two{grid-template-columns:1fr}main{width:min(100% - 18px,1180px)}.card{padding:18px}h1{font-size:28px}}
</style>
</head>
<body><main>
<header class="hero">
<h1>DWS Wiki Shortcut 全景评审</h1>
<p class="lead">以 13 项成熟 Wiki 用户任务为基线,重新审视 DWS 的空间、成员、节点与动态能力。本次不是按命令名凑数:每个入口都要求真实业务证据,缺失数组、畸形响应、空确认或读回不一致一律失败。</p>
<div class="meta"><span class="pill">评审日期 2026-08-14</span><span class="pill">独立 worktree / 独立分支</span><span class="pill">真实组织数据 E2E 28/28</span><span class="pill">报告已去标识化</span></div>
</header>
<div class="grid">
<div class="card metric"><b>20</b><span>公开 Wiki Shortcuts</span></div>
<div class="card metric"><b>13/13</b><span>基线用户任务有对应路径</span></div>
<div class="card metric"><b>7</b><span>DWS 额外场景</span></div>
<div class="card metric"><b>20/20</b><span>真实数据能力已触达</span></div>
</div>
<section class="card">
<h2>结论先行</h2>
<div class="callout ok"><strong>DWS 已形成比“API 快捷别名”更完整的 Wiki 任务层。</strong> 基线中的 13 个用户任务均有对应入口;DWS 还提供空间搜索/详情/唯一解析、成员角色更新、库内节点搜索、协作动态和按空间名新建文档。创建、复制、移动等关键写能力从“请求发出”升级为“终态 + ID + 读回”成功标准。</div>
<div class="callout warn" style="margin-top:12px"><strong>能力边界必须诚实表达。</strong> DingTalk 成员接口不提供游标,单次真实上限是 50,因此不能实现成员 <code>--page-all</code>;成员身份只接受同组织可用的 userId,无法提供 email/open_id 等多种身份模式;节点创建也没有等价的 origin/shortcut 模式。这些差异保留为明确边界,而不是用本地循环或空结果伪装。</div>
</section>
<section class="card">
<h2>13 项基线任务逐条映射</h2>
<div class="matrix"><table><thead><tr><th>基线任务</th><th>DWS 主入口</th><th>结论</th><th>DWS 视角与边界</th></tr></thead><tbody>
<tr><td><code>+space-list</code></td><td><code>wiki +space-list</code></td><td><span class="tag full">完整对齐</span></td><td>严格空集合、游标续传、自动翻页、停滞检测;支持组织/我的知识库。</td></tr>
<tr><td><code>+space-create</code></td><td><code>wiki +space-create</code></td><td><span class="tag full">超过</span></td><td>公开真实 32 字符名称上限;创建后按 workspaceId 读回。</td></tr>
<tr><td><code>+delete-space</code></td><td><code>wiki +delete-space</code></td><td><span class="tag full">超过</span></td><td>预读目标、高风险确认、只接受 <code>success=true</code>;兼容 <code>+space-delete</code>。</td></tr>
<tr><td><code>+member-add</code></td><td><code>wiki +member-add</code></td><td><span class="tag partial">任务对齐</span></td><td>支持 1–30 个 userId 与四种角色;以写接口终态作为成功证据,不把最多 50 条的名单误作精确读回。</td></tr>
<tr><td><code>+member-list</code></td><td><code>wiki +member-list</code></td><td><span class="tag partial">任务对齐</span></td><td>严格成员数组、角色过滤、真实上限 50;后端无游标,不能提供诚实的 page-all。</td></tr>
<tr><td><code>+member-remove</code></td><td><code>wiki +member-remove</code></td><td><span class="tag partial">任务对齐</span></td><td>支持批量 userId;只接受写接口明确终态,并公开无法进行精确成员读回的边界。</td></tr>
<tr><td><code>+node-list</code></td><td><code>wiki +node-list</code></td><td><span class="tag full">完整对齐</span></td><td>正确跨域路由 doc/list_nodes,严格空目录、分页与自动翻页。</td></tr>
<tr><td><code>+node-get</code></td><td><code>wiki +node-get</code></td><td><span class="tag partial">任务对齐</span></td><td>支持 DingTalk 节点 ID/在线文档 URL 并返回文档域元数据;不接受跨平台专用的 token/type 组合。</td></tr>
<tr><td><code>+node-create</code></td><td><code>wiki +node-create</code></td><td><span class="tag partial">任务对齐</span></td><td>支持 adoc/axls/able/appt/adraw/amind/folder 并读回;无 origin/shortcut 等价接口。</td></tr>
<tr><td><code>+node-copy</code></td><td><code>wiki +node-copy</code></td><td><span class="tag full">超过</span></td><td>确认后要求新 nodeId 并读取副本;底层面向在线节点,不把 .dlink 当独立副本。</td></tr>
<tr><td><code>+move</code></td><td><code>wiki +move</code></td><td><span class="tag partial">任务对齐</span></td><td>同一入口支持 Wiki 内移动和“我的文档”在线节点入 Wiki,读回 workspace/folder;底层接口没有 apply 权限迁移开关。</td></tr>
<tr><td><code>+move-to-drive</code></td><td><code>wiki +move-to-drive</code></td><td><span class="tag full">超过</span></td><td>DWS 当前接口同步完成并读回 workspace 变化,无需暴露异步 task 轮询。</td></tr>
<tr><td><code>+node-delete</code></td><td><code>wiki +node-delete</code></td><td><span class="tag full">超过</span></td><td>预读并核对 workspace,高风险确认,要求删除终态。</td></tr>
</tbody></table></div>
</section>
<section class="card">
<h2>DWS 可挖掘的 7 个额外场景</h2>
<div class="two">
<div><h3>定位与创建链</h3><ul><li><code>+space-search</code>:严格关键词搜索。</li><li><code>+space-get</code>:空间详情与 workspaceId 证据。</li><li><code>+resolve-space</code>:唯一命中直出 ID,多命中拒绝猜测。</li><li><code>+wiki-new-doc</code>:空间名解析 → 创建 → 文档读回。</li></ul></div>
<div><h3>治理与巡检链</h3><ul><li><code>+member-update</code>:角色变更终态与不可精确读回声明。</li><li><code>+node-search</code>:库内关键词/扩展名搜索,严格零命中。</li><li><code>+feed-list</code>:知识库动态时间线与服务端 exclude-file 过滤。</li></ul></div>
</div>
</section>
<section class="card">
<h2>隐藏问题与修复</h2>
<table><thead><tr><th>原问题</th><th>错误风险</th><th>本次修复</th></tr></thead><tbody>
<tr><td>5 个旧 Wiki Shortcut 可直接执行,但只有 1 个进入公开目录。</td><td>Help、Schema、Skill 发现链与运行面漂移。</td><td><span class="tag fixed">20 项统一评审</span> 全部具备 Contract/Safety/Result 与语义目录记录。</td></tr>
<tr><td>列表投影找不到数组或遇到坏元素时返回空 slice。</td><td>把内部错误、字段漂移误报为“没有数据”。</td><td><span class="tag fixed">失败关闭</span> 只有响应中真实存在的 <code>[]</code> 才是合法空集合。</td></tr>
<tr><td>节点列表 Shortcut 调错 Wiki MCP 服务。</td><td>真实后端 <code>success=false</code>,Mock/静态检查看不出。</td><td><span class="tag fixed">跨域路由</span> 明确调用 doc/list_nodes,并纳入真实 E2E。</td></tr>
<tr><td>成员帮助宣称最大 200。</td><td>真实接口超过 50 直接参数错误。</td><td><span class="tag fixed">真实上限</span> Shortcut 与原子 Help 均改为 50,并在本地提前拒绝。</td></tr>
<tr><td>成员写操作从最多 50 条、不可分页的名单推断成员存在或缺失。</td><td>目标在截断部分时会误报写失败,或把未验证的移除报告为已读回。</td><td><span class="tag fixed">终态证据</span> 只接受写接口 <code>success=true</code>,并在结果中明确 <code>readbackAvailable=false</code>。</td></tr>
<tr><td>空间搜索的稳定工作流属性名与实际请求属性名不同。</td><td>直接改写已发布的 <code>query/limit</code> 会造成无版本 Schema 破坏;继续隐式转换又会让审计者误以为请求同名透传。</td><td><span class="tag fixed">显式复合适配</span> 最终 Schema 保留兼容属性并明确声明转换为 <code>keyword/pageSize</code>;回归测试同时锁定最终交付和精确请求参数。</td></tr>
<tr><td>知识库名称帮助宣称最大 100。</td><td>真实接口超过 32 失败。</td><td><span class="tag fixed">真实上限</span> Help 与 Shortcut 校验统一为 32。</td></tr>
<tr><td>复制/移动/创建只把无异常视为成功。</td><td>空确认、未知远端效果或移动未到目标仍可能被接受。</td><td><span class="tag fixed">读回证明</span> 在后端具备精确查询能力时检查 success、业务 ID、workspace/folder 等最终状态。</td></tr>
</tbody></table>
</section>
<section class="card">
<h2>真实数据 E2E 证据矩阵</h2>
<p class="small">28 项业务断言全部通过。测试使用一次性空知识库、临时在线文档与一名同组织内部测试成员;所有对象在 finally 清理。报告不保存对象 ID、成员身份、组织信息、URL、trace 或原始响应。</p>
<div class="toolbar"><input id="q" placeholder="筛选命令或证据"><select id="g"><option value="">全部分组</option><option>空间</option><option>成员</option><option>节点</option><option>动态</option></select></div>
<div class="matrix"><table id="catalog"><thead><tr><th>分组</th><th>Shortcut</th><th>实际业务断言</th><th>状态</th></tr></thead><tbody>
<tr><td>空间</td><td><code>+space-list</code></td><td>真实 count、hasMore、nextCursor;自动翻页返回两页结果。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>空间</td><td><code>+space-search</code></td><td>等待搜索索引后命中一次性 workspaceId。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>空间</td><td><code>+space-get</code></td><td>读回 workspaceId 与创建结果一致。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>空间</td><td><code>+resolve-space</code></td><td>唯一名称解析为同一 workspaceId。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>空间</td><td><code>+space-create</code></td><td>success=true、workspaceId 非空、详情读回一致。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>空间</td><td><code>+delete-space</code></td><td>目标预读、确认、success=true;兼容别名执行 finally 清理。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>成员</td><td><code>+member-list</code></td><td>真实 owner 条目与显式 members 数组,limit=50。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>成员</td><td><code>+member-add</code></td><td>命令只报告写终态;一次性小规模空间另行确认名单完整且角色为 READER。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>成员</td><td><code>+member-update</code></td><td>命令只报告写终态;一次性小规模空间另行确认角色变为 EDITOR。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>成员</td><td><code>+member-remove</code></td><td>命令只报告写终态;一次性小规模空间另行确认完整名单中不存在该 userId。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>节点</td><td><code>+node-list</code></td><td>空库返回真实 nodes:[];有数据时验证游标与自动翻页。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>节点</td><td><code>+node-get</code></td><td>读回 nodeId 与请求一致。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>节点</td><td><code>+node-search</code></td><td>等待索引后按标题命中真实 nodeId。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>节点</td><td><code>+node-create</code></td><td>分别创建 folder/adoc,均取得 nodeId 和元数据读回。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>节点</td><td><code>+node-copy</code></td><td>取得不同的新 nodeId,副本元数据可读。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>节点</td><td><code>+move</code></td><td>读回 workspaceId 与 folderId 均等于目标;兼容 +node-move。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>节点</td><td><code>+move-to-drive</code></td><td>移动后读回 workspace 发生变化,再通过 +move 移回。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>节点</td><td><code>+node-delete</code></td><td>目标预读与 workspace 核对后收到 success=true。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>节点</td><td><code>+wiki-new-doc</code></td><td>按唯一空间名创建,nodeId 与文档详情读回一致。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>动态</td><td><code>+feed-list</code></td><td>创建/移动操作后返回真实 feeds 数组,缺字段不会被接受。</td><td><span class="tag full">PASS</span></td></tr>
</tbody></table></div>
<p class="small">可复跑入口:<code>make build</code> 后设置临时 <code>DWS_WIKI_E2E_MEMBER_ID</code>,在交互终端运行 <code>./scripts/dev/wiki-shortcut-e2e.py</code>。脚本只输出能力标签,不输出业务对象;受保护操作及最终清理均由命令逐项获取终端确认,非交互环境会在创建测试数据前拒绝运行。</p>
</section>
<section class="card">
<h2>成功判定与发布门</h2>
<div class="two"><div><h3>运行时证据层</h3><ol><li>传输/MCP 调用成功。</li><li>响应契约存在且类型正确。</li><li>写操作必须有 <code>success=true</code>;创建类操作还必须有业务 ID。</li><li>后端具备精确查询时必须读回;不具备时明确发布不可读回,而非从截断集合推断。</li><li>集合只有显式数组才允许为空。</li></ol></div><div><h3>交付门</h3><ol><li>20/20 语义目录与注册面精确覆盖。</li><li>Contract、Safety、Result、统一输出完整。</li><li>生成漂移、Schema、确认真值、全量 Go 测试。</li><li>独立真实数据 E2E 与 finally 清理。</li><li>diff PII/密钥/本地绝对路径扫描。</li></ol></div></div>
</section>
<p class="footer">DWS Wiki Shortcut business review · sanitized engineering artifact</p>
</main>
<script>
const q=document.querySelector('#q'),g=document.querySelector('#g'),rows=[...document.querySelectorAll('#catalog tbody tr')];
function filter(){const text=q.value.trim().toLowerCase(),group=g.value;rows.forEach(r=>{const okText=!text||r.textContent.toLowerCase().includes(text),okGroup=!group||r.children[0].textContent===group;r.style.display=okText&&okGroup?'':'none'})}q.addEventListener('input',filter);g.addEventListener('change',filter);
</script></body></html>
+6 -2
View File
@@ -65,12 +65,16 @@ func TestCrossPlatformCoverageTokenManagerCachesUntilMarkerRevisionChanges(t *te
token := "token-a"
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) {
calls.Add(1)
return &authpkg.TokenData{AccessToken: token, ExpiresAt: time.Now().Add(time.Hour)}, nil
return &authpkg.TokenData{
AccessToken: token,
ExpiresAt: time.Now().Add(time.Hour),
LoginRegion: string(authpkg.LoginRegionInternational),
}, nil
})
manager := NewTokenManager()
first, err := manager.Get(context.Background(), configDir, "")
if err != nil || first.AccessToken != "token-a" {
if err != nil || first.AccessToken != "token-a" || first.LoginRegion != authpkg.LoginRegionInternational || !first.LoginRegionKnown {
t.Fatalf("first token = %#v, %v", first, err)
}
second, err := manager.Get(context.Background(), configDir, "")
+10 -6
View File
@@ -41,9 +41,11 @@ type accessTokenSnapshotGetter interface {
// AccessTokenSnapshot is the minimal bearer view needed by the process cache.
// Refresh-token material never leaves the auth package.
type AccessTokenSnapshot struct {
AccessToken string
ExpiresAt time.Time
Source string
AccessToken string
ExpiresAt time.Time
Source string
LoginRegion authpkg.LoginRegion
LoginRegionKnown bool
}
type tokenManagerKey struct {
@@ -223,9 +225,11 @@ func resolveAccessTokenSnapshotFromDir(ctx context.Context, configDir, profile s
data, err := snapshotProvider.GetTokenSnapshot(ctx)
if err == nil && data != nil && strings.TrimSpace(data.AccessToken) != "" {
return AccessTokenSnapshot{
AccessToken: strings.TrimSpace(data.AccessToken),
ExpiresAt: data.ExpiresAt,
Source: "oauth",
AccessToken: strings.TrimSpace(data.AccessToken),
ExpiresAt: data.ExpiresAt,
Source: "oauth",
LoginRegion: authpkg.LoginRegion(strings.TrimSpace(data.LoginRegion)),
LoginRegionKnown: true,
}, nil
}
if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) {
+2 -2
View File
@@ -712,9 +712,9 @@ func TestCrossPlatformCoverageAgentMetadataMCPAndPluginScoping(t *testing.T) {
// 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) {
testseam.Swap(t, &runnerResolveAuthSnapshot, func(*runtimeRunner, context.Context) (AccessTokenSnapshot, error) {
resolveCalled = true
return "", nil
return AccessTokenSnapshot{}, nil
})
callsBefore := len(captured)
unauthenticated := &runtimeRunner{
+241 -1
View File
@@ -20,6 +20,8 @@ import (
"encoding/json"
"fmt"
"io"
"net"
"net/url"
"os"
"path/filepath"
"runtime"
@@ -48,8 +50,24 @@ type authLoginConfig struct {
TargetCorpID string
HistoryProfileSelector string
HistoryProfileSelectorExplicit bool
International bool
PreURL string
MCPURL string
}
type authLoginEndpointOverrides struct {
LoginURL string
MCPURL string
}
type authLoginMCPPersistence uint8
const (
authLoginMCPUseDefault authLoginMCPPersistence = iota
authLoginMCPUseManagedRegion
authLoginMCPUseExplicitOverride
)
type authLoginGuideAction string
const (
@@ -103,12 +121,17 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
支持的登录方式:
- OAuth Loopback 流 (默认): 本机自动起 127.0.0.1 监听接收回调,浏览器授权后自动完成
- OAuth 设备流 (--device): 显示 user_code + 短 URL,适合 SSH 远程 / 容器 / 无头环境
- 自有应用 OAuth (--client-id/--client-secret): 使用指定应用完成用户授权
- 直接提供 Token (--token): 跳过授权,使用已有 token
不支持的登录方式:
- 邮箱/密码登录
- 手机号/验证码登录
- 应用凭证 (AppKey/AppSecret) 直接登录
- 无用户授权的纯应用凭证 (client_credentials) 登录
区域:
- 默认使用国内钉钉 .com 登录与服务端点
- --intl(或 --international)使用国际版 .io 登录;后续业务命令按所选 profile 自动路由
注意: SSH 远程或无头环境(无本地浏览器可访问远端的 127.0.0.1)请使用 --device,
否则 OAuth 回调会跳到本机不可达的 127.0.0.1 链接,授权完成后无法回写 token。
@@ -116,6 +139,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
示例:
dws auth login # 本机登录并新增/刷新一个组织 profile
dws auth login --profile <corpId> # 指定本次授权目标组织,不持久切换当前组织
dws auth login --intl # 使用钉钉国际版 .io 登录入口
dws auth login --intl --pre-url https://pre-login.dingtalk.io
dws auth login --intl --pre-url https://pre-mcp.dingtalk.io
dws auth login --recommend # 无交互批量授权服务端推荐权限
dws auth login --device # SSH 远程 / 无头环境登录 (设备流)
dws auth login --force # 兼容保留;login 默认已忽略缓存并进入授权流程
@@ -126,6 +152,22 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
if err != nil {
return err
}
var preOverrides authLoginEndpointOverrides
if cfg.PreURL != "" {
var err error
preOverrides, err = authLoginEndpointOverridesForPreURL(cfg.PreURL)
if err != nil {
return err
}
restoreLoginBaseURL := authpkg.PushLoginBaseURLOverride(preOverrides.LoginURL)
defer restoreLoginBaseURL()
}
mcpBaseURL, mcpPersistence, err := authLoginMCPBaseURLForConfig(cfg, preOverrides)
if err != nil {
return err
}
restoreMCPBaseURL := authpkg.PushMCPBaseURLOverride(mcpBaseURL)
defer restoreMCPBaseURL()
configDir := defaultConfigDir()
var tokenData *authpkg.TokenData
format, _ := cmd.Root().PersistentFlags().GetString("format")
@@ -139,6 +181,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
AccessToken: cfg.Token,
ExpiresAt: time.Now().Add(config.ManualTokenExpiry),
}
if cfg.International {
tokenData.LoginRegion = string(authpkg.LoginRegionInternational)
}
if err := authSaveTokenData(configDir, tokenData); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to persist auth token: %v", err))
}
@@ -149,6 +194,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
provider := authpkg.NewDeviceFlowProvider(configDir, nil)
provider.Output = cmd.ErrOrStderr()
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
if cfg.International {
provider.SetLoginRegion(authpkg.LoginRegionInternational)
}
provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error {
return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data, authLoginHistoryHint{
Selector: cfg.HistoryProfileSelector,
@@ -167,6 +215,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
provider.Output = cmd.ErrOrStderr()
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
provider.TargetCorpID = cfg.TargetCorpID
if cfg.International {
provider.LoginRegion = authpkg.LoginRegionInternational
}
provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error {
return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data, authLoginHistoryHint{
Selector: cfg.HistoryProfileSelector,
@@ -180,6 +231,11 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
}
}
if tokenData != nil {
if err := persistAuthLoginMCPBaseURL(configDir, mcpBaseURL, mcpPersistence); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to persist MCP URL: %v", err))
}
}
ResetRuntimeTokenCache()
clearCompatCache()
w := cmd.OutOrStdout()
@@ -278,6 +334,10 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
}
cmd.Flags().String("token", "", "Access token")
cmd.Flags().Bool("device", false, "Use device authorization flow")
cmd.Flags().Bool("intl", false, "Use DingTalk international (.io) login and service endpoints")
cmd.Flags().Bool("international", false, "Use DingTalk international (.io) login and service endpoints")
cmd.Flags().String("pre-url", "", "Override pre-release login/MCP base URL for this login")
cmd.Flags().String("mcp-url", "", "Override MCP base URL for this login")
cmd.Flags().Bool("force", false, "兼容保留;login 默认已忽略缓存并进入授权流程")
cmd.Flags().Bool("recommend", false, "登录成功后无交互批量授权服务端推荐权限")
// Hidden compatibility flags
@@ -967,6 +1027,7 @@ func newAuthResetCommand() *cobra.Command {
return apperrors.NewInternal(fmt.Sprintf("failed to reset token data: %v", err))
}
_ = authRemove(filepath.Join(configDir, "mcp_url"))
_ = authRemove(filepath.Join(configDir, config.ManagedMCPURLRegionFileName))
_ = authRemove(filepath.Join(configDir, "token"))
_ = authDeleteAppConfig(configDir)
ResetRuntimeTokenCache()
@@ -1225,6 +1286,14 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
if err != nil {
return authLoginConfig{}, apperrors.NewInternal("failed to read --device")
}
intl, err := cmd.Flags().GetBool("intl")
if err != nil {
return authLoginConfig{}, apperrors.NewInternal("failed to read --intl")
}
international, err := cmd.Flags().GetBool("international")
if err != nil {
return authLoginConfig{}, apperrors.NewInternal("failed to read --international")
}
force, err := cmd.Flags().GetBool("force")
if err != nil {
return authLoginConfig{}, apperrors.NewInternal("failed to read --force")
@@ -1233,6 +1302,14 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
if err != nil {
return authLoginConfig{}, apperrors.NewInternal("failed to read --recommend")
}
preURL, err := cmd.Flags().GetString("pre-url")
if err != nil {
return authLoginConfig{}, apperrors.NewInternal("failed to read --pre-url")
}
mcpURL, err := cmd.Flags().GetString("mcp-url")
if err != nil {
return authLoginConfig{}, apperrors.NewInternal("failed to read --mcp-url")
}
yes := false
profileSelector := ""
if cmd.Root() != nil {
@@ -1266,9 +1343,172 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
TargetCorpID: targetCorpID,
HistoryProfileSelector: historyProfileSelector,
HistoryProfileSelectorExplicit: historyProfileSelectorExplicit,
International: intl || international,
PreURL: strings.TrimSpace(preURL),
MCPURL: strings.TrimSpace(mcpURL),
}, nil
}
func authLoginEndpointOverridesForPreURL(raw string) (authLoginEndpointOverrides, error) {
parsed, normalized, err := normalizeAuthLoginBaseURL(raw, "--pre-url")
if err != nil {
return authLoginEndpointOverrides{}, err
}
host := strings.ToLower(parsed.Hostname())
switch {
case strings.HasPrefix(host, "pre-login."):
return authLoginEndpointOverrides{
LoginURL: normalized,
MCPURL: authLoginURLWithHost(parsed, "pre-mcp."+strings.TrimPrefix(host, "pre-login.")),
}, nil
case strings.HasPrefix(host, "pre-mcp."):
return authLoginEndpointOverrides{
LoginURL: authLoginURLWithHost(parsed, "pre-login."+strings.TrimPrefix(host, "pre-mcp.")),
MCPURL: normalized,
}, nil
default:
return authLoginEndpointOverrides{}, apperrors.NewValidation("--pre-url must be a pre-login.* or pre-mcp.* URL")
}
}
func authLoginMCPBaseURLForConfig(cfg authLoginConfig, preOverrides authLoginEndpointOverrides) (string, authLoginMCPPersistence, error) {
if cfg.MCPURL != "" {
_, normalized, err := normalizeAuthLoginBaseURL(cfg.MCPURL, "--mcp-url")
if err != nil {
return "", authLoginMCPUseDefault, err
}
return normalized, authLoginMCPUseExplicitOverride, nil
}
if cfg.PreURL != "" {
if preOverrides.MCPURL == "" {
var err error
preOverrides, err = authLoginEndpointOverridesForPreURL(cfg.PreURL)
if err != nil {
return "", authLoginMCPUseDefault, err
}
}
return preOverrides.MCPURL, authLoginMCPUseExplicitOverride, nil
}
if cfg.International {
return authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion, nil
}
return authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault, nil
}
func persistAuthLoginMCPBaseURL(configDir, mcpBaseURL string, persistence authLoginMCPPersistence) error {
mcpURLPath := filepath.Join(configDir, "mcp_url")
managedRegionPath := filepath.Join(configDir, config.ManagedMCPURLRegionFileName)
switch persistence {
case authLoginMCPUseExplicitOverride:
if err := removeAuthLoginManagedMCPRegion(managedRegionPath); err != nil {
return fmt.Errorf("clear managed MCP region: %w", err)
}
if err := authAtomicWrite(mcpURLPath, []byte(mcpBaseURL), config.FilePerm); err != nil {
return fmt.Errorf("save explicit MCP URL: %w", err)
}
return nil
case authLoginMCPUseManagedRegion:
managedURL, managedErr := authReadFile(managedRegionPath)
if managedErr != nil && !os.IsNotExist(managedErr) {
return fmt.Errorf("read managed MCP region: %w", managedErr)
}
currentURL, currentErr := authReadFile(mcpURLPath)
switch {
case currentErr == nil && os.IsNotExist(managedErr):
return nil
case currentErr == nil && strings.TrimSpace(string(currentURL)) != strings.TrimSpace(string(managedURL)):
return removeAuthLoginManagedMCPRegion(managedRegionPath)
case currentErr != nil && !os.IsNotExist(currentErr):
return fmt.Errorf("read MCP URL: %w", currentErr)
}
if err := authAtomicWrite(managedRegionPath, []byte(mcpBaseURL), config.FilePerm); err != nil {
return fmt.Errorf("save managed MCP region: %w", err)
}
if err := authAtomicWrite(mcpURLPath, []byte(mcpBaseURL), config.FilePerm); err != nil {
_ = authRemove(managedRegionPath)
return fmt.Errorf("save managed MCP URL: %w", err)
}
return nil
case authLoginMCPUseDefault:
managedURL, err := authReadFile(managedRegionPath)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("read managed MCP region: %w", err)
}
currentURL, err := authReadFile(mcpURLPath)
if os.IsNotExist(err) {
return removeAuthLoginManagedMCPRegion(managedRegionPath)
}
if err != nil {
return fmt.Errorf("read MCP URL: %w", err)
}
if strings.TrimSpace(string(currentURL)) != strings.TrimSpace(string(managedURL)) {
return removeAuthLoginManagedMCPRegion(managedRegionPath)
}
if err := authAtomicWrite(mcpURLPath, []byte(mcpBaseURL), config.FilePerm); err != nil {
return fmt.Errorf("restore default MCP URL: %w", err)
}
return removeAuthLoginManagedMCPRegion(managedRegionPath)
default:
return fmt.Errorf("unsupported MCP persistence mode %d", persistence)
}
}
func removeAuthLoginManagedMCPRegion(path string) error {
if err := authRemove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func normalizeAuthLoginBaseURL(raw, flagName string) (*url.URL, string, error) {
value := strings.TrimSpace(raw)
if value == "" {
return nil, "", apperrors.NewValidation(flagName + " cannot be empty")
}
if !strings.Contains(value, "://") {
value = "https://" + value
}
parsed, err := url.Parse(value)
if err != nil {
return nil, "", apperrors.NewValidation(fmt.Sprintf("invalid %s: %v", flagName, err))
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return nil, "", apperrors.NewValidation(flagName + " must use http or https")
}
if parsed.Hostname() == "" {
return nil, "", apperrors.NewValidation(flagName + " must include a host")
}
if parsed.Scheme == "http" && !isAuthLoginLoopbackHost(parsed.Hostname()) {
return nil, "", apperrors.NewValidation(flagName + " must use HTTPS, except for a loopback HTTP test endpoint")
}
parsed.RawQuery = ""
parsed.Fragment = ""
parsed.Path = strings.TrimRight(parsed.Path, "/")
return parsed, strings.TrimRight(parsed.String(), "/"), nil
}
func isAuthLoginLoopbackHost(host string) bool {
if strings.EqualFold(strings.TrimSpace(host), "localhost") {
return true
}
ip := net.ParseIP(strings.TrimSpace(host))
return ip != nil && ip.IsLoopback()
}
func authLoginURLWithHost(parsed *url.URL, host string) string {
copyURL := *parsed
if port := parsed.Port(); port != "" {
copyURL.Host = net.JoinHostPort(host, port)
} else {
copyURL.Host = host
}
return strings.TrimRight(copyURL.String(), "/")
}
func authLoginForcesAuthorization(_ authLoginConfig) bool {
return true
}
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -214,7 +215,8 @@ func TestCrossPlatformCoverageAuthCoverageFormsParentAndTargets(t *testing.T) {
}
func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
oldSave := authSaveTokenData
oldDevice := authDeviceLogin
oldOAuth := authOAuthLogin
@@ -255,6 +257,15 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
if out, _, err := authCoverageRunLogin(t, nil, "json", true, map[string]string{"token": "token"}); err != nil || !strings.Contains(out, `"token_valid": true`) {
t.Fatalf("json token login = %q, %v", out, err)
}
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "pre-url": "https://example.com"}); err == nil {
t.Fatal("invalid pre-release host should fail")
}
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "mcp-url": "http://remote.example.com"}); err == nil {
t.Fatal("remote plaintext MCP URL should fail")
}
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "pre-url": "https://pre-login.dingtalk.io"}); err != nil {
t.Fatalf("pre-release token login = %v", err)
}
authDeviceLogin = func(*authpkg.DeviceFlowProvider, context.Context) (*authpkg.TokenData, error) {
return nil, errors.New("device")
@@ -271,6 +282,15 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true", "no-browser": "true"}); err != nil {
t.Fatal(err)
}
authDeviceLogin = func(provider *authpkg.DeviceFlowProvider, _ context.Context) (*authpkg.TokenData, error) {
if provider.LoginRegion != authpkg.LoginRegionInternational {
t.Errorf("device login region = %q, want international", provider.LoginRegion)
}
return &authpkg.TokenData{AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour)}, nil
}
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true", "intl": "true"}); err != nil {
t.Fatalf("international device login = %v", err)
}
authOAuthLogin = func(*authpkg.OAuthProvider, context.Context, bool) (*authpkg.TokenData, error) {
return nil, errors.New("oauth")
@@ -291,6 +311,25 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
if out, _, err := authCoverageRunLogin(t, caller, "table", true, map[string]string{"no-browser": "true"}); err != nil || !strings.Contains(out, "Corp") {
t.Fatalf("oauth success = %q, %v", out, err)
}
authOAuthLogin = func(provider *authpkg.OAuthProvider, _ context.Context, _ bool) (*authpkg.TokenData, error) {
if provider.LoginRegion != authpkg.LoginRegionInternational {
t.Errorf("OAuth login region = %q, want international", provider.LoginRegion)
}
return &authpkg.TokenData{AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour)}, nil
}
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"intl": "true"}); err != nil {
t.Fatalf("international OAuth login = %v", err)
}
blockedConfigDir := t.TempDir()
if err := os.Mkdir(filepath.Join(blockedConfigDir, "mcp_url"), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("DWS_CONFIG_DIR", blockedConfigDir)
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "intl": "true"}); err == nil || !strings.Contains(err.Error(), "failed to persist MCP URL") {
t.Fatalf("MCP URL persist failure = %v", err)
}
t.Setenv("DWS_CONFIG_DIR", configDir)
authRunLoginRecommend = func(context.Context, edition.ToolCaller, io.Writer, pat.LoginRecommendOptions) error {
return errors.New("recommend")
@@ -1418,7 +1457,7 @@ func TestCrossPlatformCoverageAuthCoveragePortableExchangeAndReset(t *testing.T)
authRemove = func(string) error { removed++; return errors.New("ignored") }
authDeleteAppConfig = func(string) error { removed++; return errors.New("ignored") }
edition.Override(&edition.Hooks{})
if err := reset.RunE(reset, nil); err != nil || removed != 3 || !strings.Contains(out.String(), "重新登录") {
if err := reset.RunE(reset, nil); err != nil || removed != 4 || !strings.Contains(out.String(), "重新登录") {
t.Fatalf("reset = %q, %v, removed=%d", out.String(), err, removed)
}
edition.Override(&edition.Hooks{IsEmbedded: true})
+560
View File
@@ -33,6 +33,8 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
"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/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -1033,8 +1035,12 @@ func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) {
login := &cobra.Command{Use: "login"}
login.Flags().String("token", "", "")
login.Flags().Bool("device", false, "")
login.Flags().Bool("intl", false, "")
login.Flags().Bool("international", false, "")
login.Flags().Bool("force", false, "")
login.Flags().Bool("recommend", false, "")
login.Flags().String("pre-url", "", "")
login.Flags().String("mcp-url", "", "")
root.AddCommand(login)
if err := root.PersistentFlags().Set("yes", "true"); err != nil {
@@ -1061,6 +1067,560 @@ func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) {
}
}
func TestCrossPlatformCoverageResolveAuthLoginConfigReadsInternationalAliases(t *testing.T) {
for _, flag := range []string{"intl", "international"} {
t.Run(flag, func(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().Bool("yes", false, "")
login := &cobra.Command{Use: "login"}
login.Flags().String("token", "", "")
login.Flags().Bool("device", false, "")
login.Flags().Bool("intl", false, "")
login.Flags().Bool("international", false, "")
login.Flags().Bool("force", false, "")
login.Flags().Bool("recommend", false, "")
login.Flags().String("pre-url", "", "")
login.Flags().String("mcp-url", "", "")
root.AddCommand(login)
if err := login.Flags().Set(flag, "true"); err != nil {
t.Fatalf("set %s: %v", flag, err)
}
cfg, err := resolveAuthLoginConfig(login)
if err != nil {
t.Fatalf("resolveAuthLoginConfig error = %v", err)
}
if !cfg.International {
t.Fatalf("International = false for --%s, want true", flag)
}
})
}
for _, tc := range []struct {
name string
setup func(*cobra.Command)
}{
{
name: "missing intl",
setup: func(cmd *cobra.Command) {
cmd.Flags().String("token", "", "")
cmd.Flags().Bool("device", false, "")
},
},
{
name: "missing international",
setup: func(cmd *cobra.Command) {
cmd.Flags().String("token", "", "")
cmd.Flags().Bool("device", false, "")
cmd.Flags().Bool("intl", false, "")
},
},
{
name: "missing pre url",
setup: func(cmd *cobra.Command) {
cmd.Flags().String("token", "", "")
cmd.Flags().Bool("device", false, "")
cmd.Flags().Bool("intl", false, "")
cmd.Flags().Bool("international", false, "")
cmd.Flags().Bool("force", false, "")
cmd.Flags().Bool("recommend", false, "")
},
},
{
name: "missing mcp url",
setup: func(cmd *cobra.Command) {
cmd.Flags().String("token", "", "")
cmd.Flags().Bool("device", false, "")
cmd.Flags().Bool("intl", false, "")
cmd.Flags().Bool("international", false, "")
cmd.Flags().Bool("force", false, "")
cmd.Flags().Bool("recommend", false, "")
cmd.Flags().String("pre-url", "", "")
},
},
} {
t.Run(tc.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "login"}
tc.setup(cmd)
if _, err := resolveAuthLoginConfig(cmd); err == nil {
t.Fatal("resolveAuthLoginConfig succeeded with an incomplete flag set")
}
})
}
}
func TestCrossPlatformCoverageResolveAuthLoginConfigReadsMCPURL(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().Bool("yes", false, "")
login := &cobra.Command{Use: "login"}
login.Flags().String("token", "", "")
login.Flags().Bool("device", false, "")
login.Flags().Bool("intl", false, "")
login.Flags().Bool("international", false, "")
login.Flags().Bool("force", false, "")
login.Flags().Bool("recommend", false, "")
login.Flags().String("pre-url", "", "")
login.Flags().String("mcp-url", "", "")
root.AddCommand(login)
if err := login.Flags().Set("mcp-url", " https://pre-mcp.dingtalk.io/ "); err != nil {
t.Fatalf("set mcp-url: %v", err)
}
cfg, err := resolveAuthLoginConfig(login)
if err != nil {
t.Fatalf("resolveAuthLoginConfig error = %v", err)
}
if cfg.MCPURL != "https://pre-mcp.dingtalk.io/" {
t.Fatalf("MCPURL = %q, want trimmed flag value", cfg.MCPURL)
}
}
func TestCrossPlatformCoverageResolveAuthLoginConfigReadsPreURL(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().Bool("yes", false, "")
login := &cobra.Command{Use: "login"}
login.Flags().String("token", "", "")
login.Flags().Bool("device", false, "")
login.Flags().Bool("intl", false, "")
login.Flags().Bool("international", false, "")
login.Flags().Bool("force", false, "")
login.Flags().Bool("recommend", false, "")
login.Flags().String("pre-url", "", "")
login.Flags().String("mcp-url", "", "")
root.AddCommand(login)
if err := login.Flags().Set("pre-url", " pre-login.dingtalk.io "); err != nil {
t.Fatalf("set pre-url: %v", err)
}
cfg, err := resolveAuthLoginConfig(login)
if err != nil {
t.Fatalf("resolveAuthLoginConfig error = %v", err)
}
if cfg.PreURL != "pre-login.dingtalk.io" {
t.Fatalf("PreURL = %q, want trimmed flag value", cfg.PreURL)
}
}
func TestCrossPlatformCoverageAuthLoginEndpointOverridesForPreURL(t *testing.T) {
tests := []struct {
name string
raw string
wantLogin string
wantMCP string
}{
{
name: "pre login",
raw: "https://pre-login.dingtalk.io/",
wantLogin: "https://pre-login.dingtalk.io",
wantMCP: "https://pre-mcp.dingtalk.io",
},
{
name: "pre mcp",
raw: "pre-mcp.dingtalk.io",
wantLogin: "https://pre-login.dingtalk.io",
wantMCP: "https://pre-mcp.dingtalk.io",
},
{
name: "pre login with port",
raw: "https://pre-login.dingtalk.io:8443/path/",
wantLogin: "https://pre-login.dingtalk.io:8443/path",
wantMCP: "https://pre-mcp.dingtalk.io:8443/path",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := authLoginEndpointOverridesForPreURL(tc.raw)
if err != nil {
t.Fatalf("authLoginEndpointOverridesForPreURL error = %v", err)
}
if got.LoginURL != tc.wantLogin || got.MCPURL != tc.wantMCP {
t.Fatalf("overrides = %#v, want login %q mcp %q", got, tc.wantLogin, tc.wantMCP)
}
})
}
for _, raw := range []string{"https://example.com", "http://pre-login.example.com"} {
if _, err := authLoginEndpointOverridesForPreURL(raw); err == nil {
t.Fatalf("authLoginEndpointOverridesForPreURL(%q) succeeded", raw)
}
}
}
func TestCrossPlatformCoverageAuthLoginMCPBaseURLForConfig(t *testing.T) {
tests := []struct {
name string
cfg authLoginConfig
preOverride authLoginEndpointOverrides
wantURL string
wantPersistence authLoginMCPPersistence
}{
{
name: "default center login uses com and resets only managed region",
cfg: authLoginConfig{},
wantURL: authpkg.DefaultMCPBaseURL,
wantPersistence: authLoginMCPUseDefault,
},
{
name: "international login persists managed io",
cfg: authLoginConfig{International: true},
wantURL: authpkg.InternationalMCPBaseURL,
wantPersistence: authLoginMCPUseManagedRegion,
},
{
name: "pre login persists mapped pre mcp",
cfg: authLoginConfig{PreURL: "pre-login.dingtalk.io"},
preOverride: authLoginEndpointOverrides{
LoginURL: "https://pre-login.dingtalk.io",
MCPURL: "https://pre-mcp.dingtalk.io",
},
wantURL: "https://pre-mcp.dingtalk.io",
wantPersistence: authLoginMCPUseExplicitOverride,
},
{
name: "explicit mcp url wins over pre url",
cfg: authLoginConfig{
PreURL: "pre-login.dingtalk.io",
MCPURL: " https://custom-mcp.example.com/ ",
},
preOverride: authLoginEndpointOverrides{
LoginURL: "https://pre-login.dingtalk.io",
MCPURL: "https://pre-mcp.dingtalk.io",
},
wantURL: "https://custom-mcp.example.com",
wantPersistence: authLoginMCPUseExplicitOverride,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
gotURL, gotPersistence, err := authLoginMCPBaseURLForConfig(tc.cfg, tc.preOverride)
if err != nil {
t.Fatalf("authLoginMCPBaseURLForConfig error = %v", err)
}
if gotURL != tc.wantURL || gotPersistence != tc.wantPersistence {
t.Fatalf("got url=%q persistence=%v, want url=%q persistence=%v", gotURL, gotPersistence, tc.wantURL, tc.wantPersistence)
}
})
}
for _, cfg := range []authLoginConfig{
{MCPURL: "http://remote.example.com"},
{PreURL: "https://example.com"},
} {
if _, _, err := authLoginMCPBaseURLForConfig(cfg, authLoginEndpointOverrides{}); err == nil {
t.Fatalf("authLoginMCPBaseURLForConfig(%#v) succeeded", cfg)
}
}
}
func TestCrossPlatformCoveragePersistAuthLoginMCPBaseURL(t *testing.T) {
t.Run("persists selected io mcp url", func(t *testing.T) {
configDir := t.TempDir()
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil {
t.Fatalf("persistAuthLoginMCPBaseURL error = %v", err)
}
data, err := os.ReadFile(filepath.Join(configDir, "mcp_url"))
if err != nil {
t.Fatalf("ReadFile(mcp_url) error = %v", err)
}
if string(data) != authpkg.InternationalMCPBaseURL {
t.Fatalf("mcp_url = %q, want %q", string(data), authpkg.InternationalMCPBaseURL)
}
managed, err := os.ReadFile(filepath.Join(configDir, config.ManagedMCPURLRegionFileName))
if err != nil || string(managed) != authpkg.InternationalMCPBaseURL {
t.Fatalf("managed MCP region = %q, %v", string(managed), err)
}
})
t.Run("center login preserves previous persisted override", func(t *testing.T) {
configDir := t.TempDir()
mcpURLPath := filepath.Join(configDir, "mcp_url")
const customURL = "https://custom-mcp.example.com"
if err := os.WriteFile(mcpURLPath, []byte(customURL), 0o600); err != nil {
t.Fatalf("WriteFile(mcp_url) error = %v", err)
}
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil {
t.Fatalf("persistAuthLoginMCPBaseURL error = %v", err)
}
data, err := os.ReadFile(mcpURLPath)
if err != nil {
t.Fatalf("ReadFile(mcp_url) error = %v", err)
}
if string(data) != customURL {
t.Fatalf("mcp_url = %q, want preserved override %q", string(data), customURL)
}
})
t.Run("international login preserves an unmanaged explicit override", func(t *testing.T) {
configDir := t.TempDir()
mcpURLPath := filepath.Join(configDir, "mcp_url")
const customURL = "https://private-mcp.example.com"
if err := os.WriteFile(mcpURLPath, []byte(customURL), config.FilePerm); err != nil {
t.Fatal(err)
}
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(mcpURLPath)
if err != nil || string(data) != customURL {
t.Fatalf("explicit mcp_url = %q, %v; want preserved custom URL", string(data), err)
}
if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) {
t.Fatalf("unmanaged override acquired a managed marker: %v", err)
}
})
t.Run("center login resets a managed international URL", func(t *testing.T) {
configDir := t.TempDir()
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil {
t.Fatal(err)
}
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(configDir, "mcp_url"))
if err != nil || string(data) != authpkg.DefaultMCPBaseURL {
t.Fatalf("mcp_url = %q, %v; want domestic default", string(data), err)
}
if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) {
t.Fatalf("managed region marker remains after domestic login: %v", err)
}
})
t.Run("explicit override clears region ownership", func(t *testing.T) {
configDir := t.TempDir()
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil {
t.Fatal(err)
}
const customURL = "https://custom-mcp.example.com"
if err := persistAuthLoginMCPBaseURL(configDir, customURL, authLoginMCPUseExplicitOverride); err != nil {
t.Fatal(err)
}
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(configDir, "mcp_url"))
if err != nil || string(data) != customURL {
t.Fatalf("explicit mcp_url = %q, %v; want preserved custom URL", string(data), err)
}
})
t.Run("stale marker never deletes a different custom URL", func(t *testing.T) {
configDir := t.TempDir()
if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte("https://custom.example.com"), config.FilePerm); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(configDir, config.ManagedMCPURLRegionFileName), []byte(authpkg.InternationalMCPBaseURL), config.FilePerm); err != nil {
t.Fatal(err)
}
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(configDir, "mcp_url"))
if err != nil || string(data) != "https://custom.example.com" {
t.Fatalf("custom mcp_url = %q, %v", string(data), err)
}
})
t.Run("international login clears a stale marker without changing a custom URL", func(t *testing.T) {
configDir := t.TempDir()
mcpURLPath := filepath.Join(configDir, "mcp_url")
managedRegionPath := filepath.Join(configDir, config.ManagedMCPURLRegionFileName)
const customURL = "https://private-mcp.example.com"
if err := os.WriteFile(mcpURLPath, []byte(customURL), config.FilePerm); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(managedRegionPath, []byte(authpkg.DefaultMCPBaseURL), config.FilePerm); err != nil {
t.Fatal(err)
}
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(mcpURLPath)
if err != nil || string(data) != customURL {
t.Fatalf("custom mcp_url = %q, %v", string(data), err)
}
if _, err := os.Stat(managedRegionPath); !os.IsNotExist(err) {
t.Fatalf("stale managed marker remains: %v", err)
}
})
}
func TestCrossPlatformCoveragePersistAuthLoginMCPBaseURLErrors(t *testing.T) {
fail := errors.New("persist failure")
t.Run("explicit marker cleanup", func(t *testing.T) {
testseam.Swap(t, &authRemove, func(string) error { return fail })
if err := persistAuthLoginMCPBaseURL(t.TempDir(), "https://custom.example.com", authLoginMCPUseExplicitOverride); !errors.Is(err, fail) {
t.Fatalf("explicit marker cleanup error = %v", err)
}
})
t.Run("explicit URL write", func(t *testing.T) {
testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return fail })
if err := persistAuthLoginMCPBaseURL(t.TempDir(), "https://custom.example.com", authLoginMCPUseExplicitOverride); !errors.Is(err, fail) {
t.Fatalf("explicit URL write error = %v", err)
}
})
t.Run("managed marker write", func(t *testing.T) {
testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return fail })
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) {
t.Fatalf("managed marker write error = %v", err)
}
})
t.Run("managed marker read", func(t *testing.T) {
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return nil, fail })
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) {
t.Fatalf("managed marker read error = %v", err)
}
})
t.Run("managed MCP URL read", func(t *testing.T) {
reads := 0
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) {
reads++
if reads == 1 {
return nil, os.ErrNotExist
}
return nil, fail
})
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) {
t.Fatalf("managed MCP URL read error = %v", err)
}
})
t.Run("managed stale marker cleanup", func(t *testing.T) {
reads := 0
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) {
reads++
if reads == 1 {
return []byte(authpkg.DefaultMCPBaseURL), nil
}
return []byte("https://private-mcp.example.com"), nil
})
testseam.Swap(t, &authRemove, func(string) error { return fail })
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) {
t.Fatalf("managed stale marker cleanup error = %v", err)
}
})
t.Run("managed URL write cleans marker", func(t *testing.T) {
writes := 0
removed := false
testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error {
writes++
if writes == 2 {
return fail
}
return nil
})
testseam.Swap(t, &authRemove, func(string) error { removed = true; return nil })
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) || !removed {
t.Fatalf("managed URL write error = %v, marker removed=%v", err, removed)
}
})
t.Run("default managed marker read", func(t *testing.T) {
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return nil, fail })
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) {
t.Fatalf("managed marker read error = %v", err)
}
})
t.Run("missing MCP URL cleans marker", func(t *testing.T) {
reads := 0
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) {
reads++
if reads == 1 {
return []byte(authpkg.InternationalMCPBaseURL), nil
}
return nil, os.ErrNotExist
})
testseam.Swap(t, &authRemove, func(string) error { return nil })
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil {
t.Fatalf("missing MCP URL cleanup error = %v", err)
}
})
t.Run("MCP URL read", func(t *testing.T) {
reads := 0
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) {
reads++
if reads == 1 {
return []byte(authpkg.InternationalMCPBaseURL), nil
}
return nil, fail
})
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) {
t.Fatalf("MCP URL read error = %v", err)
}
})
t.Run("default URL write", func(t *testing.T) {
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return []byte(authpkg.InternationalMCPBaseURL), nil })
testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return fail })
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) {
t.Fatalf("default URL write error = %v", err)
}
})
t.Run("final marker cleanup", func(t *testing.T) {
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return []byte(authpkg.InternationalMCPBaseURL), nil })
testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return nil })
testseam.Swap(t, &authRemove, func(string) error { return fail })
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) {
t.Fatalf("final marker cleanup error = %v", err)
}
})
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPPersistence(255)); err == nil {
t.Fatal("unsupported MCP persistence mode succeeded")
}
}
func TestCrossPlatformCoverageNormalizeAuthLoginBaseURLTransportSecurity(t *testing.T) {
tests := []struct {
name string
raw string
wantURL string
wantErr string
}{
{name: "https remote", raw: "https://pre-mcp.example.com/path/?secret=drop#fragment", wantURL: "https://pre-mcp.example.com/path"},
{name: "http localhost", raw: "http://localhost:8080/", wantURL: "http://localhost:8080"},
{name: "http IPv4 loopback", raw: "http://127.0.0.1:8080", wantURL: "http://127.0.0.1:8080"},
{name: "http IPv6 loopback", raw: "http://[::1]:8080", wantURL: "http://[::1]:8080"},
{name: "http remote", raw: "http://pre-mcp.example.com", wantErr: "must use HTTPS"},
{name: "empty", raw: " ", wantErr: "cannot be empty"},
{name: "invalid URL", raw: "https://%", wantErr: "invalid --mcp-url"},
{name: "invalid scheme", raw: "ftp://pre-mcp.example.com", wantErr: "must use http or https"},
{name: "missing host", raw: "https:///path", wantErr: "must include a host"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, got, err := normalizeAuthLoginBaseURL(tc.raw, "--mcp-url")
if tc.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("normalizeAuthLoginBaseURL error = %v, want containing %q", err, tc.wantErr)
}
return
}
if err != nil {
t.Fatalf("normalizeAuthLoginBaseURL error = %v", err)
}
if got != tc.wantURL {
t.Fatalf("normalized URL = %q, want %q", got, tc.wantURL)
}
})
}
}
func TestAuthLoginForcesAuthorizationByDefault(t *testing.T) {
if !authLoginForcesAuthorization(authLoginConfig{}) {
t.Fatal("auth login should force authorization by default so each login can add an organization profile")
+111 -4
View File
@@ -36,6 +36,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
tea "github.com/charmbracelet/bubbletea"
@@ -326,6 +327,29 @@ func TestCrossPlatformCoverageSmallAppRegistryAndRootCoverage(t *testing.T) {
func TestCrossPlatformCoverageDirectRuntimeCoverage(t *testing.T) {
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition); SetDynamicServers(nil) })
for _, tc := range []struct {
raw string
region authpkg.LoginRegion
want string
}{
{raw: "%", want: "%"},
{raw: "https://dingtalk.io/path", want: "https://dingtalk.com/path"},
{raw: "https://mcp.dingtalk.com:8443/path", region: authpkg.LoginRegionInternational, want: "https://mcp.dingtalk.io:8443/path"},
} {
if got := mcpBaseURLForLoginRegion(tc.raw, tc.region); got != tc.want {
t.Fatalf("mcpBaseURLForLoginRegion(%q, %q) = %q, want %q", tc.raw, tc.region, got, tc.want)
}
}
if hasDirectRuntimeEndpointOverride("") {
t.Fatal("blank product unexpectedly has an endpoint override")
}
t.Setenv("DINGTALK_COVERAGE_PRODUCT_MCP_URL", "https://override.test")
if !hasDirectRuntimeEndpointOverride("coverage-product") {
t.Fatal("configured product endpoint override was not detected")
}
if got := activeDingTalkGatewayEndpointWithBase("https://mcp-gw.dingtalk.com/server/contact", "%"); got != "https://mcp-gw.dingtalk.com/server/contact" {
t.Fatalf("invalid gateway base rewrote endpoint to %q", got)
}
server := mcptypes.ServerDescriptor{
Endpoint: "https://one.test",
CLI: mcptypes.CLIOverlay{
@@ -913,12 +937,21 @@ func TestCrossPlatformCoverageAuthCommandPureCoverage(t *testing.T) {
}
func TestCrossPlatformCoverageAuthLoginTokenCommandCoverage(t *testing.T) {
t.Setenv(keychain.DisableKeychainEnv, "1")
t.Setenv(keychain.StorageDirEnv, t.TempDir())
oldInteractive := authLoginInteractiveTerminal
authLoginInteractiveTerminal = func() bool { return false }
t.Cleanup(func() { authLoginInteractiveTerminal = oldInteractive; authpkg.SetRuntimeProfile("") })
for _, format := range []string{"table", "json"} {
t.Run(format, func(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
format string
international bool
}{
{format: "table"},
{format: "json", international: true},
} {
t.Run(tc.format, func(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().String("format", "table", "")
root.PersistentFlags().Bool("yes", false, "")
@@ -929,16 +962,90 @@ func TestCrossPlatformCoverageAuthLoginTokenCommandCoverage(t *testing.T) {
root.SetOut(&output)
root.SetErr(io.Discard)
args := []string{"login", "--token", "manual-token", "--yes"}
if format == "json" {
if tc.international {
args = append(args, "--intl")
}
if tc.format == "json" {
args = append(args, "--format", "json")
}
root.SetArgs(args)
if err := root.Execute(); err != nil || output.Len() == 0 {
t.Fatalf("token login = %q %v", output.String(), err)
}
data, err := authpkg.LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData error = %v", err)
}
wantRegion := ""
if tc.international {
wantRegion = string(authpkg.LoginRegionInternational)
}
if data.LoginRegion != wantRegion {
t.Fatalf("LoginRegion = %q, want %q", data.LoginRegion, wantRegion)
}
if tc.international {
snapshot, err := resolveAccessTokenSnapshotFromDir(context.Background(), configDir, "")
if err != nil {
t.Fatalf("resolveAccessTokenSnapshotFromDir error = %v", err)
}
gotEndpoint := activeDingTalkGatewayEndpointForLoginRegion(
"https://mcp-gw.dingtalk.com/server/contact",
snapshot.LoginRegion,
)
if wantEndpoint := "https://mcp-gw.dingtalk.io/server/contact"; gotEndpoint != wantEndpoint {
t.Fatalf("international manual-token endpoint = %q, want %q", gotEndpoint, wantEndpoint)
}
}
})
}
t.Run("international then domestic login restores domestic MCP URL", func(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
runLogin := func(international bool) {
t.Helper()
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().String("format", "table", "")
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().String("profile", "", "")
root.AddCommand(newAuthLoginCommand(nil))
args := []string{"login", "--token", "manual-token", "--yes"}
if international {
args = append(args, "--intl")
}
root.SetArgs(args)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
if err := root.Execute(); err != nil {
t.Fatalf("international=%v login error = %v", international, err)
}
}
runLogin(true)
if data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")); err != nil || string(data) != authpkg.InternationalMCPBaseURL {
t.Fatalf("international mcp_url = %q, %v", string(data), err)
}
runLogin(false)
if data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")); err != nil || string(data) != authpkg.DefaultMCPBaseURL {
t.Fatalf("domestic mcp_url = %q, %v", string(data), err)
}
if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) {
t.Fatalf("managed MCP region marker remains: %v", err)
}
const customURL = "https://private-mcp.example.com"
if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte(customURL), config.FilePerm); err != nil {
t.Fatal(err)
}
runLogin(true)
if data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")); err != nil || string(data) != customURL {
t.Fatalf("explicit mcp_url after international login = %q, %v; want preserved custom URL", string(data), err)
}
if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) {
t.Fatalf("explicit mcp_url acquired a managed marker: %v", err)
}
})
for _, hidden := range []bool{false, true} {
old := edition.Get()
edition.Override(&edition.Hooks{HideAuthLogin: hidden})
+96 -2
View File
@@ -74,6 +74,20 @@ func defaultPATMCPEndpoint() string {
func defaultPATGatewayBaseURL() string {
raw := strings.TrimSpace(authpkg.GetMCPBaseURL())
return mcpGatewayBaseURL(raw)
}
func defaultPATGatewayBaseURLForLoginRegion(region authpkg.LoginRegion) string {
raw := strings.TrimSpace(authpkg.GetMCPBaseURL())
if override := authpkg.MCPBaseURLOverride(); override != "" {
raw = override
} else {
raw = mcpBaseURLForLoginRegion(raw, region)
}
return mcpGatewayBaseURL(raw)
}
func mcpGatewayBaseURL(raw string) string {
parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return strings.TrimRight(raw, "/")
@@ -100,6 +114,33 @@ func defaultPATGatewayBaseURL() string {
return strings.TrimRight(parsed.String(), "/")
}
func mcpBaseURLForLoginRegion(raw string, region authpkg.LoginRegion) string {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return raw
}
host := strings.ToLower(parsed.Hostname())
fromSuffix, toSuffix := ".dingtalk.io", ".dingtalk.com"
if region.IsInternational() {
fromSuffix, toSuffix = toSuffix, fromSuffix
}
bareFrom := strings.TrimPrefix(fromSuffix, ".")
if host != bareFrom && !strings.HasSuffix(host, fromSuffix) {
return raw
}
if host == bareFrom {
host = strings.TrimPrefix(toSuffix, ".")
} else {
host = strings.TrimSuffix(host, fromSuffix) + toSuffix
}
if port := parsed.Port(); port != "" {
parsed.Host = net.JoinHostPort(host, port)
} else {
parsed.Host = host
}
return parsed.String()
}
// SetDynamicServers injects server data discovered from servers.json.
// All product endpoints are resolved dynamically from this data.
func SetDynamicServers(servers []mcptypes.ServerDescriptor) {
@@ -131,7 +172,7 @@ func registerDynamicServer(server mcptypes.ServerDescriptor, endpoints map[strin
return
}
id := strings.TrimSpace(server.CLI.ID)
endpoint := strings.TrimSpace(server.Endpoint)
endpoint := activeDingTalkGatewayEndpoint(server.Endpoint)
if id != "" && endpoint != "" {
endpoints[id] = endpoint
products[id] = true
@@ -262,6 +303,19 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
return "", false
}
func hasDirectRuntimeEndpointOverride(productID string) bool {
normalized := normalizeDirectRuntimeProductID(productID)
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
if candidate == "" {
continue
}
if _, ok := productEndpointOverride(candidate); ok {
return true
}
}
return false
}
func editionServerEndpoint(productID string) (string, bool) {
productID = strings.TrimSpace(productID)
if productID == "" {
@@ -282,7 +336,7 @@ func endpointFromEditionServers(productID string, fn func() []edition.ServerInfo
return "", false
}
for _, server := range fn() {
endpoint := strings.TrimSpace(server.Endpoint)
endpoint := activeDingTalkGatewayEndpoint(server.Endpoint)
if endpoint == "" {
continue
}
@@ -298,6 +352,46 @@ func endpointFromEditionServers(productID string, fn func() []edition.ServerInfo
return "", false
}
func activeDingTalkGatewayEndpoint(endpoint string) string {
return activeDingTalkGatewayEndpointWithBase(endpoint, defaultPATGatewayBaseURL())
}
func activeDingTalkGatewayEndpointForLoginRegion(endpoint string, region authpkg.LoginRegion) string {
return activeDingTalkGatewayEndpointWithBase(endpoint, defaultPATGatewayBaseURLForLoginRegion(region))
}
func activeDingTalkGatewayEndpointWithBase(endpoint, gatewayBaseURL string) string {
endpoint = strings.TrimSpace(endpoint)
parsed, err := url.Parse(endpoint)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return endpoint
}
if !isDingTalkMCPGatewayHost(parsed.Hostname()) {
return endpoint
}
base, err := url.Parse(gatewayBaseURL)
if err != nil || base.Scheme == "" || base.Host == "" {
return endpoint
}
parsed.Scheme = base.Scheme
parsed.Host = base.Host
return strings.TrimRight(parsed.String(), "/")
}
func isDingTalkMCPGatewayHost(host string) bool {
switch strings.ToLower(strings.TrimSpace(host)) {
case "mcp-gw.dingtalk.com", "pre-mcp-gw.dingtalk.com", "mcp-gw.dingtalk.io", "pre-mcp-gw.dingtalk.io":
return true
default:
return false
}
}
func isDingTalkMCPGatewayEndpoint(endpoint string) bool {
parsed, err := url.Parse(strings.TrimSpace(endpoint))
return err == nil && isDingTalkMCPGatewayHost(parsed.Hostname())
}
// DirectRuntimeProductIDs returns product IDs that should stay visible for
// direct runtime execution. Dynamic products come from MCP discovery/plugin
// registration; built-in helper products such as devapp resolve their endpoint
+40
View File
@@ -82,6 +82,46 @@ func TestFlagErrorWithSuggestions_unknownFlagHintAndFlags(t *testing.T) {
}
}
func TestFlagErrorWithSuggestionsChatFromExplainsBothMeanings(t *testing.T) {
t.Parallel()
root := &cobra.Command{Use: "dws"}
chat := &cobra.Command{Use: "chat"}
search := &cobra.Command{Use: "+search-msg", Run: func(*cobra.Command, []string) {}}
search.Flags().String("sender", "", "sender target")
search.Flags().String("start", "", "start time")
root.AddCommand(chat)
chat.AddCommand(search)
orig := fmt.Errorf("unknown flag: --from")
err := flagErrorWithSuggestions(search, orig)
var ae *apperrors.Error
if !stderrors.As(err, &ae) {
t.Fatalf("want *apperrors.Error, got %T", err)
}
if ae.Reason != "ambiguous_flag" || !strings.Contains(ae.Hint, "--sender") || !strings.Contains(ae.Hint, "--start") {
t.Fatalf("structured error = reason %q hint %q", ae.Reason, ae.Hint)
}
if !strings.HasSuffix(ae.Message, "See 'dws chat +search-msg --help' for usage.") {
t.Fatalf("Message = %q", ae.Message)
}
for _, flag := range []string{"from-file", "from-user"} {
t.Run(flag, func(t *testing.T) {
err := flagErrorWithSuggestions(search, fmt.Errorf("unknown flag: --%s", flag))
var structured *apperrors.Error
if stderrors.As(err, &structured) && structured.Reason == "ambiguous_flag" {
t.Fatalf("--%s incorrectly used --from ambiguity handling: %#v", flag, structured)
}
if strings.Contains(err.Error(), "--from 在消息查询中含义不明确") {
t.Fatalf("--%s incorrectly received --from ambiguity hint: %v", flag, err)
}
if !strings.Contains(err.Error(), "unknown flag: --"+flag) {
t.Fatalf("error = %q, want original flag --%s", err, flag)
}
})
}
}
// TestFlagErrorWithSuggestions_fallbackTailHint 验证 fallback 路径(非 unknown flag 类错误,
// 如 missing required flag / ambiguous shorthand)也带尾部 See '<cmd> --help' for usage.
// 这是 wukong / docker / kubectl 的通用 UX——任何 flag 解析错误都给用户一条 help 入口。
+5 -1
View File
@@ -479,7 +479,11 @@ func TestCrossPlatformCoverageChatReactionConversationAliasesReachCanonicalPaylo
if err != nil {
t.Fatalf("alias execution failed: %v", err)
}
if ctx == nil || len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--"+alias || ctx.Corrections[0].Corrected != "--conversation-id" {
if alias == "open-conversation-id" {
if ctx == nil || len(ctx.Corrections) != 0 {
t.Fatalf("alias corrections = %#v", ctx)
}
} else if ctx == nil || len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--"+alias || ctx.Corrections[0].Corrected != "--conversation-id" {
t.Fatalf("alias corrections = %#v", ctx)
}
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
+31 -7
View File
@@ -511,6 +511,22 @@ func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
// 无论哪种格式,子串 "--help' for usage." 都可被检索到。
tail := fmt.Sprintf("\nSee '%s --help' for usage.", cmd.CommandPath())
msgWithTail := errMsg + tail
if flag, ok := unknownFlagName(errMsg); ok && flag == "from" {
switch cmd.CommandPath() {
case "dws chat +search-msg", "dws chat +chat-messages":
return apperrors.NewValidation(
msgWithTail,
apperrors.WithHint("--from 在消息查询中含义不明确:按发送者过滤请使用 --sender <姓名|userId|openDingTalkId>;指定时间起点请使用 --start <RFC3339>"),
apperrors.WithReason("ambiguous_flag"),
apperrors.WithCause(err),
apperrors.WithActions(
"Use --sender <姓名|userId|openDingTalkId> to filter by sender",
"Use --start <RFC3339> together with --end <RFC3339> to set a time range",
),
apperrors.WithAvailableFlags(cmdutil.VisibleFlagNames(cmd)...),
)
}
}
if flag, protection, ok := reviewedFlagProtection(cmd, errMsg); ok {
hint := fmt.Sprintf("Parameter --%s is blocked from automatic normalization on %q; choose an explicit flag from --help.", flag, cmd.CommandPath())
reason := "blocked_flag"
@@ -579,15 +595,10 @@ func reviewedFlagProtection(cmd *cobra.Command, errMsg string) (string, pipeline
if cmd == nil {
return "", "", false
}
const prefix = "unknown flag: --"
idx := strings.Index(errMsg, prefix)
if idx < 0 {
flag, ok := unknownFlagName(errMsg)
if !ok {
return "", "", false
}
flag := strings.TrimSpace(errMsg[idx+len(prefix):])
if i := strings.IndexAny(flag, " =\n\t"); i >= 0 {
flag = flag[:i]
}
entry, ok := cli.LookupParamAlias(cmd.CommandPath())
if !ok {
return "", "", false
@@ -602,6 +613,19 @@ func reviewedFlagProtection(cmd *cobra.Command, errMsg string) (string, pipeline
return "", "", false
}
func unknownFlagName(errMsg string) (string, bool) {
const prefix = "unknown flag: --"
idx := strings.Index(errMsg, prefix)
if idx < 0 {
return "", false
}
flag := strings.TrimSpace(errMsg[idx+len(prefix):])
if i := strings.IndexAny(flag, " =\n\t"); i >= 0 {
flag = flag[:i]
}
return flag, flag != ""
}
func printExecutionError(root *cobra.Command, stdout, stderr io.Writer, err error) error {
var raw apperrors.RawStderrError
if stderrors.As(err, &raw) {
+77 -2
View File
@@ -22,6 +22,7 @@ import (
"strings"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"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"
@@ -93,8 +94,8 @@ func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
}{
{args: []string{"chat", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
{args: []string{"im", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
{args: []string{"chat", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
{args: []string{"im", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
{args: []string{"chat", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --conversation-id <GROUP_OPEN_CONVERSATION_ID>"},
{args: []string{"im", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --conversation-id <GROUP_OPEN_CONVERSATION_ID>"},
} {
command := NewRootCommand()
command.SilenceErrors = true
@@ -488,6 +489,80 @@ func TestInjectStaticServersMergesStaticAndSupplementServers(t *testing.T) {
}
}
func TestCrossPlatformCoverageStaticDingTalkEndpointsFollowConfiguredMCPBaseURL(t *testing.T) {
previous := edition.Get()
defer edition.Override(previous)
defer SetDynamicServers(nil)
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte("https://pre-mcp.dingtalk.io\n"), 0o600); err != nil {
t.Fatalf("WriteFile(mcp_url) error = %v", err)
}
edition.Override(&edition.Hooks{
Name: "test",
StaticServers: func() []edition.ServerInfo {
return []edition.ServerInfo{{
ID: "contact",
Name: "Contact",
Endpoint: "https://mcp-gw.dingtalk.com/server/contact?key=abc",
Prefixes: []string{"user"},
}}
},
})
injectStaticServers()
for _, productID := range []string{"contact", "user"} {
got, ok := directRuntimeEndpoint(productID, "")
want := "https://pre-mcp-gw.dingtalk.io/server/contact?key=abc"
if !ok || got != want {
t.Fatalf("directRuntimeEndpoint(%q) = %q, %v; want %q, true", productID, got, ok, want)
}
}
}
func TestCrossPlatformCoverageDingTalkEndpointsFollowSelectedTokenRegion(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
mcpURLPath := filepath.Join(configDir, "mcp_url")
if err := os.WriteFile(mcpURLPath, []byte("https://pre-mcp.dingtalk.io\n"), 0o600); err != nil {
t.Fatalf("WriteFile(mcp_url) error = %v", err)
}
endpoint := "https://pre-mcp-gw.dingtalk.io/server/contact?key=abc"
if got, want := activeDingTalkGatewayEndpointForLoginRegion(endpoint, authpkg.LoginRegionDefault), "https://pre-mcp-gw.dingtalk.com/server/contact?key=abc"; got != want {
t.Fatalf("domestic profile endpoint = %q, want %q", got, want)
}
if got := activeDingTalkGatewayEndpointForLoginRegion(endpoint, authpkg.LoginRegionInternational); got != endpoint {
t.Fatalf("international profile endpoint = %q, want %q", got, endpoint)
}
if err := os.WriteFile(mcpURLPath, []byte("https://mcp.dingtalk.com\n"), 0o600); err != nil {
t.Fatalf("WriteFile(mcp_url) error = %v", err)
}
if got, want := activeDingTalkGatewayEndpointForLoginRegion("https://mcp-gw.dingtalk.com/server/contact", authpkg.LoginRegionInternational), "https://mcp-gw.dingtalk.io/server/contact"; got != want {
t.Fatalf("international profile endpoint from domestic config = %q, want %q", got, want)
}
}
func TestCrossPlatformCoverageDingTalkEndpointUsesLoginScopedMCPOverride(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
restore := authpkg.PushMCPBaseURLOverride("https://pre-mcp.dingtalk.io")
defer restore()
got := activeDingTalkGatewayEndpointForLoginRegion(
"https://mcp-gw.dingtalk.com/server/contact",
authpkg.LoginRegionDefault,
)
want := "https://pre-mcp-gw.dingtalk.io/server/contact"
if got != want {
t.Fatalf("login-scoped endpoint = %q, want %q", got, want)
}
}
func mustFindCommand(t *testing.T, root *cobra.Command, path ...string) *cobra.Command {
t.Helper()
cmd := root
+29 -12
View File
@@ -162,7 +162,7 @@ var (
runnerResolveMultiProfileSelections = resolveMultiProfileSelections
runnerResolveProfile = authpkg.ResolveProfile
runnerGetCachedRuntimeToken = getCachedRuntimeToken
runnerResolveAuthToken = (*runtimeRunner).resolveAuthToken
runnerResolveAuthSnapshot = (*runtimeRunner).resolveAuthSnapshot
runnerPreflightDocDownload = (*runtimeRunner).preflightDocDownload
runnerCallTool = (*transport.Client).CallTool
runnerStdioEnsureInitialized = (*transport.StdioClient).EnsureInitialized
@@ -552,11 +552,16 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
if hasPluginAuth {
authToken = pluginAuth.Token
} else if !invocation.DryRun && (r.globalFlags == nil || !r.globalFlags.Mock) {
var tokenErr error
authToken, tokenErr = runnerResolveAuthToken(r, ctx)
snapshot, tokenErr := runnerResolveAuthSnapshot(r, ctx)
if tokenErr != nil {
return executor.Result{}, tokenResolutionError(tokenErr)
}
authToken = snapshot.AccessToken
if !hasDirectRuntimeEndpointOverride(invocation.CanonicalProduct) &&
isDingTalkMCPGatewayEndpoint(endpoint) &&
(snapshot.LoginRegionKnown || authpkg.MCPBaseURLOverride() != "") {
endpoint = activeDingTalkGatewayEndpointForLoginRegion(endpoint, snapshot.LoginRegion)
}
}
var timeoutSec int
@@ -877,21 +882,33 @@ func (r *runtimeRunner) executeStdioInvocationAtEndpoint(
}
func (r *runtimeRunner) resolveAuthToken(ctx context.Context) (string, error) {
explicitToken := ""
if r != nil && r.globalFlags != nil {
explicitToken = r.globalFlags.Token
}
return resolveRuntimeAuthToken(ctx, explicitToken)
}
func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) (string, error) {
snapshot, err := runtimeTokenManager.Get(ctx, defaultConfigDir(), explicitToken)
snapshot, err := r.resolveAuthSnapshot(ctx)
if err != nil {
return "", err
}
return snapshot.AccessToken, nil
}
func (r *runtimeRunner) resolveAuthSnapshot(ctx context.Context) (AccessTokenSnapshot, error) {
explicitToken := ""
if r != nil && r.globalFlags != nil {
explicitToken = r.globalFlags.Token
}
return resolveRuntimeAuthSnapshot(ctx, explicitToken)
}
func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) (string, error) {
snapshot, err := resolveRuntimeAuthSnapshot(ctx, explicitToken)
if err != nil {
return "", err
}
return snapshot.AccessToken, nil
}
func resolveRuntimeAuthSnapshot(ctx context.Context, explicitToken string) (AccessTokenSnapshot, error) {
return runtimeTokenManager.Get(ctx, defaultConfigDir(), explicitToken)
}
// getCachedRuntimeToken is kept as the prefetch seam used by runner tests. The
// cache itself lives exclusively in TokenManager.
func getCachedRuntimeToken(ctx context.Context) (string, error) {
+30
View File
@@ -127,12 +127,14 @@ func TestCrossPlatformCoverageRunnerRemainingRoutingCoverage(t *testing.T) {
}
func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldEdition := edition.Get()
oldPreflight := runnerPreflightDocDownload
oldCall := runnerCallTool
oldHandle := runnerHandlePatAuthCheck
oldRetry := runnerRetryWithPatAuthRetry
oldCapture := runnerCaptureRuntimeFailure
oldResolveSnapshot := runnerResolveAuthSnapshot
t.Cleanup(func() {
edition.Override(oldEdition)
runnerPreflightDocDownload = oldPreflight
@@ -140,6 +142,7 @@ func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) {
runnerHandlePatAuthCheck = oldHandle
runnerRetryWithPatAuthRetry = oldRetry
runnerCaptureRuntimeFailure = oldCapture
runnerResolveAuthSnapshot = oldResolveSnapshot
})
pluginAuthMu.Lock()
@@ -168,6 +171,27 @@ func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) {
if got, err := r.executeInvocation(context.Background(), "https://example.test", inv); err != nil || !got.Invocation.Implemented {
t.Fatalf("default auth execution = %#v, %v", got, err)
}
runnerResolveAuthSnapshot = func(*runtimeRunner, context.Context) (AccessTokenSnapshot, error) {
return AccessTokenSnapshot{
AccessToken: "international-token",
LoginRegion: authpkg.LoginRegionInternational,
LoginRegionKnown: true,
}, nil
}
successfulCall := runnerCallTool
routedEndpoint := ""
runnerCallTool = func(_ *transport.Client, _ context.Context, endpoint, _ string, _ map[string]any) (transport.ToolCallResult, error) {
routedEndpoint = endpoint
return transport.ToolCallResult{Content: map[string]any{"value": 1}}, nil
}
if _, err := r.executeInvocation(context.Background(), "https://mcp-gw.dingtalk.com/server/contact", inv); err != nil {
t.Fatalf("international auth execution = %v", err)
}
if routedEndpoint != "https://mcp-gw.dingtalk.io/server/contact" {
t.Fatalf("international routed endpoint = %q", routedEndpoint)
}
runnerResolveAuthSnapshot = oldResolveSnapshot
runnerCallTool = successfulCall
wantErr := errors.New("preflight")
runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
@@ -362,6 +386,12 @@ func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *test
if got, err := resolveRuntimeAuthToken(context.Background(), " runtime "); err != nil || got != "runtime" {
t.Fatalf("runtime explicit token = %q, %v", got, err)
}
edition.Override(&edition.Hooks{TokenProvider: func(context.Context, func() (string, error)) (string, error) {
return "", errors.New("snapshot failed")
}})
if _, err := r.resolveAuthToken(context.Background()); err == nil || !strings.Contains(err.Error(), "snapshot failed") {
t.Fatalf("resolveAuthToken error = %v", err)
}
t.Setenv(envDWSChannel, "channel")
edition.Override(&edition.Hooks{
+79 -3
View File
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 399
publicShortcutCount = 418
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including the hidden historical minutes.shortcut_minutes_search contract.
schemaPublishedShortcutCount = 401
schemaPublishedShortcutCount = 420
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 399
publiclyDeliveredShortcutCount = 418
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
@@ -140,6 +140,78 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
assertChatCatalogCompleteLeafContracts(t)
}
func TestDeliveryWikiSpaceSearchDeclaresCompatibilityAdapter(t *testing.T) {
leaf := executeShortcutSchemaQuery(t, "--cli-path", "wiki +space-search")
if got := schemaContractString(leaf["interface_mode"]); got != "composite" {
t.Fatalf("wiki +space-search interface_mode = %q, want composite", got)
}
reason := schemaContractString(leaf["interface_reason"])
for _, fragment := range []string{"query/limit", "search_wikiSpaces.keyword/pageSize", "versioned Schema migration"} {
if !strings.Contains(reason, fragment) {
t.Fatalf("wiki +space-search interface_reason = %q, want fragment %q", reason, fragment)
}
}
parameters := schemaContractMap(leaf["parameters"])
for name, want := range map[string]string{"query": "query", "limit": "limit"} {
parameter := parameters[name]
if parameter == nil {
t.Fatalf("wiki +space-search missing --%s parameter: %#v", name, parameters)
}
if got := schemaContractString(parameter["property"]); got != want {
t.Fatalf("wiki +space-search --%s property = %q, want compatibility value %q", name, got, want)
}
}
}
func TestAllShortcutsWikiSchemaExamplesIncludeRequiredParameters(t *testing.T) {
tools := deliverySchemaAllToolsForHelpFlagTest(t, NewRootCommand())
checked := 0
for _, declared := range shortcut.All() {
if declared.Service != "wiki" || declared.UserDefined || !shortcut.InPublicCatalog(declared.Service, declared.Command) {
continue
}
checked++
canonical := shortcutSchemaCanonical(declared)
tool := tools[canonical]
if tool == nil {
t.Fatalf("delivery schema --all is missing %s", canonical)
}
examples := schemaContractStringSlice(tool["examples"])
if len(examples) == 0 {
t.Fatalf("%s has no delivered examples", canonical)
}
for _, example := range examples {
argv, err := cli.ParseAgentExampleArgv(example)
if err != nil {
t.Fatalf("%s example %q is not valid argv: %v", canonical, example, err)
}
for _, flag := range declared.Flags {
if !flag.Required {
continue
}
names := append([]string{flag.Name}, flag.Aliases...)
if !schemaExampleHasLongFlag(argv, names...) {
t.Errorf("%s example %q is missing required --%s", canonical, example, flag.Name)
}
}
}
}
if checked != 20 {
t.Fatalf("checked Wiki shortcut examples = %d, want 20", checked)
}
}
func schemaExampleHasLongFlag(argv []string, names ...string) bool {
for _, argument := range argv {
for _, name := range names {
if argument == "--"+name || strings.HasPrefix(argument, "--"+name+"=") {
return true
}
}
}
return false
}
func assertSchemaSummarySafety(
t testing.TB,
summaries map[string]map[string]any,
@@ -207,7 +279,11 @@ func assertChatCatalogCompleteLeafContracts(t testing.TB) {
})
auditJoin := executeShortcutSchemaQuery(t, "--cli-path", "chat group audit-join-validation")
assertSchemaLeafParameterRequired(t, auditJoin, "chat group audit-join-validation", "conversation-id", true)
assertSchemaLeafParameterEnum(t, auditJoin, "chat group audit-join-validation", "status", []string{"AuditApprove", "AuditDelete"})
if parameters := schemaContractMap(auditJoin["parameters"]); parameters["group"] != nil {
t.Fatalf("chat group audit-join-validation publishes hidden --group alias: %#v", parameters["group"])
}
}
func assertSchemaLeafParameterRequired(t testing.TB, leaf map[string]any, cliPath, name string, want bool) {
+81
View File
@@ -424,6 +424,87 @@ func TestBuildAuthURLIncludesTargetCorpID(t *testing.T) {
}
}
func TestCrossPlatformCoverageBuildAuthURLForInternationalRegion(t *testing.T) {
authURL := buildAuthURLForRegion("client-id", "http://127.0.0.1:1234/callback", "", LoginRegionInternational)
if !strings.HasPrefix(authURL, InternationalAuthorizeURL+"?") {
t.Fatalf("auth URL = %s, want international authorize host", authURL)
}
}
func TestCrossPlatformCoverageNotEnabledHTMLUsesRegionAwareAuthorizeURL(t *testing.T) {
if !strings.Contains(notEnabledHTML, "status.authorizeUrl") {
t.Fatal("not-enabled page must read the authorize URL from the regional login status")
}
if strings.Contains(notEnabledHTML, `"https://login.dingtalk.com/oauth2/auth?client_id="`) {
t.Fatal("not-enabled page must not hard-code the domestic authorize URL")
}
}
func TestCrossPlatformCoverageLoginRegionEndpointDefaults(t *testing.T) {
if got := AuthorizeURLForLoginRegion(LoginRegionDefault); got != AuthorizeURL {
t.Fatalf("default authorize URL = %q, want %q", got, AuthorizeURL)
}
if got := DeviceBaseURLForLoginRegion(LoginRegionDefault); got != DefaultDeviceBaseURL {
t.Fatalf("default device base URL = %q, want %q", got, DefaultDeviceBaseURL)
}
if got := UserAccessTokenURLForLoginRegion(LoginRegionInternational); got != InternationalUserAccessTokenURL {
t.Fatalf("international user access token URL = %q, want %q", got, InternationalUserAccessTokenURL)
}
if got := MCPBaseURLForLoginRegion(LoginRegionInternational); got != InternationalMCPBaseURL {
t.Fatalf("international MCP base URL = %q, want %q", got, InternationalMCPBaseURL)
}
if got := DeviceBaseURLForLoginRegion(LoginRegionInternational); got != InternationalDeviceBaseURL {
t.Fatalf("international device base URL = %q, want %q", got, InternationalDeviceBaseURL)
}
}
func TestCrossPlatformCoverageOAuthProviderLoginRegionHelpers(t *testing.T) {
var nilProvider *OAuthProvider
if got := nilProvider.loginRegion(); got != LoginRegionDefault {
t.Fatalf("nil provider login region = %q", got)
}
nilProvider.useTokenLoginRegion(&TokenData{LoginRegion: string(LoginRegionInternational)})
nilProvider.applyLoginRegionToToken(&TokenData{})
provider := &OAuthProvider{}
provider.useTokenLoginRegion(nil)
provider.useTokenLoginRegion(&TokenData{LoginRegion: string(LoginRegionInternational)})
if provider.LoginRegion != LoginRegionInternational {
t.Fatalf("provider login region = %q, want international", provider.LoginRegion)
}
provider.useTokenLoginRegion(&TokenData{LoginRegion: string(LoginRegionDefault)})
provider.applyLoginRegionToToken(nil)
token := &TokenData{}
provider.applyLoginRegionToToken(token)
if token.LoginRegion != string(LoginRegionInternational) {
t.Fatalf("token login region = %q, want international", token.LoginRegion)
}
}
func TestCrossPlatformCoverageMCPBaseURLOverrideAffectsInternationalRegion(t *testing.T) {
restore := PushMCPBaseURLOverride("https://pre-mcp.dingtalk.io/")
defer restore()
if got := MCPBaseURLForLoginRegion(LoginRegionInternational); got != "https://pre-mcp.dingtalk.io" {
t.Fatalf("international MCP base URL = %q, want override", got)
}
}
func TestCrossPlatformCoverageLoginBaseURLOverrideAffectsInternationalRegion(t *testing.T) {
restore := PushLoginBaseURLOverride("https://pre-login.dingtalk.io/")
defer restore()
if got := DeviceBaseURLForLoginRegion(LoginRegionInternational); got != "https://pre-login.dingtalk.io" {
t.Fatalf("international device base URL = %q, want override", got)
}
if got := AuthorizeURLForLoginRegion(LoginRegionInternational); got != "https://pre-login.dingtalk.io/oauth2/auth" {
t.Fatalf("international authorize URL = %q, want override", got)
}
if got := UserAccessTokenURLForLoginRegion(LoginRegionInternational); got != "https://pre-login.dingtalk.io/v1.0/oauth2/userAccessToken" {
t.Fatalf("international user access token URL = %q, want override", got)
}
}
func buildTokenDataFromResponse(resp tokenResponse) *TokenData {
if resp.AccessToken == "" {
return nil
+101
View File
@@ -14,10 +14,12 @@
package auth
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
@@ -206,6 +208,105 @@ func TestCheckCLIAuthEnabled_Enabled(t *testing.T) {
t.Logf("✅ Normal enabled response: success=%v, enabled=%v", status.Success, status.Result.CLIAuthEnabled)
}
func TestCrossPlatformCoverageCheckCLIAuthEnabledTraceHeadersAndDebugLog(t *testing.T) {
t.Setenv("DINGTALK_TRACE_ID", "trace-for-cli-auth-test")
applyCLIAuthTraceHeaders(nil, "trace-for-cli-auth-test")
applyCLIAuthTraceHeaders(httptest.NewRequest(http.MethodGet, "https://example.com", nil), "")
var logBuf bytes.Buffer
previousLogger := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug})))
defer slog.SetDefault(previousLogger)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("EagleEye-TraceId"); got != "trace-for-cli-auth-test" {
t.Errorf("EagleEye-TraceId = %q, want trace-for-cli-auth-test", got)
}
if got := r.Header.Get("X-Dingtalk-Trace-Id"); got != "trace-for-cli-auth-test" {
t.Errorf("X-Dingtalk-Trace-Id = %q, want trace-for-cli-auth-test", got)
}
w.Header().Set("EagleEye-TraceId", "server-trace-001")
w.Header().Set("EagleEye-RpcId", "rpc-001")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(CLIAuthStatus{
Success: true,
Result: &CLIAuthResult{CLIAuthEnabled: true},
})
}))
defer srv.Close()
configDir := setupMCPConfigDir(t, srv.URL)
p := &OAuthProvider{configDir: configDir, httpClient: srv.Client()}
status, err := p.CheckCLIAuthEnabled(context.Background(), "sensitive-token")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if status.Result == nil || !status.Result.CLIAuthEnabled {
t.Fatal("expected CLIAuthEnabled=true")
}
logs := logBuf.String()
for _, want := range []string{
"auth.cli_auth_enabled.request",
"auth.cli_auth_enabled.response",
"trace-for-cli-auth-test",
"server-trace-001",
"rpc-001",
} {
if !strings.Contains(logs, want) {
t.Fatalf("debug logs missing %q, got: %s", want, logs)
}
}
if strings.Contains(logs, "sensitive-token") {
t.Fatalf("debug logs leaked access token: %s", logs)
}
}
func TestCrossPlatformCoverageInternationalLoginRegionRequestWrappers(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case ClientIDPath:
_ = json.NewEncoder(w).Encode(ClientIDResponse{Success: true, Result: "international-client"})
case SuperAdminPath:
_ = json.NewEncoder(w).Encode(SuperAdminResponse{Success: true, Result: []SuperAdmin{{StaffID: "admin"}}})
case SendCliAuthApplyPath:
_ = json.NewEncoder(w).Encode(SendApplyResponse{Success: true, Result: true})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
oldClient := oauthHTTPClient
oauthHTTPClient = server.Client()
restoreBaseURL := PushMCPBaseURLOverride(server.URL)
t.Cleanup(func() {
restoreBaseURL()
oauthHTTPClient = oldClient
})
ctx := context.Background()
for name, fetch := range map[string]func() (string, error){
"device": func() (string, error) { return deviceFetchClientIDForLoginRegion(ctx, LoginRegionInternational) },
"oauth": func() (string, error) { return oauthFetchClientIDForLoginRegion(ctx, LoginRegionInternational) },
} {
if got, err := fetch(); err != nil || got != "international-client" {
t.Fatalf("%s client ID = %q, %v", name, got, err)
}
}
if got, err := deviceGetAdminsForLoginRegion(ctx, "token", LoginRegionInternational); err != nil || !got.Success {
t.Fatalf("device admins = %#v, %v", got, err)
}
if got, err := oauthGetAdminsForLoginRegion(ctx, "token", LoginRegionInternational); err != nil || !got.Success {
t.Fatalf("OAuth admins = %#v, %v", got, err)
}
if got, err := oauthSendApplyForLoginRegion(ctx, "token", "admin", LoginRegionInternational); err != nil || !got.Success {
t.Fatalf("OAuth apply = %#v, %v", got, err)
}
}
func TestCheckCLIAuthEnabled_Disabled(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
+11 -1
View File
@@ -22,6 +22,7 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -1211,7 +1212,13 @@ func TestCrossPlatformCoveragePortableAuthBundleCoverageEdges(t *testing.T) {
if err := os.Symlink(filepath.Join(keyDir, "dek"), filepath.Join(keyDir, "link")); err != nil {
t.Fatal(err)
}
for name, value := range map[string]string{"app.json": "{}", profilesJSONFile: "{}", "mcp_url": "https://mcp.test", "terminal_url": "https://terminal.test"} {
for name, value := range map[string]string{
"app.json": "{}",
profilesJSONFile: "{}",
"mcp_url": "https://mcp.test",
config.ManagedMCPURLRegionFileName: "https://mcp.test",
"terminal_url": "https://terminal.test",
} {
if err := os.WriteFile(filepath.Join(configDir, name), []byte(value), 0o600); err != nil {
t.Fatal(err)
}
@@ -1234,6 +1241,9 @@ func TestCrossPlatformCoveragePortableAuthBundleCoverageEdges(t *testing.T) {
if _, err := os.Stat(filepath.Join(importDir, "app.json")); err != nil {
t.Fatal("config file was not imported")
}
if _, err := os.Stat(filepath.Join(importDir, config.ManagedMCPURLRegionFileName)); err != nil {
t.Fatal("managed MCP region marker was not imported")
}
if _, err := os.Stat(filepath.Join(keychain.StorageDir(keychain.Service), keychain.AccountToken+".enc")); err != nil {
t.Fatal("encrypted token was not imported")
}
+24 -2
View File
@@ -74,6 +74,20 @@ var (
}
)
func deviceFetchClientIDForLoginRegion(ctx context.Context, region LoginRegion) (string, error) {
if region.IsInternational() {
return FetchClientIDFromMCPForLoginRegion(ctx, region)
}
return deviceFetchClientID(ctx)
}
func deviceGetAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) {
if region.IsInternational() {
return GetSuperAdminsForLoginRegion(ctx, accessToken, region)
}
return deviceGetAdmins(ctx, accessToken)
}
type DeviceFlowProvider struct {
configDir string
clientID string
@@ -85,6 +99,7 @@ type DeviceFlowProvider struct {
httpClient *http.Client
NoBrowser bool
IdentityEnricher func(context.Context, *TokenData) error
LoginRegion LoginRegion
}
func NewDeviceFlowProvider(configDir string, logger *slog.Logger) *DeviceFlowProvider {
@@ -104,6 +119,12 @@ func (p *DeviceFlowProvider) SetBaseURL(baseURL string) {
p.baseURL = strings.TrimRight(baseURL, "/")
}
func (p *DeviceFlowProvider) SetLoginRegion(region LoginRegion) {
p.LoginRegion = region
p.baseURL = DeviceBaseURLForLoginRegion(region)
p.terminalBaseURL = MCPBaseURLForLoginRegion(region)
}
// SetTerminalBaseURL sets the terminal API base URL for device flow polling.
func (p *DeviceFlowProvider) SetTerminalBaseURL(baseURL string) {
p.terminalBaseURL = strings.TrimRight(baseURL, "/")
@@ -213,7 +234,7 @@ func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
if p.logger != nil {
p.logger.Debug("fetching client ID from MCP server (device flow always re-fetches)")
}
mcpClientID, mcpErr := deviceFetchClientID(ctx)
mcpClientID, mcpErr := deviceFetchClientIDForLoginRegion(ctx, p.LoginRegion)
if mcpErr != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
}
@@ -272,6 +293,7 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
clientID: p.clientID,
logger: p.logger,
IdentityEnricher: p.IdentityEnricher,
LoginRegion: p.LoginRegion,
}
tokenData, err := deviceExchangeCode(oauthProvider, ctx, tokenResult.AuthCode)
if err != nil {
@@ -331,7 +353,7 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
_, _ = fmt.Fprintln(p.output(), i18n.T(" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。"))
_, _ = fmt.Fprintln(p.output(), "")
admins, adminErr := deviceGetAdmins(ctx, tokenData.AccessToken)
admins, adminErr := deviceGetAdminsForLoginRegion(ctx, tokenData.AccessToken, p.LoginRegion)
if adminErr == nil && admins.Success && len(admins.Result) > 0 {
maxAdmins := 3
if len(admins.Result) < maxAdmins {
+11
View File
@@ -90,6 +90,17 @@ func TestRequestDeviceCodeSuccess(t *testing.T) {
}
}
func TestCrossPlatformCoverageDeviceFlowSetLoginRegionUsesInternationalBaseURL(t *testing.T) {
provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger())
provider.SetLoginRegion(LoginRegionInternational)
if provider.baseURL != InternationalDeviceBaseURL {
t.Fatalf("baseURL = %q, want %q", provider.baseURL, InternationalDeviceBaseURL)
}
if provider.terminalBaseURL != InternationalMCPBaseURL {
t.Fatalf("terminalBaseURL = %q, want %q", provider.terminalBaseURL, InternationalMCPBaseURL)
}
}
func TestWaitForAuthorizationSucceedsAfterPending(t *testing.T) {
t.Parallel()
+112 -5
View File
@@ -50,9 +50,15 @@ const (
// AuthorizeURL is the DingTalk OAuth authorization page.
AuthorizeURL = "https://login.dingtalk.com/oauth2/auth"
// InternationalAuthorizeURL is the DingTalk international OAuth authorization page.
InternationalAuthorizeURL = "https://login.dingtalk.io/oauth2/auth"
// UserAccessTokenURL exchanges an authorization code for user tokens.
UserAccessTokenURL = "https://api.dingtalk.com/v1.0/oauth2/userAccessToken"
// InternationalUserAccessTokenURL exchanges authorization codes for international user tokens.
InternationalUserAccessTokenURL = "https://api.dingtalk.io/v1.0/oauth2/userAccessToken"
// UserInfoURL fetches the authenticated user's profile.
UserInfoURL = "https://api.dingtalk.com/v1.0/contact/users/me"
@@ -75,6 +81,9 @@ const (
// DefaultDeviceBaseURL is the login server base URL for device flow.
DefaultDeviceBaseURL = "https://login.dingtalk.com"
// InternationalDeviceBaseURL is the international login server base URL for device flow.
InternationalDeviceBaseURL = "https://login.dingtalk.io"
// DeviceCodePath requests a device_code and user_code.
DeviceCodePath = "/oauth2/device/code.json"
@@ -96,11 +105,12 @@ const (
LogoutContinueURL = "https://login.dingtalk.com"
// MCP API endpoints for CLI authorization management.
DefaultMCPBaseURL = config.DefaultMCPBaseURL
CLIAuthEnabledPath = "/cli/cliAuthEnabled"
SuperAdminPath = "/cli/superAdmin"
SendCliAuthApplyPath = "/cli/sendCliAuthApply"
ClientIDPath = "/cli/clientId"
DefaultMCPBaseURL = config.DefaultMCPBaseURL
InternationalMCPBaseURL = "https://mcp.dingtalk.io"
CLIAuthEnabledPath = "/cli/cliAuthEnabled"
SuperAdminPath = "/cli/superAdmin"
SendCliAuthApplyPath = "/cli/sendCliAuthApply"
ClientIDPath = "/cli/clientId"
// MCP OAuth endpoints (used when clientId is fetched from MCP).
MCPOAuthTokenPath = "/oauth2/getToken"
@@ -114,6 +124,57 @@ const (
AppAccessTokenURL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
)
type LoginRegion string
const (
LoginRegionDefault LoginRegion = ""
LoginRegionInternational LoginRegion = "international"
)
func (r LoginRegion) IsInternational() bool {
return r == LoginRegionInternational
}
func AuthorizeURLForLoginRegion(region LoginRegion) string {
if override := LoginBaseURLOverride(); override != "" {
return override + "/oauth2/auth"
}
if region.IsInternational() {
return InternationalAuthorizeURL
}
return AuthorizeURL
}
func DeviceBaseURLForLoginRegion(region LoginRegion) string {
if override := LoginBaseURLOverride(); override != "" {
return override
}
if region.IsInternational() {
return InternationalDeviceBaseURL
}
return DefaultDeviceBaseURL
}
func MCPBaseURLForLoginRegion(region LoginRegion) string {
if override := MCPBaseURLOverride(); override != "" {
return override
}
if region.IsInternational() {
return InternationalMCPBaseURL
}
return GetMCPBaseURL()
}
func UserAccessTokenURLForLoginRegion(region LoginRegion) string {
if override := LoginBaseURLOverride(); override != "" {
return override + "/v1.0/oauth2/userAccessToken"
}
if region.IsInternational() {
return InternationalUserAccessTokenURL
}
return UserAccessTokenURL
}
// GetTerminalBaseURL returns the terminal base URL with priority:
// 1. ~/.dws/terminal_url file content (for pre-release environment)
// 2. Default value (https://open-dev.dingtalk.com)
@@ -146,8 +207,54 @@ var (
// clientIDFromMCP indicates whether the clientID was fetched from MCP server.
// When true, MCP OAuth endpoints should be used instead of direct DingTalk API.
clientIDFromMCP bool
loginBaseURLMu sync.RWMutex
loginBaseURLOverride string
mcpBaseURLMu sync.RWMutex
mcpBaseURLOverride string
)
// PushLoginBaseURLOverride sets a process-local DingTalk login base URL
// override and returns a restore function.
func PushLoginBaseURLOverride(baseURL string) func() {
loginBaseURLMu.Lock()
previous := loginBaseURLOverride
loginBaseURLOverride = strings.TrimRight(strings.TrimSpace(baseURL), "/")
loginBaseURLMu.Unlock()
return func() {
loginBaseURLMu.Lock()
loginBaseURLOverride = previous
loginBaseURLMu.Unlock()
}
}
func LoginBaseURLOverride() string {
loginBaseURLMu.RLock()
defer loginBaseURLMu.RUnlock()
return loginBaseURLOverride
}
// PushMCPBaseURLOverride sets a process-local MCP base URL override and returns
// a restore function. It is intended for one command invocation, such as
// pre-release smoke testing.
func PushMCPBaseURLOverride(baseURL string) func() {
mcpBaseURLMu.Lock()
previous := mcpBaseURLOverride
mcpBaseURLOverride = strings.TrimRight(strings.TrimSpace(baseURL), "/")
mcpBaseURLMu.Unlock()
return func() {
mcpBaseURLMu.Lock()
mcpBaseURLOverride = previous
mcpBaseURLMu.Unlock()
}
}
func MCPBaseURLOverride() string {
mcpBaseURLMu.RLock()
defer mcpBaseURLMu.RUnlock()
return mcpBaseURLOverride
}
// SetClientIDFromMCP sets the clientID fetched from MCP server and marks it as MCP-sourced.
func SetClientIDFromMCP(id string) {
clientMu.Lock()
+132 -15
View File
@@ -20,6 +20,7 @@ import (
"fmt"
"html"
"io"
"log/slog"
"net/http"
"net/url"
"os"
@@ -28,6 +29,7 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/google/uuid"
)
var (
@@ -51,7 +53,7 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa
"code": code,
"grantType": "authorization_code",
}
resp, err := p.postJSON(ctx, UserAccessTokenURL, body)
resp, err := p.postJSON(ctx, UserAccessTokenURLForLoginRegion(p.loginRegion()), body)
if err != nil {
return nil, err
}
@@ -62,6 +64,7 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa
// Snapshot credentials used for this token (for refresh)
data.ClientID = clientID
data.Source = resolveCredentialSource()
p.applyLoginRegionToToken(data)
// Save clientSecret for future refresh (even if env changes)
if err := oauthSaveClientSecret(clientID, clientSecret); err != nil {
// Log warning but don't fail login
@@ -91,11 +94,36 @@ func ExchangeCodeForToken(ctx context.Context, configDir, code string) (*TokenDa
return data, nil
}
func (p *OAuthProvider) loginRegion() LoginRegion {
if p == nil {
return LoginRegionDefault
}
return p.LoginRegion
}
func (p *OAuthProvider) useTokenLoginRegion(data *TokenData) {
if p == nil || p.LoginRegion != LoginRegionDefault || data == nil {
return
}
if region := LoginRegion(strings.TrimSpace(data.LoginRegion)); region != LoginRegionDefault {
p.LoginRegion = region
}
}
func (p *OAuthProvider) applyLoginRegionToToken(data *TokenData) {
if data == nil {
return
}
if region := p.loginRegion(); region != LoginRegionDefault {
data.LoginRegion = string(region)
}
}
// exchangeCodeViaMCP exchanges auth code for token via MCP proxy.
// This is used when client secret is not available (server-side secret management).
func (p *OAuthProvider) exchangeCodeViaMCP(ctx context.Context, code string) (*TokenData, error) {
clientID := ClientID()
url := GetMCPBaseURL() + MCPOAuthTokenPath
url := MCPBaseURLForLoginRegion(p.loginRegion()) + MCPOAuthTokenPath
body := map[string]string{
"clientId": clientID,
"authCode": code,
@@ -112,6 +140,7 @@ func (p *OAuthProvider) exchangeCodeViaMCP(ctx context.Context, code string) (*T
// Snapshot credentials used for this token (for refresh)
data.ClientID = clientID
data.Source = "mcp"
p.applyLoginRegionToToken(data)
// MCP mode doesn't need to save clientSecret (server-side managed)
return data, nil
}
@@ -120,8 +149,10 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
// Use stored Source to determine refresh path (not current runtime state)
// This ensures refresh works even if environment variables changed since login
if data.Source == "mcp" {
p.useTokenLoginRegion(data)
return p.refreshViaMCP(ctx, data)
}
p.useTokenLoginRegion(data)
// Direct mode: use stored clientId and load saved clientSecret
clientID := data.ClientID
@@ -145,7 +176,7 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
"refreshToken": data.RefreshToken,
"grantType": "refresh_token",
}
resp, err := p.postJSON(ctx, UserAccessTokenURL, body)
resp, err := p.postJSON(ctx, UserAccessTokenURLForLoginRegion(p.loginRegion()), body)
if err != nil {
return nil, err
}
@@ -156,6 +187,7 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
// Preserve original credentials info
updated.ClientID = data.ClientID
updated.Source = data.Source
updated.LoginRegion = data.LoginRegion
updated.PersistentCode = data.PersistentCode
updated.CorpID = data.CorpID
updated.UserID = data.UserID
@@ -185,7 +217,7 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To
return nil, fmt.Errorf("无法刷新 token: 缺少 clientId,请重新登录")
}
url := GetMCPBaseURL() + MCPRefreshTokenPath
url := MCPBaseURLForLoginRegion(p.loginRegion()) + MCPRefreshTokenPath
body := map[string]string{
"clientId": clientID,
"refreshToken": data.RefreshToken,
@@ -202,6 +234,7 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To
// Preserve original credentials info
updated.ClientID = data.ClientID
updated.Source = data.Source
updated.LoginRegion = data.LoginRegion
updated.PersistentCode = data.PersistentCode
updated.CorpID = data.CorpID
updated.UserID = data.UserID
@@ -371,6 +404,10 @@ func firstNonEmpty(values ...string) string {
}
func buildAuthURL(clientID, redirectURI, targetCorpID string) string {
return buildAuthURLForRegion(clientID, redirectURI, targetCorpID, LoginRegionDefault)
}
func buildAuthURLForRegion(clientID, redirectURI, targetCorpID string, region LoginRegion) string {
params := url.Values{
"client_id": {clientID},
"redirect_uri": {redirectURI},
@@ -381,7 +418,7 @@ func buildAuthURL(clientID, redirectURI, targetCorpID string) string {
if targetCorpID = strings.TrimSpace(targetCorpID); targetCorpID != "" {
params.Set("corpId", targetCorpID)
}
return AuthorizeURL + "?" + params.Encode()
return AuthorizeURLForLoginRegion(region) + "?" + params.Encode()
}
const successHTML = `<!doctype html>
@@ -937,14 +974,15 @@ const notEnabledHTML = `<!doctype html>
clientId = status.clientId || "";
applySent = status.applySent || false;
selectedAdminId = status.selectedAdminId || "";
const authorizeUrl = status.authorizeUrl || "";
if (clientId) {
if (clientId && authorizeUrl) {
const port = location.port;
const redirectUri = encodeURIComponent(
"http://127.0.0.1:" + port + "/callback"
);
backLink.href =
"https://login.dingtalk.com/oauth2/auth?client_id=" +
authorizeUrl + "?client_id=" +
clientId +
"&prompt=consent&redirect_uri=" +
redirectUri +
@@ -1398,6 +1436,7 @@ const mcpRequestMaxRetries = 3
// false negatives caused by momentary network issues.
func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) {
var lastErr error
traceID := cliAuthTraceID()
for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ {
if attempt > 0 {
select {
@@ -1406,7 +1445,7 @@ func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken str
case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
}
}
status, err := p.doCheckCLIAuthEnabled(ctx, accessToken)
status, err := p.doCheckCLIAuthEnabledAttempt(ctx, accessToken, attempt+1, traceID)
if err == nil {
return status, nil
}
@@ -1416,16 +1455,27 @@ func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken str
}
func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) {
url := GetMCPBaseURL() + CLIAuthEnabledPath
return p.doCheckCLIAuthEnabledAttempt(ctx, accessToken, 1, cliAuthTraceID())
}
func (p *OAuthProvider) doCheckCLIAuthEnabledAttempt(ctx context.Context, accessToken string, attempt int, traceID string) (*CLIAuthStatus, error) {
url := MCPBaseURLForLoginRegion(p.loginRegion()) + CLIAuthEnabledPath
req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("x-user-access-token", accessToken)
applyCLIAuthTraceHeaders(req, traceID)
if ch := os.Getenv("DWS_CHANNEL"); ch != "" {
req.Header.Set("x-dws-channel", ch)
}
applyEditionEnterpriseCredentialHeaders(req)
slog.Debug("auth.cli_auth_enabled.request",
"attempt", attempt,
"url", url,
"trace_id", traceID,
"channel", os.Getenv("DWS_CHANNEL"),
)
client := p.httpClient
if client == nil {
@@ -1433,9 +1483,23 @@ func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken s
}
resp, err := client.Do(req)
if err != nil {
slog.Debug("auth.cli_auth_enabled.error",
"attempt", attempt,
"url", url,
"trace_id", traceID,
"error", err,
)
return nil, fmt.Errorf("sending request: %w", err)
}
defer resp.Body.Close()
slog.Debug("auth.cli_auth_enabled.response",
"attempt", attempt,
"url", url,
"status", resp.StatusCode,
"trace_id", traceID,
"response_trace_id", cliAuthResponseTraceID(resp.Header),
"eagleeye_rpc_id", resp.Header.Get("EagleEye-RpcId"),
)
data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
if err != nil {
@@ -1449,9 +1513,42 @@ func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken s
return &status, nil
}
func cliAuthTraceID() string {
if traceID := strings.TrimSpace(os.Getenv("DINGTALK_TRACE_ID")); traceID != "" {
return traceID
}
return strings.ReplaceAll(uuid.NewString(), "-", "")
}
func applyCLIAuthTraceHeaders(req *http.Request, traceID string) {
if req == nil || traceID == "" {
return
}
req.Header.Set("EagleEye-TraceId", traceID)
req.Header.Set("X-Dingtalk-Trace-Id", traceID)
}
func cliAuthResponseTraceID(headers http.Header) string {
for _, key := range []string{
"EagleEye-TraceId",
"X-Trace-Id",
"X-Request-Id",
"X-Dingtalk-Trace-Id",
} {
if value := strings.TrimSpace(headers.Get(key)); value != "" {
return value
}
}
return ""
}
// GetSuperAdmins fetches the list of corp super admins.
// It retries up to mcpRequestMaxRetries times on transient errors.
func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminResponse, error) {
return GetSuperAdminsForLoginRegion(ctx, accessToken, LoginRegionDefault)
}
func GetSuperAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) {
var lastErr error
for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ {
if attempt > 0 {
@@ -1461,7 +1558,7 @@ func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespons
case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
}
}
result, err := doGetSuperAdmins(ctx, accessToken)
result, err := doGetSuperAdminsForLoginRegion(ctx, accessToken, region)
if err == nil {
return result, nil
}
@@ -1471,7 +1568,11 @@ func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespons
}
func doGetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminResponse, error) {
url := GetMCPBaseURL() + SuperAdminPath
return doGetSuperAdminsForLoginRegion(ctx, accessToken, LoginRegionDefault)
}
func doGetSuperAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) {
url := MCPBaseURLForLoginRegion(region) + SuperAdminPath
req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
@@ -1500,6 +1601,10 @@ func doGetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespo
// SendCliAuthApply sends a CLI auth apply request to the specified admin.
// It retries up to mcpRequestMaxRetries times on transient errors.
func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*SendApplyResponse, error) {
return SendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, LoginRegionDefault)
}
func SendCliAuthApplyForLoginRegion(ctx context.Context, accessToken, adminStaffID string, region LoginRegion) (*SendApplyResponse, error) {
var lastErr error
for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ {
if attempt > 0 {
@@ -1509,7 +1614,7 @@ func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*S
case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
}
}
result, err := doSendCliAuthApply(ctx, accessToken, adminStaffID)
result, err := doSendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, region)
if err == nil {
return result, nil
}
@@ -1519,7 +1624,11 @@ func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*S
}
func doSendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*SendApplyResponse, error) {
url := GetMCPBaseURL() + SendCliAuthApplyPath + "?adminStaffId=" + adminStaffID
return doSendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, LoginRegionDefault)
}
func doSendCliAuthApplyForLoginRegion(ctx context.Context, accessToken, adminStaffID string, region LoginRegion) (*SendApplyResponse, error) {
url := MCPBaseURLForLoginRegion(region) + SendCliAuthApplyPath + "?adminStaffId=" + adminStaffID
req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
@@ -1557,6 +1666,10 @@ type ClientIDResponse struct {
// This is used when no client ID is provided via flags, config, or env vars.
// It retries up to mcpRequestMaxRetries times on transient errors.
func FetchClientIDFromMCP(ctx context.Context) (string, error) {
return FetchClientIDFromMCPForLoginRegion(ctx, LoginRegionDefault)
}
func FetchClientIDFromMCPForLoginRegion(ctx context.Context, region LoginRegion) (string, error) {
var lastErr error
for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ {
if attempt > 0 {
@@ -1566,7 +1679,7 @@ func FetchClientIDFromMCP(ctx context.Context) (string, error) {
case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
}
}
id, err := doFetchClientIDFromMCP(ctx)
id, err := doFetchClientIDFromMCPForLoginRegion(ctx, region)
if err == nil {
return id, nil
}
@@ -1576,7 +1689,11 @@ func FetchClientIDFromMCP(ctx context.Context) (string, error) {
}
func doFetchClientIDFromMCP(ctx context.Context) (string, error) {
url := GetMCPBaseURL() + ClientIDPath
return doFetchClientIDFromMCPForLoginRegion(ctx, LoginRegionDefault)
}
func doFetchClientIDFromMCPForLoginRegion(ctx context.Context, region LoginRegion) (string, error) {
url := MCPBaseURLForLoginRegion(region) + ClientIDPath
req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
+33 -5
View File
@@ -68,6 +68,27 @@ var (
oauthSleep = time.Sleep
)
func oauthFetchClientIDForLoginRegion(ctx context.Context, region LoginRegion) (string, error) {
if region.IsInternational() {
return FetchClientIDFromMCPForLoginRegion(ctx, region)
}
return oauthFetchClientID(ctx)
}
func oauthGetAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) {
if region.IsInternational() {
return GetSuperAdminsForLoginRegion(ctx, accessToken, region)
}
return oauthGetAdmins(ctx, accessToken)
}
func oauthSendApplyForLoginRegion(ctx context.Context, accessToken, adminStaffID string, region LoginRegion) (*SendApplyResponse, error) {
if region.IsInternational() {
return SendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, region)
}
return oauthSendApply(ctx, accessToken, adminStaffID)
}
// OAuthProvider handles the DingTalk OAuth 2.0 authorization code flow.
type OAuthProvider struct {
configDir string
@@ -80,6 +101,7 @@ type OAuthProvider struct {
// IdentityEnricher resolves userId/userName/corpName while the freshly
// exchanged access token is still only in memory.
IdentityEnricher func(context.Context, *TokenData) error
LoginRegion LoginRegion
}
// NewOAuthProvider creates a new OAuth provider.
@@ -173,7 +195,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
if p.logger != nil {
p.logger.Debug("fetching client ID from MCP server (OAuth flow always re-fetches)")
}
mcpClientID, mcpErr := oauthFetchClientID(ctx)
mcpClientID, mcpErr := oauthFetchClientIDForLoginRegion(ctx, p.LoginRegion)
if mcpErr != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
}
@@ -401,7 +423,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
_, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`))
return
}
result, err := oauthGetAdmins(ctx, token.AccessToken)
result, err := oauthGetAdminsForLoginRegion(ctx, token.AccessToken, p.LoginRegion)
if err != nil {
_, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error())
return
@@ -425,7 +447,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
_, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`))
return
}
result, err := oauthSendApply(ctx, token.AccessToken, adminStaffID)
result, err := oauthSendApplyForLoginRegion(ctx, token.AccessToken, adminStaffID, p.LoginRegion)
if err != nil {
_, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error())
return
@@ -448,7 +470,13 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
applySent := callbackApplySent
selectedAdminId := callbackSelectedAdminId
callbackTokenMu.Unlock()
_, _ = fmt.Fprintf(w, `{"clientId":"%s","applySent":%t,"selectedAdminId":"%s"}`, p.clientID, applySent, selectedAdminId)
data, _ := json.Marshal(map[string]any{
"clientId": p.clientID,
"authorizeUrl": AuthorizeURLForLoginRegion(p.LoginRegion),
"applySent": applySent,
"selectedAdminId": selectedAdminId,
})
_, _ = w.Write(data)
})
// API endpoint: check CLI auth enabled status
@@ -491,7 +519,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
_ = server.Shutdown(shutCtx)
}()
authURL := buildAuthURL(p.clientID, redirectURI, p.TargetCorpID)
authURL := buildAuthURLForRegion(p.clientID, redirectURI, p.TargetCorpID, p.LoginRegion)
if p.logger != nil {
p.logger.Debug("authorization URL", "url", authURL)
}
+1 -1
View File
@@ -272,7 +272,7 @@ func ImportPortableAuthBundle(configDir string, r io.Reader) (PortableImportRepo
func portableConfigFiles(configDir string) ([]string, error) {
var files []string
patterns := []string{"app*.json", profilesJSONFile, "mcp_url", "terminal_url"}
patterns := []string{"app*.json", profilesJSONFile, "mcp_url", config.ManagedMCPURLRegionFileName, "terminal_url"}
for _, pattern := range patterns {
matches, err := portableGlob(filepath.Join(configDir, pattern))
if err != nil {
+1
View File
@@ -97,6 +97,7 @@ type TokenData struct {
ClientID string `json:"client_id,omitempty"` // Associated app client ID for refresh
UpdatedAt string `json:"updated_at,omitempty"`
Source string `json:"source,omitempty"`
LoginRegion string `json:"login_region,omitempty"`
// LegacyOrgScopedProfile is an in-memory destination for an explicitly
// matched historical profile whose userId was never resolved. It is never
// persisted as token material.
+18 -12
View File
@@ -177,6 +177,7 @@ func reduceLeafParamAliases(path string, realByMorph map[string][]realFlag, conc
aliasMap := make(map[string]string)
blockedSet := make(map[string]bool)
excludedSet := make(map[string]bool)
claimedRealSet := make(map[string]bool)
pendingReview := ov.Confirm || ov.Investigate
for boundFlag, conceptID := range ov.Bind {
@@ -218,6 +219,14 @@ func reduceLeafParamAliases(path string, realByMorph map[string][]realFlag, conc
if len(candidates) == 0 {
continue
}
for m := range eff {
if _, isReal := realByMorph[m]; isReal {
claimedRealSet[m] = true
}
}
for _, exclude := range concept.Excludes {
excludedSet[cmdutil.Morph(exclude)] = true
}
visible := distinctRealNames(candidates, true)
var canon string
switch len(visible) {
@@ -267,21 +276,18 @@ func reduceLeafParamAliases(path string, realByMorph map[string][]realFlag, conc
}
aliasMap[m] = canon
}
// Excludes are not passive prose: once this concept is active on a
// reviewed command, a non-real excluded spelling is protected from
// downstream fuzzy correction. A real flag is left alone because it
// already has an independently valid command-local meaning.
for _, exclude := range concept.Excludes {
morphed := cmdutil.Morph(exclude)
if _, isReal := realByMorph[morphed]; !isReal {
excludedSet[morphed] = true
}
}
}
for excluded := range excludedSet {
if _, isAlias := aliasMap[excluded]; !isAlias {
blockedSet[excluded] = true
if _, isAlias := aliasMap[excluded]; isAlias {
continue
}
if claimedRealSet[excluded] {
continue
}
if _, isReal := realByMorph[excluded]; isReal {
continue
}
blockedSet[excluded] = true
}
// (b) Command scoped aliases override concept reductions.
+146 -265
View File
@@ -889,13 +889,9 @@ var generatedParamAliases = []ParamAliasEntry{
Blocked: []string{"cursor"},
},
{
CLIPath: "chat category add-conv",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
CLIPath: "chat category add-conv",
Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Ambiguous: []string{"chat", "chat-id", "open-conversation-id"},
},
{
CLIPath: "chat category create",
@@ -920,13 +916,9 @@ var generatedParamAliases = []ParamAliasEntry{
Blocked: []string{"category-ids"},
},
{
CLIPath: "chat category remove-conv",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
CLIPath: "chat category remove-conv",
Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Ambiguous: []string{"chat", "chat-id", "open-conversation-id"},
},
{
CLIPath: "chat category rename",
@@ -970,23 +962,19 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat conversation-info",
Aliases: map[string]string{
"chat-id": "group",
"open-conversation-id": "group",
"staff-id": "user",
"uid": "user",
"userid": "user",
"staff-id": "user",
"uid": "user",
"userid": "user",
},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
Ambiguous: []string{"chat-id", "open-conversation-id"},
},
{
CLIPath: "chat group audit-join-validation",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Ambiguous: []string{"staff-id", "uid", "user", "user-id", "userid"},
},
{
@@ -1006,12 +994,9 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat group dismiss",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group get-by-group-id",
@@ -1020,12 +1005,9 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat group invite-url",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group members",
@@ -1100,52 +1082,37 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat group notice create",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group notice edit",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group notice get",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group notice list",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group quit",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group rename",
@@ -1161,23 +1128,17 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat group set-admin",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"user-ids": "users",
"chat-id": "conversation-id",
"user-ids": "users",
},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"},
},
{
CLIPath: "chat group set-history",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group share-invite",
@@ -1191,151 +1152,111 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat group transfer-owner",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"staff-id": "user",
"uid": "user",
"userid": "user",
"chat-id": "conversation-id",
"staff-id": "user",
"uid": "user",
"userid": "user",
},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
},
{
CLIPath: "chat group update-alias",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group update-icon",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group update-nick",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group update-settings",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group-mute",
Aliases: map[string]string{
"chat-id": "group",
"open-conversation-id": "group",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group update-icon",
Aliases: map[string]string{
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group update-nick",
Aliases: map[string]string{
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group update-settings",
Aliases: map[string]string{
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group-mute",
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Ambiguous: []string{"chat-id", "open-conversation-id"},
},
{
CLIPath: "chat group-mute-member",
Aliases: map[string]string{
"chat-id": "group",
"open-conversation-id": "group",
"user-ids": "users",
"user-ids": "users",
},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"},
Ambiguous: []string{"chat-id", "open-conversation-id"},
},
{
CLIPath: "chat group-role add",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group-role list",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group-role query-user",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"staff-id": "user",
"uid": "user",
"userid": "user",
"chat-id": "conversation-id",
"staff-id": "user",
"uid": "user",
"userid": "user",
},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
},
{
CLIPath: "chat group-role remove",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group-role remove-user",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"staff-id": "user",
"uid": "user",
"userid": "user",
"chat-id": "conversation-id",
"staff-id": "user",
"uid": "user",
"userid": "user",
},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
},
{
CLIPath: "chat group-role set-user",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"staff-id": "user",
"uid": "user",
"userid": "user",
"chat-id": "conversation-id",
"staff-id": "user",
"uid": "user",
"userid": "user",
},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
},
{
CLIPath: "chat group-role update",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat hide",
@@ -1369,12 +1290,10 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat message add-emoji",
Aliases: map[string]string{
"chat-id": "conversation-id",
"message-id": "msg-id",
"open-conversation-id": "conversation-id",
"open-message-id": "msg-id",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
Ambiguous: []string{"open-message-id"},
},
{
CLIPath: "chat message add-favorite",
@@ -1391,12 +1310,10 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat message add-text-emotion",
Aliases: map[string]string{
"chat-id": "conversation-id",
"message-id": "msg-id",
"open-conversation-id": "conversation-id",
"open-message-id": "msg-id",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
Ambiguous: []string{"open-message-id"},
},
{
CLIPath: "chat message combine-forward",
@@ -1427,8 +1344,6 @@ var generatedParamAliases = []ParamAliasEntry{
Aliases: map[string]string{
"dest-open-cid": "dest-conversation-id",
"destination-conversation-id": "dest-conversation-id",
"message-id": "msg-id",
"open-message-id": "msg-id",
"source-conversation-id": "src-conversation-id",
"src-open-cid": "src-conversation-id",
"target-conversation-id": "dest-conversation-id",
@@ -1453,21 +1368,20 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat message list",
Aliases: map[string]string{
"chat-id": "group",
"max-result": "limit",
"max-results": "limit",
"open-conversation-id": "group",
"page-size": "limit",
"per-page": "limit",
"staff-id": "user",
"start": "time",
"take": "limit",
"top": "limit",
"uid": "user",
"user-id": "user",
"userid": "user",
"max-result": "limit",
"max-results": "limit",
"page-size": "limit",
"per-page": "limit",
"staff-id": "user",
"start": "time",
"take": "limit",
"top": "limit",
"uid": "user",
"user-id": "user",
"userid": "user",
},
Blocked: []string{"at-user-ids", "conversation-ids", "count", "cursor", "dest-conversation-id", "end", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "page", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
Blocked: []string{"at-user-ids", "conversation-ids", "count", "cursor", "dest-conversation-id", "end", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "page", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
Ambiguous: []string{"chat-id", "open-conversation-id"},
},
{
CLIPath: "chat message list-all",
@@ -1514,12 +1428,9 @@ var generatedParamAliases = []ParamAliasEntry{
},
},
{
CLIPath: "chat message list-mentions",
Aliases: map[string]string{
"chat-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
CLIPath: "chat message list-mentions",
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Ambiguous: []string{"chat-id", "open-conversation-id"},
},
{
CLIPath: "chat message list-pin-msg",
@@ -1534,8 +1445,7 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat message list-topic-replies",
Aliases: map[string]string{
"chat-id": "group",
"open-conversation-id": "group",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
@@ -1549,11 +1459,10 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat message read-status",
Aliases: map[string]string{
"chat-id": "conversation-id",
"msg-id": "message-id",
"open-conversation-id": "conversation-id",
"open-message-id": "message-id",
"user-ids": "users",
"chat-id": "conversation-id",
"msg-id": "message-id",
"open-message-id": "message-id",
"user-ids": "users",
},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "staff-id", "target", "topic-id", "uid", "userid"},
},
@@ -1561,30 +1470,25 @@ var generatedParamAliases = []ParamAliasEntry{
CLIPath: "chat message recall",
Aliases: map[string]string{
"chat-id": "conversation-id",
"message-id": "msg-id",
"open-conversation-id": "conversation-id",
"open-message-id": "msg-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"},
},
{
CLIPath: "chat message recall-by-bot",
Aliases: map[string]string{
"chat-id": "group",
"open-conversation-id": "group",
"robot": "robot-code",
"robot": "robot-code",
},
Blocked: []string{"bot-code", "bot-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-bot-id", "open-conversation-ids", "robot-id", "source", "src-conversation-id", "target"},
Blocked: []string{"bot-code", "bot-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-bot-id", "open-conversation-ids", "robot-id", "source", "src-conversation-id", "target"},
Ambiguous: []string{"chat-id", "open-conversation-id"},
},
{
CLIPath: "chat message remove-emoji",
Aliases: map[string]string{
"chat-id": "conversation-id",
"message-id": "msg-id",
"open-conversation-id": "conversation-id",
"open-message-id": "msg-id",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
Ambiguous: []string{"open-message-id"},
},
{
CLIPath: "chat message remove-favorite",
@@ -1601,12 +1505,10 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat message remove-text-emotion",
Aliases: map[string]string{
"chat-id": "conversation-id",
"message-id": "msg-id",
"open-conversation-id": "conversation-id",
"open-message-id": "msg-id",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
Ambiguous: []string{"open-message-id"},
},
{
CLIPath: "chat message reply",
@@ -1620,12 +1522,9 @@ var generatedParamAliases = []ParamAliasEntry{
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-id", "msg-id", "msg-ids", "name", "open-conversation-ids", "open-message-id", "source", "src-conversation-id", "src-msg-id", "staff-id", "target", "uid", "user", "user-id", "userid"},
},
{
CLIPath: "chat message search",
Aliases: map[string]string{
"chat-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
CLIPath: "chat message search",
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Ambiguous: []string{"chat-id", "open-conversation-id"},
},
{
CLIPath: "chat message search-advanced",
@@ -1638,28 +1537,25 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat message send",
Aliases: map[string]string{
"chat-id": "group",
"file": "file-path",
"open-conversation-id": "group",
"staff-id": "user",
"to-user": "user",
"uid": "user",
"user-id": "user",
"userid": "user",
"file": "file-path",
"staff-id": "user",
"to-user": "user",
"uid": "user",
"user-id": "user",
"userid": "user",
},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "user-ids", "users"},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "user-ids", "users"},
Ambiguous: []string{"chat-id", "open-conversation-id"},
},
{
CLIPath: "chat message send-by-bot",
Aliases: map[string]string{
"at-users": "at-user-ids",
"chat-id": "group",
"open-conversation-id": "group",
"robot": "robot-code",
"user-ids": "users",
"at-users": "at-user-ids",
"robot": "robot-code",
"user-ids": "users",
},
Blocked: []string{"bot-code", "bot-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-bot-id", "open-conversation-ids", "robot-id", "source", "src-conversation-id", "staff-id", "target", "to-user-id", "uid", "user", "user-id", "userid"},
Ambiguous: []string{"at-ids"},
Ambiguous: []string{"at-ids", "chat-id", "open-conversation-id"},
},
{
CLIPath: "chat message send-by-webhook",
@@ -1668,14 +1564,9 @@ var generatedParamAliases = []ParamAliasEntry{
},
},
{
CLIPath: "chat message send-card",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "user", "user-id", "userid"},
CLIPath: "chat message send-card",
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "user", "user-id", "userid"},
Ambiguous: []string{"chat", "chat-id", "open-conversation-id"},
},
{
CLIPath: "chat message set-pin-msg",
@@ -1684,8 +1575,6 @@ var generatedParamAliases = []ParamAliasEntry{
"chat-id": "open-conversation-id",
"conversation-id": "open-conversation-id",
"group": "open-conversation-id",
"message-id": "msg-id",
"open-message-id": "msg-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"},
},
@@ -1696,8 +1585,6 @@ var generatedParamAliases = []ParamAliasEntry{
"chat-id": "open-conversation-id",
"conversation-id": "open-conversation-id",
"group": "open-conversation-id",
"message-id": "msg-id",
"open-message-id": "msg-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"},
},
@@ -1708,8 +1595,6 @@ var generatedParamAliases = []ParamAliasEntry{
"chat-id": "open-conversation-id",
"conversation-id": "open-conversation-id",
"group": "open-conversation-id",
"message-id": "msg-id",
"open-message-id": "msg-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"},
},
@@ -1720,17 +1605,13 @@ var generatedParamAliases = []ParamAliasEntry{
"chat-id": "open-conversation-id",
"conversation-id": "open-conversation-id",
"group": "open-conversation-id",
"message-id": "msg-id",
"open-message-id": "msg-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"},
},
{
CLIPath: "chat mute",
Aliases: map[string]string{
"chat-id": "conversation-id",
"group": "conversation-id",
"open-conversation-id": "conversation-id",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "group-id", "group-ids", "open-conversation-ids"},
},
@@ -2548,7 +2429,7 @@ var generatedParamAliases = []ParamAliasEntry{
"node-id": "node",
"url": "node",
},
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"},
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"},
Ambiguous: []string{"max-result", "max-results", "next-cursor", "next-page-token", "next-token", "per-page", "size", "take", "top"},
},
{
+104
View File
@@ -237,6 +237,42 @@ func TestReduceLeafParamAliasesRemainingEdges(t *testing.T) {
})
}
func TestCrossPlatformCoverageReduceLeafParamAliasesBindExcludesRealFlags(t *testing.T) {
entry, problems := reduceLeafParamAliases(
"demo cmd",
realMap(realFlag{name: "id"}, realFlag{name: "name"}, realFlag{name: "query"}),
[]Concept{
{ID: "base_id", Members: []string{"base-id", "base-token"}, Excludes: []string{"keyword", "name", "query", "unsafe"}},
{ID: "query", Members: []string{"query", "keyword"}},
},
CommandOverride{Bind: map[string]string{"id": "base_id"}},
)
if len(problems) != 0 {
t.Fatalf("reduceLeafParamAliases() problems = %v", problems)
}
if entry == nil {
t.Fatal("reduceLeafParamAliases() entry = nil")
}
if entry.Aliases["base-id"] != "id" || entry.Aliases["base-token"] != "id" {
t.Fatalf("bound aliases = %#v, want base-id/base-token -> id", entry.Aliases)
}
if entry.Aliases["keyword"] != "query" {
t.Fatalf("query alias = %#v, want keyword -> query", entry.Aliases)
}
if containsParamAlias(entry.Blocked, "keyword") {
t.Fatalf("excluded alias entered blocked list: %#v", entry.Blocked)
}
if containsParamAlias(entry.Blocked, "name") {
t.Fatalf("real excluded flag entered blocked list: %#v", entry.Blocked)
}
if containsParamAlias(entry.Blocked, "query") {
t.Fatalf("claimed real excluded flag entered blocked list: %#v", entry.Blocked)
}
if !containsParamAlias(entry.Blocked, "unsafe") {
t.Fatalf("non-real excluded flag was not blocked: %#v", entry.Blocked)
}
}
func TestParamAliasEntryLookupMethods(t *testing.T) {
entry := ParamAliasEntry{
Aliases: map[string]string{"uid": "user"},
@@ -457,6 +493,22 @@ func TestReduceLeafParamAliasesExcludesProtectFuzzyButDoNotOverrideAnotherConcep
}
}
func TestReduceLeafParamAliasesExcludesDoNotBlockRealFlag(t *testing.T) {
concepts := []Concept{
{ID: "single_id", Members: []string{"id", "item-id"}, Excludes: []string{"item-ids"}},
}
entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "id"}, realFlag{name: "item-ids"}), concepts, CommandOverride{})
if len(problems) != 0 {
t.Fatalf("unexpected problems: %v", problems)
}
if entry == nil {
t.Fatal("expected a reduced entry")
}
if containsParamAlias(entry.Blocked, "item-ids") {
t.Fatalf("real exclude was blocked: %#v", entry)
}
}
func TestReduceLeafParamAliasesRejectsProtectionOrScopedAliasOnRealFlag(t *testing.T) {
real := realMap(realFlag{name: "user-id"}, realFlag{name: "user"})
for name, override := range map[string]CommandOverride{
@@ -472,6 +524,58 @@ func TestReduceLeafParamAliasesRejectsProtectionOrScopedAliasOnRealFlag(t *testi
}
}
func TestGeneratedParamAliasesBlockPluralListSpellingsOnSingleIDCommands(t *testing.T) {
entries := make(map[string]ParamAliasEntry, len(generatedParamAliases))
for _, entry := range generatedParamAliases {
entries[entry.CLIPath] = entry
}
assertBlocked := func(path string, names ...string) {
t.Helper()
entry, ok := entries[path]
if !ok {
t.Fatalf("missing generated alias entry for %q", path)
}
for _, name := range names {
if !entry.IsBlocked(cmdutil.Morph(name)) {
t.Fatalf("%s: %q not blocked; entry = %#v", path, name, entry)
}
}
}
for _, path := range []string{
"chat message add-emoji",
"chat message remove-emoji",
"chat message add-text-emotion",
"chat message remove-text-emotion",
} {
assertBlocked(path, "msg-ids", "message-ids")
}
for _, path := range []string{
"chat message send",
"chat conversation-info",
"chat category add-conv",
"chat category remove-conv",
"chat message list",
"chat message list-mentions",
"chat message recall-by-bot",
"chat message search",
} {
assertBlocked(path, "conversation-ids")
}
}
func TestGeneratedParamAliasesKeepAuditJoinUserRoleAmbiguous(t *testing.T) {
entry, ok := LookupParamAlias("chat group audit-join-validation")
if !ok {
t.Fatal("missing generated alias entry for chat group audit-join-validation")
}
for _, name := range []string{"user", "user-id", "userid", "uid", "staff-id"} {
if !entry.IsAmbiguous(cmdutil.Morph(name)) {
t.Fatalf("%q not ambiguous; entry = %#v", name, entry)
}
}
}
// TestGeneratedParamAliasesAreWellFormed guards the committed generated table
// at the Go level, complementing the byte-identity drift gate.
func TestGeneratedParamAliasesAreWellFormed(t *testing.T) {
+19 -12
View File
@@ -60,11 +60,17 @@
"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."},
"chat group members remove": {"bind": {"id": "open_conversation_id"}},
"chat message add-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 add-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 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."},
"chat conversation-info": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
"chat group-mute": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
"chat group-mute-member": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
"chat message add-emoji": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."},
"chat message add-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."},
"chat message list-mentions": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
"chat message recall-by-bot": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
"chat message remove-emoji": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."},
"chat message remove-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."},
"chat message search": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
"chat mute": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --conversation-id and hidden compatibility --group/--id/--chat/--open-conversation-id remain unchanged; reviewed chat-id reduces to --conversation-id."},
"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"},
@@ -84,12 +90,12 @@
"chat +unread-chats": {"scoped_aliases": {"limit": "count", "size": "count"}, "scope_strict": true, "note": "On this exact command, limit and size both denote the returned unread-conversation count."},
"chat message list-unread-conversations": {"scoped_aliases": {"limit": "count", "size": "count"}, "scope_strict": true, "note": "On this exact command, limit and size both denote the returned unread-conversation count."},
"chat +messages-list-direct": {"scoped_aliases": {"start": "time"}, "block": ["end"], "scope_strict": true, "note": "This exact command accepts one start boundary in yyyy-MM-dd HH:mm:ss; an end-only input cannot be represented."},
"chat message list": {"scoped_aliases": {"start": "time"}, "block": ["end"], "scope_strict": true, "note": "This exact command accepts one start boundary in yyyy-MM-dd HH:mm:ss; an end-only input cannot be represented."},
"chat message list": {"scoped_aliases": {"start": "time"}, "block": ["end"], "ambiguous": ["chat-id", "open-conversation-id"], "scope_strict": true, "note": "This exact command accepts one start boundary in yyyy-MM-dd HH:mm:ss; an end-only input cannot be represented. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
"chat message list-by-sender": {"scoped_aliases": {"user-id": "sender-user-id", "open-dingtalk-id": "sender-open-dingtalk-id"}, "block": ["time"], "scope_strict": true, "note": "Only same-role sender identifiers are mapped; --time cannot supply the required RFC3339 start/end range."},
"contact +resolve-dept": {"bind": {"name": "search_query"}, "note": "The real --name is a department-name search keyword and carries the search_query concept on this shortcut."},
"contact +list-sub-depts": {"block": ["name", "query"], "note": "--dept is an integer department id; names and search queries require a separate resolution command"},
"contact +dept-members": {"bind": {"dept": "search_query"}, "scoped_aliases": {"name": "dept"}, "note": "The real --dept is a department-name search keyword; search spellings come from search_query, while --name remains command-scoped."},
"chat message send": {"scoped_aliases": {"to-user": "user", "file": "file-path"}, "note": "Recipient and local-file-path aliases are exact to this command; obsolete file metadata flags remain unsupported."},
"chat message send": {"scoped_aliases": {"to-user": "user", "file": "file-path"}, "ambiguous": ["chat-id", "open-conversation-id"], "note": "Recipient and local-file-path aliases are exact to this command; obsolete file metadata flags remain unsupported. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
"chat +group-members": {"bind": {"group": "group_name"}, "note": "The real --group is a group-name search keyword on this shortcut, not an identifier."},
"chat +category-create": {"scoped_aliases": {"name": "title"}, "scope_strict": true, "note": "The reviewed name/title mapping preserves the category display-name value on this exact shortcut."},
"chat category create": {"scoped_aliases": {"name": "title"}, "scope_strict": true, "note": "The reviewed name/title mapping preserves the category display-name value on this exact command."},
@@ -98,8 +104,8 @@
"chat +category-delete": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."},
"chat category delete": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."},
"chat category list-conversations": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."},
"chat category add-conv": {"block": ["category-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input."},
"chat category remove-conv": {"block": ["category-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input."},
"chat category add-conv": {"block": ["category-id"], "ambiguous": ["chat", "chat-id", "open-conversation-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
"chat category remove-conv": {"block": ["category-id"], "ambiguous": ["chat", "chat-id", "open-conversation-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
"chat +chat-role-update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."},
"chat group-role remove": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."},
"chat group-role update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."},
@@ -109,7 +115,7 @@
"chat +messages-send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact shortcut."},
"chat message send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact command."},
"doc block insert": {"block": ["before-block-id"], "note": "Parent and reference roles remain distinct. --before-block-id needs both --ref-block and --where before, while role-free --block-id cannot choose parent versus reference.", "scoped_aliases": {"parent-block-id": "parent-block", "ref-block-id": "ref-block", "reference-block-id": "ref-block"}, "ambiguous": ["block-id"], "scope_strict": true},
"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."},
"chat message send-by-bot": {"scoped_aliases": {"at-users": "at-user-ids"}, "block": ["user-id", "to-user-id"], "ambiguous": ["at-ids", "chat-id", "open-conversation-id"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
"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", "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."},
@@ -142,7 +148,7 @@
"chat category create-smart": {"bind": {"members": "open_dingtalk_ids"}, "scoped_aliases": {"title": "name"}, "note": "The real --members is an openDingTalkId list; the reviewed title/name alias is exact to the category display name."},
"chat group audit-join-validation": {"ambiguous": ["user", "user-id", "userid", "uid", "staff-id"], "note": "A role-free user identifier cannot choose between the required --applicant and --inviter roles."},
"chat message reply": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The required --ref-sender is a role-specific openDingTalkId and must not accept generic userId spellings."},
"chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings."},
"chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "ambiguous": ["chat", "chat-id", "open-conversation-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
"chat +category-add-conversation": {"block": ["category-id"], "note": "The real --category-ids is a list; a singular category ID is not promoted automatically."},
"chat +category-list-conversations": {"block": ["category-ids"], "note": "The real --category-id is singular; list cardinality is not reduced automatically."},
"chat +category-remove-conversation": {"block": ["category-id"], "note": "The real --category-ids is a list; a singular category ID is not promoted automatically."},
@@ -328,7 +334,7 @@
{"command": "chat group rename", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"},
{"command": "chat group rename", "emitted": "group-id", "expect": "did-you-mean:blocked", "via": "guard:group-id-vs-open-conversation-id"},
{"command": "chat message send", "emitted": "conversation-id", "expect": "group", "via": "concept:open_conversation_id"},
{"command": "chat message add-emoji", "emitted": "open-conversation-id", "expect": "conversation-id", "via": "override:scoped"},
{"command": "chat message add-emoji", "emitted": "open-conversation-id", "expect": "conversation-id", "via": "concept:open_conversation_id"},
{"command": "chat message add-emoji", "emitted": "group-id", "expect": "did-you-mean:blocked", "via": "guard:group-id-vs-open-conversation-id"},
{"command": "chat +group-members", "emitted": "conversation-id", "expect": "did-you-mean:blocked", "via": "guard:group-name-vs-open-conversation-id"},
{"command": "chat +send-to-group", "emitted": "group-name", "expect": "group", "via": "concept:group_name+bind"},
@@ -466,6 +472,7 @@
{"command": "chat +flag-create", "emitted": "group", "expect": "conversation-id", "via": "concept:open_conversation_id"},
{"command": "chat +chat-add-bot", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"},
{"command": "chat +chat-add-bot", "emitted": "robot", "expect": "robot-code", "via": "concept:robot_code"},
{"command": "chat group audit-join-validation", "emitted": "user", "expect": "did-you-mean:ambiguous", "via": "guard:applicant-vs-inviter-role"},
{"command": "chat +chat-audit-join", "emitted": "applicant-user-id", "expect": "applicant", "via": "override:scoped-user-role"},
{"command": "chat +chat-audit-join", "emitted": "user-id", "expect": "did-you-mean:ambiguous", "via": "guard:applicant-vs-inviter-role"},
{"command": "chat +chat-create", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
+52 -8
View File
@@ -1354,16 +1354,16 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
interfaceType string
}{
{"chat message edit", "conversation-id", "openConversationId", true, ""},
{"chat message edit", "msg-id", "openMessageId", true, ""},
{"chat message edit", "message-id", "openMessageId", true, ""},
{"chat message edit", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"},
{"chat message update-text-emotion", "message-id", "openMsgId", true, ""},
{"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, ""},
{"chat message update-text-emotion", "old-emotion-id", "oldEmotionId", true, ""},
{"chat category batch-info", "category-ids", "categoryIds", true, "array"},
{"chat category list-by-conv", "group", "openConversationId", true, ""},
{"chat group update-nick", "group", "openConversationId", true, ""},
{"chat category list-by-conv", "conversation-id", "openConversationId", true, ""},
{"chat group update-nick", "conversation-id", "", true, ""},
{"chat group upgrade-to-external", "extension", "extension", false, "object"},
{"chat +messages-send-card", "receiver-open-dingtalk-id", "receiverOpenDingTalkId", false, ""},
{"chat message list-favorites", "size", "", false, "string"},
@@ -1397,7 +1397,8 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
}
}
// Hidden conversation aliases must stay unpublished (merge-base parity).
// Manifest-covered migrations hide legacy aliases; manifest-external
// commands keep their existing visible flags for compatibility.
editLeaf, err := queryDeliverySchemaPayload([]string{"chat message edit"})
if err != nil {
t.Fatal(err)
@@ -1421,9 +1422,52 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
t.Fatal(err)
}
listParams := schemaMap(listByConv["parameters"])
for _, hidden := range []string{"conversation-id", "id"} {
if _, ok := listParams[hidden]; ok {
t.Fatalf("chat category list-by-conv unexpectedly publishes hidden alias --%s", hidden)
if _, ok := listParams["conversation-id"]; !ok {
t.Fatalf("chat category list-by-conv missing public canonical --conversation-id")
}
if _, ok := listParams["group"]; !ok {
t.Fatalf("chat category list-by-conv unexpectedly hides manifest-external --group")
}
if _, ok := listParams["id"]; ok {
t.Fatalf("chat category list-by-conv unexpectedly publishes hidden alias --id")
}
for _, path := range []string{
"chat message add-emoji",
"chat message remove-emoji",
"chat message add-text-emotion",
"chat message remove-text-emotion",
} {
leaf, err := queryDeliverySchemaPayload([]string{path})
if err != nil {
t.Fatal(err)
}
params := schemaMap(leaf["parameters"])
if _, ok := params["conversation-id"]; !ok {
t.Fatalf("%s missing public canonical --conversation-id", path)
}
for _, visible := range []string{"group", "id", "chat"} {
if _, ok := params[visible]; !ok {
t.Fatalf("%s unexpectedly hides manifest-external --%s", path, visible)
}
}
}
groupBots, err := queryDeliverySchemaPayload([]string{"chat group bots"})
if err != nil {
t.Fatal(err)
}
groupBotsParams := schemaMap(groupBots["parameters"])
group := groupBotsParams["group"]
if group == nil {
t.Fatal("chat group bots missing public legacy --group")
}
if group["property"] != "openConversationId" {
t.Fatalf("chat group bots --group property = %#v, want openConversationId", group["property"])
}
for _, migrated := range []string{"conversation-id", "group-name"} {
if _, ok := groupBotsParams[migrated]; ok {
t.Fatalf("chat group bots unexpectedly publishes migrated --%s", migrated)
}
}
}
@@ -43,19 +43,14 @@ func init() {
RequireOneOf: [][]string{{"conversation-id", "open-dingtalk-id", "user", "permParam"}},
})
registerExclusiveOneOf("chat.chat_permission_grant_cross_org_data", "target-org-id", "all")
registerRequireOneOf("chat.add_emoji_reaction", "conversation-id", "group", "id", "chat")
registerRequireOneOf("chat.add_text_emotion", "conversation-id", "group", "id", "chat")
registerExclusiveOneOf("chat.clear_conversation_messages", "conversation-id", "id", "chat")
registerExclusiveOneOf("chat.clear_conversation_red_point", "conversation-id", "id", "chat")
registerRequireOneOf("chat.update_text_emotion", "conversation-id", "group", "id", "chat")
registerExclusiveOneOf("chat.get_conversation_info", "group", "user", "open-dingtalk-id")
registerExclusiveOneOf("chat.hide_conversation", "conversation-id", "id", "chat")
registerExclusiveOneOf("chat.list_conversation_message_v2", "group", "user", "open-dingtalk-id")
registerExclusiveOneOf("chat.list_individual_chat_message", "user", "open-dingtalk-id")
registerExclusiveOneOf("chat.mark_conversation_unread", "conversation-id", "id", "chat")
registerExclusiveOneOf("chat.mark_message_read", "conversation-id", "id", "chat")
registerRequireOneOf("chat.remove_emoji_reaction", "conversation-id", "group", "id", "chat")
registerRequireOneOf("chat.remove_text_emotion", "conversation-id", "group", "id", "chat")
registerRequireOneOf("chat.send_personal_message", "text", "content", "msg-type")
registerExclusiveOneOf("chat.send_robot_message", "group", "users")
registerRequireOneOf("chat.set_group_member_mute_list", "users", "user")
@@ -252,7 +252,8 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"chat.batch_query_group_chat_settings --groups": "Reviewed unpinned adapter: chat.batch_query_group_chat_settings has no singular pinned interface_ref; --groups is a CLI wrapper input and does not publish a direct interface property.",
"chat.batch_update_group_chat_settings --items": "Reviewed unpinned adapter: chat.batch_update_group_chat_settings has no singular pinned interface_ref; --items is a CLI wrapper input and does not publish a direct interface property.",
"chat.create_text_emotion --background-id": "Runtime extension: the executable helper forwards backgroundId to im/create_text_emotion, but the pinned source-revision metadata does not declare that optional property; preserve the compatibility flag without advertising it as a pinned RPC field.",
"chat.get_group_mute_config --group": "Reviewed unpinned adapter: chat.get_group_mute_config has no singular pinned interface_ref; --group is a CLI wrapper input and does not publish a direct interface property.",
"chat.get_group_mute_config --conversation-id": "Reviewed unpinned adapter: chat.get_group_mute_config has no singular pinned interface_ref; --conversation-id is a CLI wrapper input and does not publish a direct interface property.",
"chat.get_group_mute_config --group": "Reviewed legacy Schema compatibility: the historical visible --group wrapper input was required but did not publish a direct interface property.",
"chat.list_conversation_message_v2 --open-dingtalk-id": "selects the alternate list_individual_chat_message branch",
"chat.list_conversation_message_v2 --user": "selects the alternate list_individual_chat_message branch",
"chat.list_message_favorites --cursor": "Reviewed unpinned adapter: chat.list_message_favorites has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
@@ -265,6 +266,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"chat.query_msg_read_status --users": "conditional wrapper/alias of --user: parseCSVValues + appendChatIDArgs routes each supplied identifier to targetUserIds or targetOpenDingTalkIds according to its runtime ID shape; there is no single RPC property for this flag",
"chat.remove_message_favorite --open-conversation-id": "Reviewed unpinned adapter: chat.remove_message_favorite has no singular pinned interface_ref; --open-conversation-id is a CLI wrapper input and does not publish a direct interface property.",
"chat.remove_message_favorite --open-message-id": "Reviewed unpinned adapter: chat.remove_message_favorite has no singular pinned interface_ref; --open-message-id is a CLI wrapper input and does not publish a direct interface property.",
"chat.update_text_emotion --conversation-id": "Reviewed unpinned adapter: chat.update_text_emotion has no singular pinned interface_ref; --conversation-id is a CLI wrapper input and does not publish a direct interface property.",
"chat.reply_personal_message --ref-msg-id": "serialized into the aggregate content JSON string",
"chat.reply_personal_message --ref-sender": "resolved then serialized into the aggregate content JSON string",
"chat.reply_personal_message --text": "serialized into the aggregate content JSON string",
+25 -12
View File
@@ -578,18 +578,7 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
for _, alias := range flag.Aliases {
RegisterFlag(cmd, flag.Kind, alias, "", flag.Usage+" (alias)")
_ = cmd.Flags().MarkHidden(alias)
if registered := cmd.Flags().Lookup(alias); registered != nil {
runtimeannotate.SetFlagAnnotation(
registered,
runtimeannotate.AnnotationFlagAliasOf,
flag.Name,
)
runtimeannotate.SetFlagAnnotation(
registered,
runtimeannotate.AnnotationFlagAliasOrigin,
runtimeannotate.FlagAliasOriginCorecmdV1,
)
}
AnnotateFlagAlias(cmd, alias, flag.Name)
}
if flag.MarkRequired {
_ = cmd.MarkFlagRequired(flag.Name)
@@ -600,6 +589,30 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
}
}
// AnnotateFlagAlias records framework-owned evidence that aliasName is a hidden
// compatibility alias for canonicalName. It is for commands that already own
// their Cobra flag registration outside FlagSpec but still need the same
// interface-snapshot alias contract as FlagSpec.Aliases.
func AnnotateFlagAlias(cmd *cobra.Command, aliasName, canonicalName string) {
if cmd == nil {
return
}
registered := cmd.Flags().Lookup(aliasName)
if registered == nil {
return
}
runtimeannotate.SetFlagAnnotation(
registered,
runtimeannotate.AnnotationFlagAliasOf,
canonicalName,
)
runtimeannotate.SetFlagAnnotation(
registered,
runtimeannotate.AnnotationFlagAliasOrigin,
runtimeannotate.FlagAliasOriginCorecmdV1,
)
}
// RegisterFlag registers one flag by Kind. Default is applied at registration
// for every kind so --help DefValue matches the declared fallback.
// Malformed KindInt / KindBool Default values panic at registration (fail-closed)
+10
View File
@@ -116,6 +116,16 @@ func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) {
}
}
func TestCrossPlatformCoverageAnnotateFlagAliasIgnoresMissingInputs(t *testing.T) {
AnnotateFlagAlias(nil, "alias", "canonical")
cmd := newTestCommand()
AnnotateFlagAlias(cmd, "missing", "canonical")
if flag := cmd.Flags().Lookup("missing"); flag != nil {
t.Fatalf("unexpected missing flag registered: %#v", flag)
}
}
// ── effective value fallback chain ─────────────────────────────────
func TestCrossPlatformCoverageEffectiveValueFallbackChain(t *testing.T) {
+25
View File
@@ -15,9 +15,13 @@ package errors
import (
stderrors "errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
func TestCrossPlatformCoverageExitCodeByCategory(t *testing.T) {
@@ -382,6 +386,27 @@ func TestCrossPlatformCoverageServerGuidanceSuppressesUnsafeActionURL(t *testing
}
}
func TestCrossPlatformCoveragePrintJSONCLIOrgNotAuthorizedUsesInternationalActionURL(t *testing.T) {
dir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", dir)
if err := os.WriteFile(filepath.Join(dir, "mcp_url"), []byte("https://mcp.dingtalk.io\n"), config.FilePerm); err != nil {
t.Fatalf("WriteFile(mcp_url) error = %v", err)
}
var b strings.Builder
if err := PrintJSON(&b, NewAPI(
"business error",
WithServerDiag(ServerDiagnostics{ServerErrorCode: "CLI_ORG_NOT_AUTHORIZED"}),
)); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
want := `"action_url": "https://open-dev.dingtalk.io/fe/old#/developerSettings"`
if got := b.String(); !strings.Contains(got, want) {
t.Fatalf("expected international action_url %q, got %q", want, got)
}
}
func TestCrossPlatformCoveragePrintJSONIncludesRPCCodeAndData(t *testing.T) {
t.Parallel()
+808 -446
View File
File diff suppressed because it is too large Load Diff
+260 -8
View File
@@ -3,6 +3,7 @@ package helpers
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
@@ -11,6 +12,7 @@ import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
func runChatCoverageCommand(t *testing.T, caller edition.ToolCaller, args ...string) error {
@@ -32,14 +34,26 @@ func runChatCoverageCommand(t *testing.T, caller edition.ToolCaller, args ...str
func runChatCoverageDirect(t *testing.T, path []string, flags map[string]string) error {
t.Helper()
command, _, err := newChatCommand().Find(path)
InitDeps(&scriptedToolCaller{})
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
root := newChatCommand()
installExampleGlobalFlags(root)
root.PersistentFlags().Bool("debug", false, "")
root.PersistentFlags().Bool("verbose", false, "")
command, _, err := root.Find(path)
if err != nil {
return err
}
for name, value := range flags {
if err := command.Flags().Set(name, value); err != nil {
flag := command.Flag(name)
if flag == nil {
return fmt.Errorf("no such flag -%s", name)
}
if err := flag.Value.Set(value); err != nil {
return err
}
flag.Changed = true
}
return command.RunE(command, nil)
}
@@ -93,7 +107,7 @@ func TestCrossPlatformCoverageChatStableCompatibilityHintsRemainAvailable(t *tes
hint string
}{
{path: "send", args: []string{"send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
{path: "history", args: []string{"history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
{path: "history", args: []string{"history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --conversation-id <GROUP_OPEN_CONVERSATION_ID>"},
} {
command, remaining, err := root.Find([]string{tc.path})
if err != nil {
@@ -113,6 +127,80 @@ func TestCrossPlatformCoverageChatStableCompatibilityHintsRemainAvailable(t *tes
}
}
func TestCrossPlatformCoverageChatAliasInstallerRemainingEdges(t *testing.T) {
restoreChatManifestExternalVisibleFlags(nil)
mismatchedRoot := &cobra.Command{Use: "chat"}
mismatchedRoot.AddCommand(&cobra.Command{Use: "other"})
restoreChatManifestExternalVisibleFlags(mismatchedRoot)
missingPrimary := &cobra.Command{Use: "leaf"}
installChatFlagAliases(missingPrimary, "conversation-id", []string{"group"}, requireChatConversationID)
if flag := missingPrimary.Flags().Lookup("group"); flag != nil {
t.Fatalf("alias registered without canonical flag: %#v", flag)
}
skipGroup := &cobra.Command{Use: "leaf", RunE: func(cmd *cobra.Command, args []string) error { return nil }}
skipGroup.Flags().String("conversation-id", "", "")
skipGroup.Flags().String("group-name", "", "")
installChatFlagAliases(skipGroup, "conversation-id", []string{"group", "chat"}, requireChatConversationID)
if flag := skipGroup.Flags().Lookup("group"); flag != nil {
t.Fatalf("group alias registered beside group-name: %#v", flag)
}
if flag := skipGroup.Flags().Lookup("chat"); flag == nil {
t.Fatal("non-group alias was not registered")
}
preRunCalled := false
withPreRun := &cobra.Command{
Use: "leaf",
PreRunE: func(cmd *cobra.Command, args []string) error {
preRunCalled = true
return nil
},
RunE: func(cmd *cobra.Command, args []string) error { return nil },
}
withPreRun.Flags().String("conversation-id", "", "")
installChatFlagAliases(withPreRun, "conversation-id", []string{"group"}, requireChatConversationID)
withPreRun.SetArgs([]string{"--group", "cid"})
if err := withPreRun.ExecuteContext(context.Background()); err != nil {
t.Fatalf("execute with alias and previous PreRunE: %v", err)
}
if !preRunCalled {
t.Fatal("previous PreRunE was not called")
}
}
func TestCrossPlatformCoverageChatMessageForwardRequiresMessageID(t *testing.T) {
caller := &productExampleCaller{}
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
root := newChatCommand()
command, _, err := root.Find([]string{"message", "forward"})
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"message-id", "msg-id"} {
if flag := command.Flags().Lookup(name); flag != nil && flag.Annotations != nil {
delete(flag.Annotations, cobra.BashCompOneRequiredFlag)
}
}
if err := command.Flags().Set("src-conversation-id", "src"); err != nil {
t.Fatal(err)
}
if err := command.Flags().Set("dest-conversation-id", "dest"); err != nil {
t.Fatal(err)
}
err = command.RunE(command, nil)
if err == nil || !strings.Contains(err.Error(), "missing required flag: --message-id") {
t.Fatalf("forward missing message id error = %v", err)
}
if caller.calls != 0 {
t.Fatalf("tool calls = %d, want 0", caller.calls)
}
}
func TestCrossPlatformCoverageChatGroupUpdateIconAcceptsUploadedMediaIDPrefixes(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
@@ -199,9 +287,10 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T)
{"category", "remove-conv", "--group=cid", "--category-ids=1,2"},
{"message", "list-by-ids", "--msg-ids=" + strings.Repeat("id,", 51) + "last"},
{"group", "transfer-owner", "--group=cid", "--new-owner=D-owner"},
{"group", "transfer-owner", "--group=cid", "--new-owner=DAAAAAAAAAAAiE"},
{"group", "update-icon", "--group=cid", "--icon-media-id=@valid"},
{"group", "set-history", "--group=cid", "--option=ALL"},
{"group", "audit-join-validation", "--group=cid", "--record-id=1", "--applicant=D1", "--inviter=D2", "--status=AuditApprove", "--description=ok"},
{"group", "audit-join-validation", "--conversation-id=cid", "--record-id=1", "--applicant=D1", "--inviter=D2", "--status=AuditApprove", "--description=ok"},
{"mark-read", "--conversation-id=cid", "--message-id=mid"},
{"text", "translate", "--query=hello", "--to=zh_CN"},
{"group-role", "set-user", "--group=cid", "--user=D1", "--role-ids=r1"},
@@ -293,7 +382,7 @@ func TestCrossPlatformCoverageChatNativeSendCardMentions(t *testing.T) {
caller := &scriptedToolCaller{}
err := runChatCoverageCommand(t, caller,
"message", "send-card",
"--group=cid",
"--conversation-id=cid",
"--at-open-dingtalk-ids=D1,D2,D1",
"--at-all",
)
@@ -314,13 +403,13 @@ func TestCrossPlatformCoverageChatNativeSendCardMentions(t *testing.T) {
name string
args []string
}{
{name: "member mention rejects direct message", args: []string{"--receiver=D1", "--at-open-dingtalk-ids=D2"}},
{name: "at all rejects direct message", args: []string{"--receiver=D1", "--at-all"}},
{name: "member mention rejects direct message", args: []string{"--open-dingtalk-id=D1", "--at-open-dingtalk-ids=D2"}},
{name: "at all rejects direct message", args: []string{"--open-dingtalk-id=D1", "--at-all"}},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
err := runChatCoverageCommand(t, caller, append([]string{"message", "send-card"}, tc.args...)...)
if err == nil || !strings.Contains(err.Error(), "only supported with --group") {
if err == nil || !strings.Contains(err.Error(), "only supported with --conversation-id") {
t.Fatalf("error = %v, want group-only mention validation", err)
}
if caller.calls != 0 {
@@ -330,6 +419,168 @@ func TestCrossPlatformCoverageChatNativeSendCardMentions(t *testing.T) {
}
}
func TestCrossPlatformCoverageChatSendCardHiddenAliasesMapToCanonicalPayload(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
for _, tc := range []struct {
name string
args []string
want map[string]any
}{
{
name: "group alias",
args: []string{"--group=cid"},
want: map[string]any{"openConversationId": "cid"},
},
{
name: "receiver alias",
args: []string{"--receiver=DAAAAAAAAAAAiE"},
want: map[string]any{"receiverOpenDingTalkId": "DAAAAAAAAAAAiE"},
},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
err := runChatCoverageCommand(t, caller, append([]string{"message", "send-card"}, tc.args...)...)
if err != nil {
t.Fatal(err)
}
if caller.calls != 1 || caller.server != "im" || caller.tool != "create_and_send_card" || !reflect.DeepEqual(caller.args, tc.want) {
t.Fatalf("call = count:%d server:%q tool:%q args:%#v, want %#v", caller.calls, caller.server, caller.tool, caller.args, tc.want)
}
})
}
}
func TestCrossPlatformCoverageChatGroupAuditJoinValidationUsesCanonicalAndAliasPayload(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
for _, tc := range []struct {
name string
flag string
}{
{name: "canonical conversation-id", flag: "--conversation-id=cid"},
{name: "hidden group alias", flag: "--group=cid"},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
err := runChatCoverageCommand(t, caller,
"group", "audit-join-validation",
tc.flag,
"--record-id=123",
"--applicant=D-applicant",
"--inviter=D-inviter",
"--status=AuditDelete",
"--description=deny",
)
if err != nil {
t.Fatal(err)
}
want := map[string]any{
"openConversationId": "cid",
"applyRecordId": int64(123),
"applicantUid": "D-applicant",
"inviterUid": "D-inviter",
"status": "AuditDelete",
"auditDescription": "deny",
}
if caller.calls != 1 || caller.server != "im" || caller.tool != "audit_join_group" || !reflect.DeepEqual(caller.args, want) {
t.Fatalf("call = count:%d server:%q tool:%q args:%#v, want %#v", caller.calls, caller.server, caller.tool, caller.args, want)
}
})
}
}
func TestCrossPlatformCoverageChatIMIDMigrationRequiredFlagErrors(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
tests := []struct {
name string
path []string
flag map[string]string
want string
}{
{name: "message list mutually exclusive targets", path: []string{"message", "list"}, flag: map[string]string{"conversation-id": "cid", "user": "u1", "time": "2026-01-01"}, want: "mutually exclusive"},
{name: "message list missing target", path: []string{"message", "list"}, flag: map[string]string{"time": "2026-01-01"}, want: "--conversation-id, --user or --open-dingtalk-id is required"},
{name: "topic replies missing conversation", path: []string{"message", "list-topic-replies"}, flag: map[string]string{"topic-id": "t1"}, want: "conversation-id"},
{name: "read status missing conversation", path: []string{"message", "read-status"}, flag: map[string]string{"message-id": "m1"}, want: "conversation-id"},
{name: "read status conflicting aliases", path: []string{"message", "read-status"}, flag: map[string]string{"conversation-id": "cid1", "group": "cid2", "message-id": "m1"}, want: "conflicts"},
{name: "read status missing message", path: []string{"message", "read-status"}, flag: map[string]string{"conversation-id": "cid"}, want: "message-id"},
{name: "update text emotion missing message", path: []string{"message", "update-text-emotion"}, flag: map[string]string{"conversation-id": "cid", "old-emotion-id": "e1", "emotion-id": "e2", "emotion-name": "n", "text": "t", "background-id": "b"}, want: "message-id"},
{name: "update text emotion missing detail flag", path: []string{"message", "update-text-emotion"}, flag: map[string]string{"conversation-id": "cid", "message-id": "m1", "old-emotion-id": "e1", "emotion-id": "e2", "emotion-name": "n", "text": "t"}, want: "background-id"},
{name: "transfer owner missing conversation", path: []string{"group", "transfer-owner"}, flag: map[string]string{"new-owner": "D1"}, want: "conversation-id"},
{name: "invite url missing conversation", path: []string{"group", "invite-url"}, want: "conversation-id"},
{name: "quit missing conversation", path: []string{"group", "quit"}, want: "conversation-id"},
{name: "update icon missing conversation", path: []string{"group", "update-icon"}, flag: map[string]string{"icon-media-id": "@media"}, want: "conversation-id"},
{name: "update settings missing conversation", path: []string{"group", "update-settings"}, flag: map[string]string{"setting-key": "searchable"}, want: "conversation-id"},
{name: "set admin missing conversation", path: []string{"group", "set-admin"}, flag: map[string]string{"users": "D1"}, want: "conversation-id"},
{name: "role list missing conversation", path: []string{"group-role", "list"}, want: "group"},
{name: "role add missing conversation", path: []string{"group-role", "add"}, flag: map[string]string{"name": "role"}, want: "conversation-id"},
{name: "role update missing conversation", path: []string{"group-role", "update"}, flag: map[string]string{"role-id": "r1", "name": "role"}, want: "conversation-id"},
{name: "role remove missing conversation", path: []string{"group-role", "remove"}, flag: map[string]string{"role-id": "r1"}, want: "conversation-id"},
{name: "role set user missing conversation", path: []string{"group-role", "set-user"}, flag: map[string]string{"user": "D1", "role-ids": "r1"}, want: "conversation-id"},
{name: "role remove user missing conversation", path: []string{"group-role", "remove-user"}, flag: map[string]string{"user": "D1", "role-ids": "r1"}, want: "conversation-id"},
{name: "role query user missing conversation", path: []string{"group-role", "query-user"}, flag: map[string]string{"user": "D1"}, want: "conversation-id"},
{name: "bots missing legacy group", path: []string{"group", "bots"}, want: "group"},
{name: "bots rejects migrated conversation id", path: []string{"group", "bots"}, flag: map[string]string{"conversation-id": "cid"}, want: "no such flag"},
{name: "dismiss missing conversation", path: []string{"group", "dismiss"}, flag: map[string]string{"yes": "true"}, want: "conversation-id"},
{name: "set history missing conversation", path: []string{"group", "set-history"}, flag: map[string]string{"option": "ALL"}, want: "conversation-id"},
{name: "set pin missing message", path: []string{"message", "set-pin-msg"}, flag: map[string]string{"open-conversation-id": "cid"}, want: "message-id"},
{name: "unset pin missing message", path: []string{"message", "unset-pin-msg"}, flag: map[string]string{"open-conversation-id": "cid"}, want: "message-id"},
{name: "audit join missing conversation", path: []string{"group", "audit-join-validation"}, flag: map[string]string{"record-id": "1", "applicant": "D1", "inviter": "D2", "status": "AuditApprove"}, want: "conversation-id"},
{name: "set top missing message", path: []string{"message", "set-top-msg"}, flag: map[string]string{"open-conversation-id": "cid"}, want: "message-id"},
{name: "unset top missing message", path: []string{"message", "unset-top-msg"}, flag: map[string]string{"open-conversation-id": "cid"}, want: "message-id"},
{name: "update alias missing conversation", path: []string{"group", "update-alias"}, flag: map[string]string{"alias-title": "alias"}, want: "conversation-id"},
{name: "notice create missing conversation", path: []string{"group", "notice", "create"}, flag: map[string]string{"content": "hello"}, want: "conversation-id"},
{name: "notice edit missing conversation", path: []string{"group", "notice", "edit"}, flag: map[string]string{"notice-id": "n1", "content": "hello"}, want: "conversation-id"},
{name: "notice get missing conversation", path: []string{"group", "notice", "get"}, flag: map[string]string{"notice-id": "n1"}, want: "conversation-id"},
{name: "notice list missing conversation", path: []string{"group", "notice", "list"}, want: "conversation-id"},
}
probe := newChatCommand()
messageList, _, err := probe.Find([]string{"message", "list"})
if err != nil {
t.Fatal(err)
}
if got, err := chatConversationID(messageList); err != nil || got != "" {
t.Fatalf("empty chatConversationID = %q, %v", got, err)
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := runChatCoverageDirect(t, tc.path, tc.flag)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v, want containing %q", err, tc.want)
}
})
}
}
func TestCrossPlatformCoverageChatMessageReadStatusConversationAliasesExecute(t *testing.T) {
for _, alias := range []string{"group", "id", "chat", "open-conversation-id"} {
t.Run(alias, func(t *testing.T) {
caller := &scriptedToolCaller{}
if err := runChatCoverageCommand(t, caller, "message", "read-status", "--"+alias, "cid-1", "--message-id", "msg-1"); err != nil {
t.Fatal(err)
}
if caller.server != "im" || caller.tool != "query_msg_read_status" {
t.Fatalf("call = %s/%s, want im/query_msg_read_status", caller.server, caller.tool)
}
if got := caller.args["openConversationId"]; got != "cid-1" {
t.Fatalf("openConversationId = %#v, want cid-1", got)
}
if got := caller.args["openMessageId"]; got != "msg-1" {
t.Fatalf("openMessageId = %#v, want msg-1", got)
}
})
}
}
func TestCrossPlatformCoverageChatWebhookReplyConversationAndDownloadEdges(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
@@ -343,6 +594,7 @@ func TestCrossPlatformCoverageChatWebhookReplyConversationAndDownloadEdges(t *te
_ = runChatCoverageCommand(t, &scriptedToolCaller{}, "conversation-info", "--open-dingtalk-id=D1")
_ = runChatCoverageCommand(t, &scriptedToolCaller{}, "conversation-info", "--user=D1")
_ = runChatCoverageCommand(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[{"userId":"u1","openDingTalkId":"D1"}]}`}, {text: `{}`}}}, "conversation-info", "--user=u1")
_ = runChatCoverageCommand(t, &scriptedToolCaller{}, "message", "send-card", "--open-dingtalk-id=D1")
_ = runChatCoverageCommand(t, &scriptedToolCaller{}, "message", "send-card", "--receiver=D1")
oldGet := httpGetFile
+170 -2
View File
@@ -17,8 +17,88 @@ import (
"bytes"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageChatGroupAuditJoinValidationAliasContract(t *testing.T) {
cmd := newChatCommand()
leaf, _, err := cmd.Find([]string{"group", "audit-join-validation"})
if err != nil {
t.Fatal(err)
}
canonical := leaf.Flags().Lookup("conversation-id")
if canonical == nil || canonical.Hidden {
t.Fatalf("conversation-id flag = %#v, want visible canonical", canonical)
}
legacy := leaf.Flags().Lookup("group")
if legacy == nil || !legacy.Hidden {
t.Fatalf("group flag = %#v, want hidden compatibility alias", legacy)
}
if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != "conversation-id" {
t.Fatalf("group alias_of annotation = %#v", got)
}
if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 {
t.Fatalf("group alias_origin annotation = %#v", got)
}
if got := legacy.Annotations[cobra.BashCompOneRequiredFlag]; len(got) != 0 {
t.Fatalf("hidden group alias kept required annotation: %#v", got)
}
}
func TestCrossPlatformCoverageChatGroupAuditJoinValidationRestoreRequiredNoop(t *testing.T) {
restoreChatGroupBotsLegacyRequired(nil)
restoreChatPendingMigrationCanonicalRequired(nil)
root := &cobra.Command{Use: "chat"}
root.AddCommand(&cobra.Command{Use: "other"})
restoreChatGroupBotsLegacyRequired(root)
restoreChatPendingMigrationCanonicalRequired(root)
}
func TestCrossPlatformCoverageChatGroupBotsKeepsLegacyGroupFlag(t *testing.T) {
cmd := newChatCommand()
leaf, _, err := cmd.Find([]string{"group", "bots"})
if err != nil {
t.Fatal(err)
}
group := leaf.Flags().Lookup("group")
if group == nil || group.Hidden {
t.Fatalf("group flag = %#v, want visible legacy flag", group)
}
if got := group.Annotations[cobra.BashCompOneRequiredFlag]; len(got) == 0 || got[0] != "true" {
t.Fatalf("group required annotation = %#v, want true", got)
}
if leaf.Flags().Lookup("conversation-id") != nil {
t.Fatalf("chat group bots still exposes migrated --conversation-id")
}
if leaf.Flags().Lookup("group-name") != nil {
t.Fatalf("chat group bots still exposes migrated --group-name")
}
}
func TestCrossPlatformCoverageChatPendingMigrationAliasesMatchManifest(t *testing.T) {
cmd := newChatCommand()
leaf, _, err := cmd.Find([]string{"group", "dismiss"})
if err != nil {
t.Fatal(err)
}
canonical := leaf.Flags().Lookup("conversation-id")
if canonical == nil {
t.Fatal("missing conversation-id flag")
}
if got := canonical.Annotations[cobra.BashCompOneRequiredFlag]; len(got) == 0 || got[0] != "true" {
t.Fatalf("conversation-id required annotation = %#v, want true", got)
}
legacy := leaf.Flags().Lookup("group")
if legacy == nil || !legacy.Hidden {
t.Fatalf("group flag = %#v, want hidden legacy alias", legacy)
}
if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != "conversation-id" {
t.Fatalf("group alias_of annotation = %#v", got)
}
}
func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing.T) {
tests := []struct {
name string
@@ -51,7 +131,7 @@ func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing
contains: []string{
"send -> query-send-status -> edit",
"query-send-status --open-task-id <上一步返回的openTaskId>",
"edit --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>",
"edit --conversation-id <上一步返回的openConversationId> --message-id <上一步返回的openMessageId>",
},
notContain: "chat message list",
},
@@ -61,7 +141,7 @@ func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing
contains: []string{
"send -> query-send-status -> recall",
"query-send-status --open-task-id <上一步返回的openTaskId>",
"recall --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>",
"recall --conversation-id <上一步返回的openConversationId> --message-id <上一步返回的openMessageId>",
},
notContain: "chat message list",
},
@@ -183,3 +263,91 @@ func TestCrossPlatformCoverageChatMessageHelpDocumentsOptionalTimeDefaults(t *te
})
}
}
func TestCrossPlatformCoverageChatReactionHelpKeepsManifestExternalAliasesVisible(t *testing.T) {
for _, command := range []string{"add-emoji", "remove-emoji", "add-text-emotion", "remove-text-emotion"} {
t.Run(command, func(t *testing.T) {
cmd := newChatCommand()
var output bytes.Buffer
cmd.SetOut(&output)
cmd.SetErr(&output)
cmd.SetArgs([]string{"message", command, "--help"})
if err := cmd.Execute(); err != nil {
t.Fatalf("chat message %s --help: %v\n%s", command, err, output.String())
}
help := output.String()
if !strings.Contains(help, "--conversation-id") {
t.Fatalf("chat message %s help missing --conversation-id:\n%s", command, help)
}
for _, visible := range []string{"--group", "--id", "--chat"} {
if !strings.Contains(help, visible+" string") {
t.Fatalf("chat message %s help hides manifest-external alias %s:\n%s", command, visible, help)
}
}
})
}
}
func TestCrossPlatformCoverageChatGroupBotsHelpKeepsLegacyGroup(t *testing.T) {
cmd := newChatCommand()
var output bytes.Buffer
cmd.SetOut(&output)
cmd.SetErr(&output)
cmd.SetArgs([]string{"group", "bots", "--help"})
if err := cmd.Execute(); err != nil {
t.Fatalf("chat group bots --help: %v\n%s", err, output.String())
}
help := output.String()
if !strings.Contains(help, "--group string") {
t.Fatalf("chat group bots help missing visible --group:\n%s", help)
}
for _, hidden := range []string{"--conversation-id", "--group-name"} {
if strings.Contains(help, hidden) {
t.Fatalf("chat group bots help exposes migrated flag %s:\n%s", hidden, help)
}
}
}
func TestCrossPlatformCoverageChatSendCardHelpUsesCanonicalIDFlags(t *testing.T) {
cmd := newChatCommand()
var output bytes.Buffer
cmd.SetOut(&output)
cmd.SetErr(&output)
cmd.SetArgs([]string{"message", "send-card", "--help"})
if err := cmd.Execute(); err != nil {
t.Fatalf("chat message send-card --help: %v\n%s", err, output.String())
}
help := output.String()
for _, visible := range []string{"--conversation-id", "--open-dingtalk-id"} {
if !strings.Contains(help, visible) {
t.Fatalf("send-card help missing %s:\n%s", visible, help)
}
}
for _, visible := range []string{"--group", "--receiver"} {
if !strings.Contains(help, visible+" string") {
t.Fatalf("send-card help hides manifest-external alias %s:\n%s", visible, help)
}
}
}
func TestCrossPlatformCoverageChatGroupAuditJoinValidationHelpUsesCanonicalConversationID(t *testing.T) {
cmd := newChatCommand()
var output bytes.Buffer
cmd.SetOut(&output)
cmd.SetErr(&output)
cmd.SetArgs([]string{"group", "audit-join-validation", "--help"})
if err := cmd.Execute(); err != nil {
t.Fatalf("chat group audit-join-validation --help: %v\n%s", err, output.String())
}
help := output.String()
if !strings.Contains(help, "--conversation-id") {
t.Fatalf("audit-join-validation help missing --conversation-id:\n%s", help)
}
if strings.Contains(help, "--group string") {
t.Fatalf("audit-join-validation help exposes hidden --group alias:\n%s", help)
}
}
+67 -19
View File
@@ -81,6 +81,32 @@ func TestCrossPlatformCoverageChatUpdateTextEmotion(t *testing.T) {
},
},
},
{
name: "open-conversation-id alias",
args: []string{
"message", "update-text-emotion",
"--open-conversation-id", "conv-3",
"--message-id", "msg-3",
"--old-emotion-id", "old-3",
"--emotion-id", "new-3",
"--emotion-name", "smile",
"--text", "done",
"--background-id", "im_bg_2",
},
want: guardedMutationCall{
productID: "im",
toolName: "update_text_emotion",
args: map[string]any{
"openConversationId": "conv-3",
"openMsgId": "msg-3",
"oldEmotionId": "old-3",
"emotionId": "new-3",
"emotionName": "smile",
"text": "done",
"backgroundId": "im_bg_2",
},
},
},
}
for _, test := range tests {
test := test
@@ -127,7 +153,7 @@ func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) {
{
name: "missing conversation-id and aliases",
args: dropFlag("--conversation-id"),
wantErr: "at least one of the flags in the group [conversation-id group id chat] is required",
wantErr: "missing required flag: --conversation-id (or --group / --id / --chat / --open-conversation-id)",
},
{
name: "missing old-emotion-id",
@@ -135,7 +161,7 @@ func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) {
wantErr: `required flag(s) "old-emotion-id" not set`,
},
{
name: "missing msg-id and background-id",
name: "missing message-id and background-id",
args: []string{
"message", "update-text-emotion",
"--conversation-id", "conv-1",
@@ -144,7 +170,7 @@ func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) {
"--emotion-name", "like",
"--text", "nice",
},
wantErr: `required flag(s) "background-id", "msg-id" not set`,
wantErr: "missing required flag: --message-id (or --msg-id / --open-message-id)",
},
}
for _, test := range tests {
@@ -163,19 +189,41 @@ func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) {
}
func TestCrossPlatformCoverageChatGroupGetMuteConfig(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand,
"group", "get-mute-config", "--group", "conv-1")
if err != nil {
t.Fatalf("get-mute-config returned error: %v", err)
}
want := guardedMutationCall{
productID: "im",
toolName: "get_group_mute_config",
args: map[string]any{"openConversationId": "conv-1"},
}
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
t.Fatalf("tool calls = %#v, want %#v", caller.calls, want)
for _, test := range []struct {
name string
args []string
want guardedMutationCall
}{
{
name: "legacy group alias",
args: []string{"group", "get-mute-config", "--group", "conv-1"},
want: guardedMutationCall{
productID: "im",
toolName: "get_group_mute_config",
args: map[string]any{"openConversationId": "conv-1"},
},
},
{
name: "canonical conversation id",
args: []string{"group", "get-mute-config", "--conversation-id", "conv-2"},
want: guardedMutationCall{
productID: "im",
toolName: "get_group_mute_config",
args: map[string]any{"openConversationId": "conv-2"},
},
},
} {
test := test
t.Run(test.name, func(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand, test.args...)
if err != nil {
t.Fatalf("get-mute-config returned error: %v", err)
}
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], test.want) {
t.Fatalf("tool calls = %#v, want %#v", caller.calls, test.want)
}
})
}
}
@@ -196,12 +244,12 @@ func TestCrossPlatformCoverageChatGroupGetMuteConfigRecordsRawArgs(t *testing.T)
}
}
func TestCrossPlatformCoverageChatGroupGetMuteConfigRequiresGroup(t *testing.T) {
func TestCrossPlatformCoverageChatGroupGetMuteConfigRequiresConversationID(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand,
"group", "get-mute-config")
if err == nil || !strings.Contains(err.Error(), "--group") {
t.Fatalf("err = %v, want message containing --group", err)
if err == nil || !strings.Contains(err.Error(), "--conversation-id") {
t.Fatalf("err = %v, want message containing --conversation-id", err)
}
if len(caller.calls) != 0 {
t.Fatalf("tool calls = %#v, want none", caller.calls)
+274 -1
View File
@@ -1226,7 +1226,7 @@ func newDriveCommand() *cobra.Command {
driveListCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (--versions 模式下必填)")
driveListCmd.Flags().String("pattern", "", "按名称通配过滤结果,如 \"*日报*\" (客户端过滤) (可选)")
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().Int("latest", 0, "按修改时间取最新 N 个文件(1~50);与 --pattern 组合时表示名称匹配的文件中最新 N 个;可与 --workspace/--depth 组合;与 --order-by/--order/--limit/--cursor 互斥;扫描触发 2000 条上限或途中目录读取失败时报错,不产出不完整的 Top-N (可选)")
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)(可选)")
@@ -3299,6 +3299,274 @@ 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 <dentryUuid>
dws drive status --local-folder /abs/path/repo --remote-folder <dentryUuid> --space-id xxxx
dws drive status --local-folder /abs/path/repo --remote-folder <dentryUuid> --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 处理:
skip 默认,安全:本地已存在则保持不动,只新增
smart 推荐增量同步:本地 modified_time 已 ≥ 远端时则跳过下载
overwrite 总是下载覆盖(Drive 作为权威源)
该命令会写入本地文件系统,执行前需要用户确认;非交互环境先用 --dry-run
预览,确认后以相同参数追加 --yes 执行。
输出 summary(downloaded/skipped/failed)与逐文件 items。
若有文件下载失败,命令以非零退出码退出,结构化结果仍在 stdout。`,
Example: ` dws drive pull --local-folder /abs/path/repo --remote-folder <dentryUuid>
dws drive pull --local-folder /abs/path/repo --remote-folder <dentryUuid> --if-exists smart
dws drive pull --local-folder /abs/path/repo --remote-folder <dentryUuid> --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", "skip", "本地文件已存在时的策略: skip|smart|overwrite;命令会写本地,执行需确认 (可选)")
drivePushCmd := &cobra.Command{
Use: "push",
Short: "把本地文件夹单向镜像到钉盘(本地 → Drive)",
Long: `递归把本地 --local-folder 下的文件与子目录(含空目录)镜像到钉盘
--remote-folder 文件夹:缺失的目录按需创建(已存在则复用,不重建),文件按
--if-exists 处理。文件级镜像——只新增/覆盖,不删除远端多余文件。
已存在的远端文件按 --if-exists 处理:
skip 默认,安全:已存在则保持不动,只新增
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 <dentryUuid>
dws drive push --local-folder /abs/path/repo --remote-folder <dentryUuid> --if-exists smart
dws drive push --local-folder /abs/path/repo --remote-folder <dentryUuid> --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 策略:
skip 默认,两侧都不动并保留两边内容
remote-wins 拉取远端覆盖本地
local-wins 上传本地覆盖远端
keep-both 本地文件改名保留,再拉取远端到原路径
ask 交互式逐个询问
exact 模式下远端无可靠 MD5、内容无法核对的文件归入 unknown 并跳过(可改用 --quick)。
文件级同步——只新增/覆盖,不删除任何一侧的多余文件。输出 summary(pulled/pushed/
skipped/failed)、diff 与逐条 items;有失败则以非零退出码退出,结构化结果仍在 stdout。
该命令会同时写入本地与钉盘,执行前需要用户确认;非交互环境先用 --dry-run
预览,确认后以相同参数追加 --yes 执行。`,
Example: ` dws drive sync --local-folder /abs/path/repo --remote-folder <dentryUuid>
dws drive sync --local-folder /abs/path/repo --remote-folder <dentryUuid> --on-conflict local-wins
dws drive sync --local-folder /abs/path/repo --remote-folder <dentryUuid> --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", "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: "比较本地文件夹与钉盘文件夹的差异,只读不落盘。",
Result: driveFolderStatusResultSpec(),
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 <dentryUuid>",
"dws drive status --local-folder /abs/path/repo --remote-folder <dentryUuid> --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: "把钉盘文件夹单向镜像到本地;写操作需确认,默认跳过本地既有文件。",
DryRun: &contract.DryRunSpec{
PreviewKind: contract.DryRunPreviewPlan,
RemoteReads: true,
},
Result: driveFolderPullResultSpec(),
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 <dentryUuid>",
"dws drive pull --local-folder /abs/path/repo --remote-folder <dentryUuid> --if-exists smart --dry-run",
},
ExampleDispositions: driveFolderStatefulExampleDispositions(),
},
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: "把本地文件夹单向镜像到钉盘;写操作需确认,默认跳过远端既有文件。",
DryRun: &contract.DryRunSpec{
PreviewKind: contract.DryRunPreviewPlan,
RemoteReads: true,
},
Result: driveFolderPushResultSpec(),
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 <dentryUuid>",
"dws drive push --local-folder /abs/path/repo --remote-folder <dentryUuid> --if-exists smart --dry-run",
},
ExampleDispositions: driveFolderStatefulExampleDispositions(),
},
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: "本地与钉盘文件夹双向同步;写操作需确认,默认跳过双端冲突。",
DryRun: &contract.DryRunSpec{
PreviewKind: contract.DryRunPreviewPlan,
RemoteReads: true,
},
Result: driveFolderSyncResultSpec(),
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 <dentryUuid>",
"dws drive sync --local-folder /abs/path/repo --remote-folder <dentryUuid> --on-conflict remote-wins --dry-run",
},
ExampleDispositions: driveFolderStatefulExampleDispositions(),
},
Parameters: []contract.ParamDecl{
{Name: "local-folder", Required: boolPtr(true)},
{Name: "remote-folder", Required: boolPtr(true)},
},
},
})
driveCmd.AddCommand(
driveListCmd,
driveListSpacesCmd,
@@ -3322,6 +3590,11 @@ func newDriveCommand() *cobra.Command {
drivePermissionCmd,
drivePublishCmd,
recycleCmd,
// 同步命令:status / pull / push / sync
driveStatusCmd,
drivePullCmd,
drivePushCmd,
driveSyncCmd,
driveStarCmd,
driveCoverCmd,
driveRevertCmd,
+211 -6
View File
@@ -10,11 +10,14 @@ import (
"os/signal"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
)
// ──────────────────────────────────────────────────────────
@@ -296,6 +299,11 @@ bfs:
if folderErr != nil {
if driveDepthUnrecoverable(folderErr) {
if latest > 0 {
// 不完整集合上的 Top-N 会被误读为全局最新:latest 下不吐 partial,
// 直接回根因错误(auth 过期 / 网络不可达比通用 token 更可操作)。
return folderErr
}
// partial 照吐 stdout,错误详情走 stderr,非零退出
errs = append(errs, newDriveDepthError(folder, folderErr))
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth, route, filter); emitErr != nil {
@@ -331,17 +339,206 @@ bfs:
}
}
if truncated && latest > 0 {
return &CLIError{
Code: CodeContentTruncated,
Message: fmt.Sprintf("LATEST_SCAN_TRUNCATED: 扫描在全局上限 %d 条处截断,未扫描区域可能含更新文件,拒绝输出不完整的 Top-%d", driveDepthMaxItems, latest),
Suggestion: fmt.Sprintf("缩小扫描范围后重试:--folder 指定子目录,或降低 --depth 层数,如 dws drive list --folder <子目录ID> --latest %d", latest),
}
// BFS 序与修改时间无关:截断与递归途中目录失败都让未扫区域可能含更新文件,
// 此时的 Top-N 不是全局最新,两者同属一条防线——拒绝以成功状态产出。
if latest > 0 && (truncated || len(errs) > 0) {
return driveLatestIncompleteError(latest, truncated, errs, driveLatestScopeFromCmd(cmd, maxDepth, rootFolderID))
}
return emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth, route, filter)
}
// driveLatestScope 是原调用的完整候选集快照,用于生成不改变候选集的恢复命令。
//
// 恢复命令若丢掉任何一项,用户照抄后都会在**另一个集合**上拿到一份「看起来对」的 Top-N ——
// 比直接报错更难发现:丢 --workspace/--space-id 会从知识库切到普通钉盘(或反之);丢 --folder
// 会从子树跳到空间根;丢 --pattern/--type/--start/--end 会把全部条目纳入排序基。
type driveLatestScope struct {
// domain 是查询域 flag 串(如 "--workspace ws-1" / "--space-id sp-1"),无则空串。
domain string
// filters 是决定候选集的过滤 flag 串(--pattern/--type/--start/--end),无则空串。
filters string
// folder 是原调用实际使用的扫描根(已解析的 ID,非用户原始 URL),空则为空间根。
folder string
// depth 是原调用的 --depth 层数,让「去掉 --latest 重跑」的恢复命令给出确切层数。
depth int
// notes 收集无法安全内联进可执行命令的原值展示行。POSIX 构建下恒为空(单引号足够);
// Windows 构建下含元字符的值走这里,命令里只留占位符。
notes []string
// 以上 domain/filters/folder 里的值全部经 driveLatestScopeValue 渲染:恢复命令是给用户
// 直接复制到 shell 执行的,而 workspace 的常见形态就是带 & 查询串的 URL,pattern 又天然
// 含 * 与中文,裸拼接会改变命令解析。
}
// driveLatestValueRenderer 把用户值渲染成可内联的命令片段;ok 为 false 表示该值在目标 shell
// 下无法安全内联。取成参数而非直接调用平台绑定函数,是为了让任一平台的测试都能驱动另一平台
// 的降级分支 —— 否则「Windows 上降级为占位符」这条路在 POSIX 机器上永不可达、无法验证。
type driveLatestValueRenderer func(string) (string, bool)
// value 渲染单个用户值供内联。值在目标 shell 下无法安全内联时,登记一条展示行并返回占位符
// —— 宁可让用户手动粘一次,也不能给出一条粘贴即执行额外命令的「恢复命令」。
// 展示行用 strconv.Quote 包裹并显式声明非可执行,与 internal/auth 侧展示 profile 标识一致。
func (s *driveLatestScope) value(render driveLatestValueRenderer, label, v string) string {
if inline, ok := render(v); ok {
return inline
}
s.notes = append(s.notes, fmt.Sprintf("%s 原值(仅作数据展示,不是可执行命令)%s", label, strconv.Quote(v)))
return driveLatestUnsafeValuePlaceholder
}
// driveLatestUnsafeValuePlaceholder 是不可内联值在命令中的占位符。
const driveLatestUnsafeValuePlaceholder = "<见下方原值>"
// driveLatestScopeFromCmd 从原命令抽完整候选集。--workspace 决定路由(知识库 vs 钉盘),判定与
// drive list 里的路由分支同源(同一个 flagOrFallback(cmd, "workspace", "workspace-id"));
// 钉盘侧的 --space-id 同样必须保留。目录 flag 名无需按路由切换:--folder 两条路由都接受。
//
// rootFolder 取 runDriveListDepth 实际使用的扫描根而非重新读 flag:用户可能传的是 URL,
// 解析后的 ID 才是真正被扫描的目标,也是照抄时更精确的形态。
func driveLatestScopeFromCmd(cmd *cobra.Command, depth int, rootFolder string) driveLatestScope {
return driveLatestScopeFrom(cmd, depth, rootFolder, driveLatestScopeValue)
}
// driveLatestScopeFrom 是 driveLatestScopeFromCmd 的可注入本体:render 决定用户值以何种形态
// 进入恢复命令。生产路径固定传平台绑定的 driveLatestScopeValue;测试可传另一平台的策略,
// 从而在单一平台上覆盖两种形态。
func driveLatestScopeFrom(cmd *cobra.Command, depth int, rootFolder string, render driveLatestValueRenderer) driveLatestScope {
scope := driveLatestScope{depth: depth}
if workspaceID := flagOrFallback(cmd, "workspace", "workspace-id"); workspaceID != "" {
scope.domain = "--workspace " + scope.value(render, "--workspace", workspaceID)
} else if spaceID, _ := cmd.Flags().GetString("space-id"); spaceID != "" {
scope.domain = "--space-id " + scope.value(render, "--space-id", spaceID)
}
if rootFolder != "" {
scope.folder = scope.value(render, "--folder", rootFolder)
}
// 顺序固定为注册顺序,保证同一组入参每次给出同一条恢复命令(便于用户比对与测试断言)。
filters := make([]string, 0, 4)
for _, name := range []string{"pattern", "type", "start", "end"} {
if v, _ := cmd.Flags().GetString(name); v != "" {
filters = append(filters, "--"+name+" "+scope.value(render, "--"+name, v))
}
}
scope.filters = strings.Join(filters, " ")
return scope
}
// command 拼一条保留原候选集的恢复命令:查询域 + 指定的 --folder + 原过滤条件。
// folderArg 传 "" 表示该条命令不带 --folder(原调用就在空间根时不应凭空塞一个)。
func (s driveLatestScope) command(folderArg string) string {
parts := make([]string, 0, 4)
parts = append(parts, "dws drive list")
if s.domain != "" {
parts = append(parts, s.domain)
}
if folderArg != "" {
parts = append(parts, "--folder "+folderArg)
}
if s.filters != "" {
parts = append(parts, s.filters)
}
return strings.Join(parts, " ")
}
// driveLatestIncompleteError 是排序基不完整时的拒绝产出错误。截断与目录失败共用
// CodeContentTruncated(→ ExitAPI),但 token 分开,便于消费方区分「范围太大」与「读不到」;
// 二者同真时两个 token 都带。拒绝产出后 errors[] 不再进 stdout,失败详情必须落在错误消息里,
// 否则用户完全瞎。调用点已保证 truncated 与 len(errs)>0 至少一真。
func driveLatestIncompleteError(latest int, truncated bool, errs []driveDepthError, scope driveLatestScope) error {
// 目录失败详情排在截断之前:BFS 可以先记下可恢复目录错误、再在别的目录撞上 2000 上限,
// 此时 permission_denied 这类 reason 是用户唯一能动手修的线索,不能被截断提示吞掉。
causes := make([]string, 0, 2)
if len(errs) > 0 {
causes = append(causes, driveLatestFolderFailureCause(errs))
}
if truncated {
causes = append(causes, fmt.Sprintf("LATEST_SCAN_TRUNCATED: 扫描在全局上限 %d 条处截断", driveDepthMaxItems))
}
return &CLIError{
Code: CodeContentTruncated,
Message: fmt.Sprintf("%s,未扫描区域可能含更新文件,拒绝输出不完整的 Top-%d",
strings.Join(causes, ";同时 "), latest),
Suggestion: driveLatestIncompleteSuggestion(latest, truncated, len(errs) > 0, scope),
}
}
// driveLatestFolderFailureCause 组装目录失败详情,含首个失败的 folder/depth/reason。
// folderName 空回落 folderID,两者都空回落 <root>。
//
// folderName / folderID / message 三项都是远端可控内容(目录名由共享目录的创建者决定,
// message 是服务端错误文本),必须过 driveLatestSafeRemoteText。Reason 不用过:它是
// classifyDriveDepthReason 的固定三值映射,与服务端字符串无关。
func driveLatestFolderFailureCause(errs []driveDepthError) string {
first := errs[0]
folder := first.FolderName
if folder == "" {
folder = first.FolderID
}
if folder == "" {
folder = "<root>"
}
return fmt.Sprintf("LATEST_SCAN_INCOMPLETE: %d 个目录未读全(首个失败 folder=%s depth=%d reason=%s: %s)",
len(errs), driveLatestSafeRemoteText(folder), first.Depth, first.Reason,
driveLatestSafeRemoteText(first.Message))
}
// driveLatestSafeRemoteText 把远端可控文本压成可安全嵌进单行 stderr 错误消息的形式。
//
// 拒绝产出后 errors[] 不再进 stdout,失败详情改走纯文本错误消息 —— 而 JSON 编码会转义的
// 控制字符在纯文本里会被终端直接执行:ANSI/OSC 序列可以伪造提示、清屏、隐藏后续内容、改窗口
// 标题,在 AI Agent 场景还会污染上下文窗口。latest=0 的既有路径仍把原值放进 errors[] JSON,
// 不受影响,也不该受影响(消费方需要原始数据)。
//
// output.SanitizeForTerminal 负责剥 ANSI/OSC、C0 控制字符与危险 Unicode,但按设计保留 \n 与
// \t;本错误消息是单行叙述,故再把这两者折成空格,避免远端换行把一条错误拆成多行伪造输出。
func driveLatestSafeRemoteText(s string) string {
s = output.SanitizeForTerminal(s)
s = strings.ReplaceAll(s, "\n", " ")
s = strings.ReplaceAll(s, "\t", " ")
return strings.TrimSpace(s)
}
// driveLatestIncompleteSuggestion 按实际触发的成因给恢复指引。约束两条:
// 1. 每条示例命令都带原查询域(scope.base()),照抄不会切换查询域;
// 2. 每个子句的示例命令与该子句正文一致——「去掉 --latest」的子句示例不带 --latest,
// 否则照抄复现同一错误。
func driveLatestIncompleteSuggestion(latest int, truncated, folderFailed bool, scope driveLatestScope) string {
// 「缩小范围」类命令要求用户换一个目录,故 --folder 给占位符;查询域与原过滤条件由
// command 一并带上,否则照抄后候选集就变了(例如丢掉 --pattern 会对全部条目取 Top-N)。
narrowed := scope.command("<可读子目录ID>")
clauses := make([]string, 0, 3)
if folderFailed {
clauses = append(clauses, "确认目录权限后重试")
}
switch {
case folderFailed && truncated:
// 两个成因都要解:既要换到可读目录,也要把范围缩到 2000 条以内。
clauses = append(clauses, fmt.Sprintf("或用 --folder 缩小到可读子目录、并降低 --depth 层数后重取 Top-%d:%s --latest %d", latest, narrowed, latest))
case folderFailed:
clauses = append(clauses, fmt.Sprintf("或用 --folder 缩小到可读子目录后重取 Top-%d:%s --latest %d", latest, narrowed, latest))
default:
clauses = append(clauses, fmt.Sprintf("缩小扫描范围后重试:--folder 指定子目录,或降低 --depth 层数,如 %s --latest %d", narrowed, latest))
}
// partial+errors[] 承诺限定 --depth>1:单层去掉 --latest 会路由回普通单层 list,本就无
// errors[] 契约,故该子句只在多层时给出,并直接带上原层数。这是唯一一条「按原范围」命令,
// 必须原样带回原 --folder(原调用在空间根时则不带),照抄即复现同一候选集、只是不取 Top-N。
if folderFailed && scope.depth > 1 {
clauses = append(clauses, fmt.Sprintf("需要看失败明细请去掉 --latest 按原范围重跑(同时输出已扫到的 partial 与 errors[] 明细):%s --depth %d", scope.command(scope.folder), scope.depth))
}
// Windows 构建下无法安全引用的值不会进入命令(cmd.exe 不把单引号当引号),原值改在此处以
// 数据行给出,由用户手动替换占位符 —— 少一次复制粘贴的便利,换掉一条粘贴即执行的命令。
if len(scope.notes) > 0 {
clauses = append(clauses, fmt.Sprintf("命令中的 %s 请手动替换为 —— %s",
driveLatestUnsafeValuePlaceholder, strings.Join(scope.notes, "、")))
}
return strings.Join(clauses, ";")
}
// emitDriveDepthCancelled 处理 SIGINT 取消:吐已扫到的 partial(truncated=true)后回退出码 130。
//
// 这里刻意**不**套用 latest 的拒绝产出防线(只有 BFS 尾部 guard 与 unrecoverable 分支走):
// 取消是用户主动发起的,退出码 130 本身已明确告知结果不完整,此时 partial 是用户的预期产物而
// 非冒充全局最新的误导。sortTime 仍由 emitDriveDepthResult 统一剥除,取消路径不例外。
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
@@ -389,6 +586,14 @@ func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, trunca
maxDepth = d
}
}
// sortTime 是内部排序字段(applyDriveListLatest 排 Top-N、applyDriveListFilter 筛时间区间
// 时都已用完),任何输出路径都不得泄露进契约。放在这里一处覆盖三条 emit 路径:正常 emit、
// SIGINT 取消、unrecoverable partial。
// depth/parentId/rel_path 不在此处删——它们是 depth>1 的既有输出契约,
// stripDriveDepthDecorations 仅在单层(reqDepth==1)把这套装饰整体剥掉。
for _, item := range items {
delete(item, "sortTime")
}
if (latest > 0 || filter.active()) && reqDepth == 1 {
stripDriveDepthDecorations(items)
}
+146
View File
@@ -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
}`),
}
}
@@ -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)
}
}
@@ -0,0 +1,731 @@
package helpers
import (
"errors"
"fmt"
"strconv"
"strings"
"testing"
"github.com/spf13/cobra"
)
// 本文件锁定 drive list --latest 的两个 P1 行为,独立于 pr868_*_test.go / drive_depth_test.go:
//
// P1-a:sortTime 是内部排序字段,任何输出路径都不得泄露进契约;
// P1-b:递归途中目录读取失败时,Top-N 建立在不完整集合上,必须拒绝产出而非吐 partial。
//
// 改代码前这些断言对 origin/main 应为红:main 采集端无条件写 sortTime、emit 仅在单层
// latest/filter 才剥;main 尾部拒绝 guard 只拦截断、不拦目录失败。
//
// 另锁定评审反馈的三个边界:
//
// 截断与目录失败同真时,失败详情(permission_denied 等)不得被截断提示吞掉;
// 拒绝产出时给的恢复命令必须保留原查询域(--workspace / --space-id),照抄不会切换查询域;
// 恢复命令里的用户值必须过 argv 引用,URL 查询串与 shell 元字符都不能改变命令解析。
//
// 关于 TestCrossPlatformCoverage 前缀:这是门禁约定,不是命名风格。平台覆盖率门禁
// scripts/policy/run-platform-coverage-gate.sh 只跑
// -run '^(TestAllShortcuts|TestCrossPlatformCoverage)',本包新增的生产代码若没有带该前缀的
// 测试覆盖,Coverage (macOS) / (Windows) 会以「changed code coverage 低于 100%」失败
// (本 PR 改名前实测 69.0476%,84 条可执行语句)。改名务必保留前缀。
// assertNoSortTime 断言 stdout 每个 item 都不含内部排序字段 sortTime。
func assertNoSortTime(t *testing.T, result map[string]any) {
t.Helper()
items, ok := result["items"].([]any)
if !ok {
t.Fatalf("items missing or wrong type: %#v", result["items"])
}
for i, raw := range items {
item, ok := raw.(map[string]any)
if !ok {
t.Fatalf("item[%d] not an object: %#v", i, raw)
}
if _, leaked := item["sortTime"]; leaked {
t.Fatalf("item[%d] leaked internal sortTime into output contract: %#v", i, item)
}
}
}
// TestCrossPlatformCoverageDriveLatestNoSortTimeLeak 覆盖 main 的覆盖漏洞:main 的
// TestCrossPlatformCoverageDriveDepthLatestTruncatedAndSortTime 名字带 SortTime,
// 却只断言 TRUNCATED、从不检查输出无 sortTime。这里把三条泄露路径都钉住。
func TestCrossPlatformCoverageDriveLatestNoSortTimeLeak(t *testing.T) {
twoFiles := `{"items":[{"fileId":"f1","name":"a.txt","type":"FILE","modifiedTime":1000},{"fileId":"f2","name":"b.txt","type":"FILE","modifiedTime":2000}]}`
// 场景 A:depth>1 --latest —— 走 applyDriveListLatest(只读 sortTime、不剥),reqDepth>1 不触发 strip。
t.Run("depth_latest", func(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: twoFiles}}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 5, driveListFilter{}); err != nil {
t.Fatalf("runDriveListDepth: %v", err)
}
assertNoSortTime(t, decodeDepthResult(t, out))
})
// 场景 B:--depth 2 无 latest 无 filter —— 走 else 分支树序排序,同样不触发 strip。
t.Run("depth_no_latest", func(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: twoFiles}}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0, driveListFilter{}); err != nil {
t.Fatalf("runDriveListDepth: %v", err)
}
assertNoSortTime(t, decodeDepthResult(t, out))
})
// 场景 C:--depth 2 --type file —— #971 引入的 filter 也读 sortTime(applyDriveListFilter),
// strip 条件 (latest>0||filter.active()) && reqDepth==1 在多层下依旧不成立,泄露面因此变大。
t.Run("depth_filter_no_latest", func(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: twoFiles}}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0, driveListFilter{nodeType: "file"}); err != nil {
t.Fatalf("runDriveListDepth: %v", err)
}
result := decodeDepthResult(t, out)
if len(result["items"].([]any)) != 2 {
t.Fatalf("filter 应保留两个 FILE: %#v", result["items"])
}
assertNoSortTime(t, result)
})
}
// TestCrossPlatformCoverageDriveLatestSigintStillEmitsPartialWithoutSortTime 同时承担两件事:
// 1. P1-a 的第四条路径 —— SIGINT 取消也走 emitDriveDepthResult,同样不得泄露 sortTime;
// 2. SIGINT 契约锁 —— 本次 P1-b 只在 BFS 尾部 guard 与 unrecoverable 分支生效,**不改**
// 取消路径:SIGINT 是用户主动中断、退出码 130 已明确告知不完整,partial 是明确预期。
// 这条测试防止后续误把 fail-closed 扩到取消路径,也为外部评测用例
// test_sigint_exits_130_with_partial 提供本地对照。
func TestCrossPlatformCoverageDriveLatestSigintStillEmitsPartialWithoutSortTime(t *testing.T) {
caller := &scriptedToolCaller{}
out := installDepthCaller(t, caller)
items := []map[string]any{
{"fileId": "f1", "name": "a.txt", "type": "FILE", "sortTime": int64(2000), "rel_path": "a.txt", "depth": 2},
}
errs := []driveDepthError{
{Depth: 1, FolderID: "fid-1", FolderName: "半路目录", Reason: "permission_denied", Message: "denied"},
}
// latest>0 且 reqDepth>1:main 在此不 strip,sortTime 泄露。
err := emitDriveDepthCancelled(items, errs, "", 5, 3, newDrivePanDepthRoute(), driveListFilter{})
var cancelErr *driveDepthCancelledError
if !errors.As(err, &cancelErr) {
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
}
if cancelErr.ExitCode() != 130 {
t.Fatalf("exit code = %d, want 130", cancelErr.ExitCode())
}
result := decodeDepthResult(t, out)
// partial 契约不变:items 与 errors 都照吐,truncated 标记为真。
if len(result["items"].([]any)) != 1 {
t.Fatalf("取消路径应保留 partial items: %#v", result["items"])
}
if len(result["errors"].([]any)) != 1 {
t.Fatalf("取消路径应保留 errors[]: %#v", result["errors"])
}
if result["truncated"] != true {
t.Fatalf("取消结果 truncated = %#v, want true", result["truncated"])
}
assertNoSortTime(t, result)
}
// TestCrossPlatformCoverageDriveLatestRefusesOnFolderFailure 覆盖 P1-b:递归途中一个可恢复目录失败(403/business,
// 非 auth 非限流 → 记 errs[] 跳过),Top-N 落在不完整集合上,必须拒绝产出。
// 构造:根目录成功产出 FOLDER+FILE(collected>0 且 dirA 入队),子目录返回 forbidden.* →
// recoverable → errs=[1]。旧代码尾部 `if truncated && latest>0` 不触发 → emit 吐 partial(err=nil);
// 新代码 `len(errs)>0` → LATEST_SCAN_INCOMPLETE 且 stdout 无 items。
func TestCrossPlatformCoverageDriveLatestRefusesOnFolderFailure(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"dirA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE","modifiedTime":1000}]}`},
{text: `{"errorCode":"forbidden.noPermission","errorMsg":"denied"}`},
}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 5, driveListFilter{})
if err == nil || !strings.Contains(err.Error(), "LATEST_SCAN_INCOMPLETE") {
t.Fatalf("err = %v, want LATEST_SCAN_INCOMPLETE", err)
}
// 拒绝产出:stdout 必须没有 items(不是 partial)。旧代码此处会吐 partial,断言随之失败。
if out.Len() != 0 {
t.Fatalf("expected no stdout on refusal, got: %s", out.String())
}
}
// TestCrossPlatformCoverageDriveLatestRefusesOnTruncationWithFolderFailure 端到端证明「截断 + 目录失败」组合确实可达:
// 根目录出 dirA/dirB 两个子目录 → dirA 权限失败记 errs[] → dirB 返回 2000 条触发全局截断,
// 尾部 guard 拿到 truncated=true 且 len(errs)=1。旧实现在此让 truncated 短路,permission_denied
// 详情整块丢失(评审反馈的阻塞点);现在两个 token 与失败详情都必须在。
func TestCrossPlatformCoverageDriveLatestRefusesOnTruncationWithFolderFailure(t *testing.T) {
useDriveDepthArgs(t)
var bulk strings.Builder
bulk.WriteString(`{"items":[`)
for i := 0; i < driveDepthMaxItems; i++ {
if i > 0 {
bulk.WriteString(",")
}
fmt.Fprintf(&bulk, `{"fileId":"f%d","name":"file-%d.txt","type":"FILE","modifiedTime":%d}`, i, i, 1000+i)
}
bulk.WriteString(`]}`)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"dirA","name":"报表","type":"FOLDER"},{"fileId":"dirB","name":"dirB","type":"FOLDER"}]}`},
{text: `{"errorCode":"forbidden.noPermission","errorMsg":"denied"}`}, // dirA:可恢复 → 记 errs[]
{text: bulk.String()}, // dirB:撞 2000 上限 → truncated
}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 5, driveListFilter{})
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated {
t.Fatalf("err = %T %v, want CodeContentTruncated", err, err)
}
msg := cliErr.Message
if !strings.Contains(msg, "LATEST_SCAN_TRUNCATED") {
t.Fatalf("组合场景缺 TRUNCATED token: %q", msg)
}
// 旧实现在这里丢掉整段目录失败详情。
if !strings.Contains(msg, "LATEST_SCAN_INCOMPLETE") ||
!strings.Contains(msg, "folder=报表") ||
!strings.Contains(msg, "permission_denied") {
t.Fatalf("组合场景丢失目录失败详情: %q", msg)
}
if out.Len() != 0 {
t.Fatalf("expected no stdout on refusal, got: %s", out.String())
}
}
// TestCrossPlatformCoverageDriveLatestScopeWiredFromCommand 端到端验证查询域从原命令一路带到恢复命令:单测构造器
// 拿不到的是 runDriveListDepth 里的接线(driveLatestScopeFromCmd(cmd, maxDepth, rootFolderID)),这里补上。
func TestCrossPlatformCoverageDriveLatestScopeWiredFromCommand(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"dirA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE","modifiedTime":1000}]}`},
{text: `{"errorCode":"forbidden.noPermission","errorMsg":"denied"}`},
}}
installDepthCaller(t, caller)
cmd := newDriveListScopeCmd(t, map[string]string{"space-id": "sp-7"})
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{"spaceId": "sp-7"}, "", 4, "", true, 5, driveListFilter{})
var cliErr *CLIError
if !errors.As(err, &cliErr) {
t.Fatalf("err = %T %v", err, err)
}
// 恢复命令必须带原 --space-id,且给出原 --depth 层数。
assertDriveLatestSuggestion(t, cliErr.Suggestion, "--space-id sp-7")
if !strings.Contains(cliErr.Suggestion, "--depth 4") {
t.Fatalf("恢复命令应保留原层数: %q", cliErr.Suggestion)
}
}
// TestCrossPlatformCoverageDriveLatestRefusesOnUnrecoverableFailure 覆盖 P1-b 的另一半:递归途中遇不可恢复错误
// (auth 过期 / 网络不可达)且 latest>0 时,不吐 partial,直接回根因错误。
// 与 latest=0 的既有行为(TestCrossPlatformCoverageRunDriveListDepthUnrecoverable:partial
// + errors[] 进 stdout 后非零退出)对照——latest 下 partial 的 Top-N 会被误读为全局最新,
// 故必须拒绝产出;回根因错误而非 INCOMPLETE token,因为 auth/网络比通用截断提示更可操作。
func TestCrossPlatformCoverageDriveLatestRefusesOnUnrecoverableFailure(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"dirA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE","modifiedTime":1000}]}`},
{text: `{"errorCode":"DWS_SERVICE_UNAUTHORIZED"}`},
}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 5, driveListFilter{})
// 回根因错误:Code 仍是 auth 过期,不被包装成 LATEST_SCAN_INCOMPLETE。
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeAuthTokenExpired {
t.Fatalf("err = %T %v, want CodeAuthTokenExpired", err, err)
}
if strings.Contains(cliErr.Message, "LATEST_SCAN_INCOMPLETE") {
t.Fatalf("unrecoverable 应回根因错误而非 INCOMPLETE 包装: %q", cliErr.Message)
}
// 拒绝产出:不吐 partial(对照 latest=0 时会输出 2 条 items + 1 条 errors)。
if out.Len() != 0 {
t.Fatalf("expected no stdout on refusal, got: %s", out.String())
}
}
// TestCrossPlatformCoverageDriveLatestIncompleteErrorBranches 直接单测构造器的各条分支。
func TestCrossPlatformCoverageDriveLatestIncompleteErrorBranches(t *testing.T) {
// 纯截断分支:errs 为空 → 只有 TRUNCATED。
t.Run("truncated_only", func(t *testing.T) {
err := driveLatestIncompleteError(5, true, nil, driveLatestScope{depth: 3})
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated {
t.Fatalf("err = %T %v, want CodeContentTruncated", err, err)
}
if !strings.Contains(cliErr.Message, "LATEST_SCAN_TRUNCATED") {
t.Fatalf("message = %q", cliErr.Message)
}
if strings.Contains(cliErr.Message, "LATEST_SCAN_INCOMPLETE") {
t.Fatalf("无目录失败时不应出现 INCOMPLETE: %q", cliErr.Message)
}
assertDriveLatestSuggestion(t, cliErr.Suggestion, "")
})
// 纯目录失败分支:未截断 → 只有 INCOMPLETE,Message 含首个失败的 folder/depth/reason。
t.Run("folder_failure_only", func(t *testing.T) {
err := driveLatestIncompleteError(3, false, twoDriveDepthErrors(), driveLatestScope{depth: 3})
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated {
t.Fatalf("err = %T %v, want CodeContentTruncated", err, err)
}
msg := cliErr.Message
if !strings.Contains(msg, "LATEST_SCAN_INCOMPLETE") ||
!strings.Contains(msg, "报表") ||
!strings.Contains(msg, "depth=2") ||
!strings.Contains(msg, "permission_denied") ||
!strings.Contains(msg, "2 个目录未读全") {
t.Fatalf("message = %q", msg)
}
if strings.Contains(msg, "LATEST_SCAN_TRUNCATED") {
t.Fatalf("未截断时不应出现 TRUNCATED: %q", msg)
}
assertDriveLatestSuggestion(t, cliErr.Suggestion, "")
})
// 组合分支(评审反馈的阻塞点):截断与目录失败同真时,旧实现让 truncated 短路、
// permission_denied 这类目录失败详情整块丢失。现在两个 token 都必须在,且失败详情不得被吞。
t.Run("truncated_with_folder_failures", func(t *testing.T) {
err := driveLatestIncompleteError(4, true, twoDriveDepthErrors(), driveLatestScope{depth: 3})
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated {
t.Fatalf("err = %T %v, want CodeContentTruncated", err, err)
}
msg := cliErr.Message
// 两个成因都要可被消费方 token 匹配到。
if !strings.Contains(msg, "LATEST_SCAN_INCOMPLETE") || !strings.Contains(msg, "LATEST_SCAN_TRUNCATED") {
t.Fatalf("组合场景需同时带两个 token: %q", msg)
}
// 目录失败详情必须完整保留——这是用户唯一能动手修的线索。
if !strings.Contains(msg, "报表") ||
!strings.Contains(msg, "depth=2") ||
!strings.Contains(msg, "permission_denied") ||
!strings.Contains(msg, "2 个目录未读全") {
t.Fatalf("组合场景丢失目录失败详情: %q", msg)
}
if !strings.Contains(msg, "拒绝输出不完整的 Top-4") {
t.Fatalf("message 缺少拒绝产出结论: %q", msg)
}
assertDriveLatestSuggestion(t, cliErr.Suggestion, "")
// 组合场景的指引要同时覆盖两条补救:换可读目录 + 降层数。
if !strings.Contains(cliErr.Suggestion, "确认目录权限") || !strings.Contains(cliErr.Suggestion, "--depth") {
t.Fatalf("组合场景 suggestion 需同时给出权限与降层数补救: %q", cliErr.Suggestion)
}
})
// folderName 为空回落 folderID;folderID 也空回落 <root>。
t.Run("folder_fallback", func(t *testing.T) {
byID := driveLatestIncompleteError(1, false, []driveDepthError{{FolderID: "fid-x"}}, driveLatestScope{depth: 2})
if !strings.Contains(byID.Error(), "folder=fid-x") {
t.Fatalf("fallback to folderID: %v", byID)
}
byRoot := driveLatestIncompleteError(1, false, []driveDepthError{{}}, driveLatestScope{depth: 2})
if !strings.Contains(byRoot.Error(), "folder=<root>") {
t.Fatalf("fallback to <root>: %v", byRoot)
}
})
}
// twoDriveDepthErrors 是两条目录失败样本,首条用于断言「首个失败」详情。
func twoDriveDepthErrors() []driveDepthError {
return []driveDepthError{
{Depth: 2, FolderID: "fid-9", FolderName: "报表", Reason: "permission_denied", Message: "denied"},
{Depth: 1, FolderID: "fid-3", FolderName: "归档", Reason: "api_error", Message: "boom"},
}
}
// TestCrossPlatformCoverageDriveLatestScopePreservedInSuggestion 覆盖评审反馈的第二个阻塞点:恢复命令此前固定
// 生成 `dws drive list --folder ...`,把原调用的 --workspace / --space-id 丢掉。用户照抄后
// 会从知识库切到普通钉盘(或从指定钉盘空间切到「我的文件」),在另一个查询域里拿到一份
// 「看起来对」的 Top-N —— 比直接报错更难发现。
func TestCrossPlatformCoverageDriveLatestScopePreservedInSuggestion(t *testing.T) {
cases := []struct {
name string
flags map[string]string
want string
}{
// 知识库路由:--workspace 决定路由,丢了就切到普通钉盘。
{name: "workspace", flags: map[string]string{"workspace": "ws-1"}, want: "--workspace ws-1"},
// 别名路径:--workspace-id 与 --workspace 同源(flagOrFallback)。
{name: "workspace_id_alias", flags: map[string]string{"workspace-id": "ws-alias"}, want: "--workspace ws-alias"},
// 钉盘路由:--space-id 丢了就退回「我的文件」。
{name: "space_id", flags: map[string]string{"space-id": "sp-7"}, want: "--space-id sp-7"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
scope := driveLatestScopeFromCmd(newDriveListScopeCmd(t, tc.flags), 3, "")
// 三条成因组合下的恢复命令都必须带原查询域。
for _, variant := range []struct {
label string
truncated bool
errs []driveDepthError
}{
{"truncated_only", true, nil},
{"folder_failure_only", false, twoDriveDepthErrors()},
{"both", true, twoDriveDepthErrors()},
} {
err := driveLatestIncompleteError(5, variant.truncated, variant.errs, scope)
var cliErr *CLIError
if !errors.As(err, &cliErr) {
t.Fatalf("%s: err = %T %v", variant.label, err, err)
}
assertDriveLatestSuggestion(t, cliErr.Suggestion, tc.want)
}
})
}
// --workspace 优先于 --space-id:与 drive list 的路由判定同序(先看 workspace 再看 space-id),
// 否则恢复命令会把知识库查询写成钉盘查询。
t.Run("workspace_wins_over_space_id", func(t *testing.T) {
scope := driveLatestScopeFromCmd(newDriveListScopeCmd(t, map[string]string{
"workspace": "ws-1", "space-id": "sp-7",
}), 3, "")
err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), scope)
suggestion := err.(*CLIError).Suggestion
if !strings.Contains(suggestion, "--workspace ws-1") || strings.Contains(suggestion, "--space-id") {
t.Fatalf("workspace 应优先且不混入 space-id: %q", suggestion)
}
})
// 无查询域时不得凭空造 flag(原调用就是「我的文件」根,硬塞 scope 同样是改查询域)。
t.Run("no_scope_adds_nothing", func(t *testing.T) {
scope := driveLatestScopeFromCmd(newDriveListScopeCmd(t, nil), 3, "")
err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), scope)
suggestion := err.(*CLIError).Suggestion
if strings.Contains(suggestion, "--workspace") || strings.Contains(suggestion, "--space-id") {
t.Fatalf("无 scope 时不应凭空造查询域 flag: %q", suggestion)
}
assertDriveLatestSuggestion(t, suggestion, "")
})
// depth==1(知识库 --latest 单层)时不给 --depth 1:partial+errors[] 契约只在多层成立,
// 硬塞 --depth 1 会让「去掉 --latest 看明细」的子句自相矛盾。
t.Run("single_depth_omits_depth_flag", func(t *testing.T) {
err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), driveLatestScope{domain: "--workspace ws-1", depth: 1})
suggestion := err.(*CLIError).Suggestion
if strings.Contains(suggestion, "--depth") {
t.Fatalf("单层不应出现 --depth: %q", suggestion)
}
})
// 多层时给出确切层数,用户无需把 <原层数> 换成数字。
t.Run("multi_depth_emits_actual_depth", func(t *testing.T) {
err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), driveLatestScope{domain: "--space-id sp-7", depth: 4})
suggestion := err.(*CLIError).Suggestion
if !strings.Contains(suggestion, "--depth 4") {
t.Fatalf("应给出原层数 --depth 4: %q", suggestion)
}
if strings.Contains(suggestion, "<原层数>") {
t.Fatalf("不应残留占位符: %q", suggestion)
}
})
}
// TestCrossPlatformCoverageDriveLatestScopeQuotesHostileValues 覆盖评审的 P1 阻断项:恢复命令是给用户直接复制到
// shell 里执行的,查询域的值来自用户输入(workspace 常见形态就是带查询串的 URL)。未引用时
// 一个 `&` 就把命令拆成后台任务,`;` / `$()` 更能执行额外内容。
// 断言落在「值被完整包在单引号里」而不只是「出现过」——后者对裸拼接也成立,抓不住缺陷。
func TestCrossPlatformCoverageDriveLatestScopeQuotesHostileValues(t *testing.T) {
cases := []struct {
name string
flag string
value string
want string
}{
// 合法 workspace URL:& 在裸拼接下直接改变 shell 解析(前半段被丢进后台)。
{name: "workspace_url", flag: "workspace", value: "https://alidocs.dingtalk.com/i/nodes/abc?spaceId=1&type=doc",
want: `--workspace 'https://alidocs.dingtalk.com/i/nodes/abc?spaceId=1&type=doc'`},
// 命令替换:裸拼接会在用户复制执行时真的跑起来。
{name: "command_substitution", flag: "workspace", value: "$(id)", want: `--workspace '$(id)'`},
// 分号拆语句。
{name: "semicolon", flag: "space-id", value: "sp-7;id", want: `--space-id 'sp-7;id'`},
// 空格拆参:裸拼接会让 --folder 收到错误的值。
{name: "space", flag: "space-id", value: "sp 7", want: `--space-id 'sp 7'`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// 显式注入 POSIX 策略:本测试断言的是「危险值被单引号正确包住」这一 POSIX 形态。
// Windows 策略下这些值根本不内联(见 ...SuggestionNeverInlinesHostileValue),
// 若走平台绑定,这里在 Windows runner 上会因形态不同而误报。
scope := driveLatestScopeFrom(newDriveListScopeCmd(t, map[string]string{tc.flag: tc.value}), 3, "", driveLatestPosixScopeValue)
err := driveLatestIncompleteError(5, true, twoDriveDepthErrors(), scope)
suggestion := err.(*CLIError).Suggestion
if !strings.Contains(suggestion, tc.want) {
t.Fatalf("查询域未安全引用\n want substring: %s\n got suggestion: %s", tc.want, suggestion)
}
})
}
}
// TestCrossPlatformCoverageDriveLatestScopePreservesFiltersAndFolder 覆盖自动 CR 的 P2:
// --pattern/--type/--start/--end 与 --folder 同样决定 --latest 的候选集合。恢复命令丢掉任一项,
// 用户照抄后就是在**另一个集合**上取 Top-N —— 原调用带 --pattern 时,缺了它的重试命令会对全部
// 条目排序,结果「看起来成功」却答非所问,比直接报错更难发现。
func TestCrossPlatformCoverageDriveLatestScopePreservesFiltersAndFolder(t *testing.T) {
cmd := newDriveListScopeCmd(t, map[string]string{
"workspace": "ws-1",
"pattern": "*日报*",
"type": "file",
"start": "7d",
"end": "2026-08-01",
})
// 注入 POSIX 策略:`--pattern '*日报*'` 这种引用形态是 POSIX 专属,Windows 下该值会降级为
// 占位符 + 展示行(见 ...SuggestionNeverInlinesHostileValue)。走平台绑定会在 Windows 误报。
scope := driveLatestScopeFrom(cmd, 4, "folder-root", driveLatestPosixScopeValue)
err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), scope)
suggestion := err.(*CLIError).Suggestion
// 每条示例命令都要带齐查询域与全部过滤条件;pattern 含 * 与中文,必须是引用后的形态。
for _, want := range []string{
"--workspace ws-1",
"--pattern '*日报*'",
"--type file",
"--start 7d",
"--end 2026-08-01",
} {
for _, clause := range strings.Split(suggestion, ";") {
cmdText := extractTrailingDwsCommand(clause)
if cmdText == "" {
continue
}
if !strings.Contains(cmdText, want) {
t.Fatalf("恢复命令丢失候选集条件 %q:\n clause: %s", want, cmdText)
}
}
}
// 「去掉 --latest 按原范围重跑」是唯一的原范围命令,必须原样带回原 --folder 而非占位符,
// 否则它就不是「原范围」。
var origin string
for _, clause := range strings.Split(suggestion, ";") {
if strings.Contains(clause, "去掉 --latest") {
origin = extractTrailingDwsCommand(clause)
}
}
if origin == "" {
t.Fatalf("多层场景应给出「去掉 --latest 按原范围重跑」子句: %q", suggestion)
}
if !strings.Contains(origin, "--folder folder-root") {
t.Fatalf("原范围命令应保留原 --folder: %q", origin)
}
if strings.Contains(origin, "<可读子目录ID>") {
t.Fatalf("原范围命令不应把原 --folder 换成占位符: %q", origin)
}
}
// TestCrossPlatformCoverageDriveLatestScopeOmitsFolderAtSpaceRoot 原调用就在空间根时不得凭空
// 造 --folder:硬塞一个目录同样是改候选集。
func TestCrossPlatformCoverageDriveLatestScopeOmitsFolderAtSpaceRoot(t *testing.T) {
scope := driveLatestScopeFromCmd(newDriveListScopeCmd(t, map[string]string{"space-id": "sp-7"}), 3, "")
err := driveLatestIncompleteError(2, false, twoDriveDepthErrors(), scope)
suggestion := err.(*CLIError).Suggestion
for _, clause := range strings.Split(suggestion, ";") {
if !strings.Contains(clause, "去掉 --latest") {
continue
}
if strings.Contains(extractTrailingDwsCommand(clause), "--folder") {
t.Fatalf("空间根扫描的原范围命令不应带 --folder: %q", clause)
}
}
}
// TestCrossPlatformCoverageDriveLatestErrorStripsRemoteControlChars 覆盖自动 CR 的 P2 安全项:
// 拒绝产出后,目录名与服务端错误文本从 JSON(编码时会被转义)挪进了纯文本 stderr。若原样透传,
// 其中的 ANSI/OSC 序列会被终端直接执行 —— 可清屏、伪造彩色「成功」、隐藏后续输出、改窗口标题,
// 在 AI Agent 场景还会污染上下文窗口。目录名对共享目录而言是他人可控输入。
func TestCrossPlatformCoverageDriveLatestErrorStripsRemoteControlChars(t *testing.T) {
assertNoControlChars := func(t *testing.T, msg string) {
t.Helper()
for name, r := range map[string]rune{"ESC": 0x1b, "BEL": 0x07, "CR": '\r', "LF": '\n', "TAB": '\t'} {
if strings.ContainsRune(msg, r) {
t.Fatalf("错误消息残留 %s 控制字符: %q", name, msg)
}
}
}
// folderName 路径:CSI 清屏 + 变色,外加 OSC 改标题。
t.Run("folder_name", func(t *testing.T) {
err := driveLatestIncompleteError(3, false, []driveDepthError{{
Depth: 2,
FolderName: "报表\x1b[2J\x1b[31m看起来成功\x1b[0m",
Reason: "permission_denied",
Message: "denied\r\n\x1b]0;pwned\x07次行",
}}, driveLatestScope{depth: 2})
msg := err.(*CLIError).Message
assertNoControlChars(t, msg)
// 清理不能把诊断信息一起抹掉:可读部分必须留下。
if !strings.Contains(msg, "报表") || !strings.Contains(msg, "denied") || !strings.Contains(msg, "次行") {
t.Fatalf("清理后应保留可读文本: %q", msg)
}
})
// folderName 为空时回落到 folderID,该字段同样来自服务端。
t.Run("folder_id_fallback", func(t *testing.T) {
err := driveLatestIncompleteError(1, true, []driveDepthError{{
FolderID: "fid\x1b[1m-x",
Reason: "api_error",
Message: "boom",
}}, driveLatestScope{depth: 1})
msg := err.(*CLIError).Message
assertNoControlChars(t, msg)
if !strings.Contains(msg, "fid-x") {
t.Fatalf("剥离控制序列后 folderID 应连成 fid-x: %q", msg)
}
})
}
// TestCrossPlatformCoverageDriveLatestSafeRemoteText 直接钉住清理函数的各条分支:换行/制表符
// 折成空格(SanitizeForTerminal 按设计保留这两者,但单行错误消息里它们会拆出伪造行),
// 首尾空白收掉,可打印内容与中文原样保留。
func TestCrossPlatformCoverageDriveLatestSafeRemoteText(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{name: "plain", in: "denied", want: "denied"},
{name: "cjk", in: "报表目录", want: "报表目录"},
{name: "csi", in: "a\x1b[31mb", want: "ab"},
{name: "osc", in: "a\x1b]0;t\x07b", want: "ab"},
{name: "newline_to_space", in: "a\nb", want: "a b"},
{name: "tab_to_space", in: "a\tb", want: "a b"},
{name: "carriage_return_dropped", in: "a\rb", want: "ab"},
{name: "trim_outer", in: "\n denied \t", want: "denied"},
{name: "empty", in: "", want: ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := driveLatestSafeRemoteText(tc.in); got != tc.want {
t.Fatalf("driveLatestSafeRemoteText(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
// TestCrossPlatformCoverageDriveLatestSuggestionNeverInlinesHostileValue 端到端锁定评审提出的
// Windows 注入阻断项:`--space-id sp-7&whoami` 这种值,在 POSIX 下靠单引号关停,但 cmd.exe
// 不把单引号当引号,粘贴后 `&whoami` 仍会执行。因此不变量必须是「恢复命令里不存在能被目标
// shell 解释的裸元字符」,两个平台分别用各自的手段满足:POSIX 引用内联,Windows 不内联。
//
// 两种形态都通过注入渲染策略在本机验证,不依赖当前 GOOS —— 否则「Windows 上降级为占位符」
// 这条路在 POSIX 机器上永不可达,就成了只有 Windows runner 才跑到的盲区(平台覆盖率门禁也会
// 因此报未覆盖)。平台绑定本身由 TestCrossPlatformCoverageDriveLatestScopeValueBinding 覆盖。
func TestCrossPlatformCoverageDriveLatestSuggestionNeverInlinesHostileValue(t *testing.T) {
const hostile = "sp-7&whoami"
newSuggestion := func(t *testing.T, render driveLatestValueRenderer) string {
t.Helper()
scope := driveLatestScopeFrom(newDriveListScopeCmd(t, map[string]string{"space-id": hostile}), 3, "", render)
return driveLatestIncompleteError(5, true, twoDriveDepthErrors(), scope).(*CLIError).Suggestion
}
t.Run("windows_never_inlines", func(t *testing.T) {
suggestion := newSuggestion(t, driveLatestWindowsScopeValue)
for _, clause := range strings.Split(suggestion, ";") {
cmdText := extractTrailingDwsCommand(clause)
if cmdText == "" {
continue
}
// cmd.exe 里 & 分隔命令,且单引号不是引号,故它压根不能进命令。
if strings.Contains(cmdText, "&") {
t.Fatalf("windows 形态的命令不得含 &: %s", cmdText)
}
if strings.Contains(cmdText, hostile) {
t.Fatalf("windows 形态的命令不得内联原值: %s", cmdText)
}
}
if !strings.Contains(suggestion, driveLatestUnsafeValuePlaceholder) {
t.Fatalf("应降级为占位符: %s", suggestion)
}
if !strings.Contains(suggestion, strconv.Quote(hostile)) {
t.Fatalf("应在展示行给出原值: %s", suggestion)
}
if !strings.Contains(suggestion, "不是可执行命令") {
t.Fatalf("展示行须显式声明非可执行: %s", suggestion)
}
})
t.Run("posix_quotes_inline", func(t *testing.T) {
suggestion := newSuggestion(t, driveLatestPosixScopeValue)
if !strings.Contains(suggestion, "'"+hostile+"'") {
t.Fatalf("posix 形态应单引号内联原值: %s", suggestion)
}
// POSIX 下不该无谓降级 —— 那会白白损失可复制体验。
if strings.Contains(suggestion, driveLatestUnsafeValuePlaceholder) {
t.Fatalf("posix 形态不应降级为占位符: %s", suggestion)
}
for _, clause := range strings.Split(suggestion, ";") {
cmdText := extractTrailingDwsCommand(clause)
if cmdText == "" {
continue
}
if strings.Contains(cmdText, "&") && !strings.Contains(cmdText, "'"+hostile+"'") {
t.Fatalf("posix 形态出现未引用的元字符: %s", cmdText)
}
}
})
}
// newDriveListScopeCmd 造一个带 drive list 查询域与过滤 flag 的命令。flag 名与 newDriveCommand
// 里 driveListCmd 的注册保持一致;workspace-id 是 cross-product 别名,此处显式注册以覆盖别名路径。
func newDriveListScopeCmd(t *testing.T, flags map[string]string) *cobra.Command {
t.Helper()
cmd := &cobra.Command{Use: "list"}
for _, name := range []string{
"workspace", "workspace-id", "space-id", // 查询域
"folder", // 扫描根(scope 从 rootFolder 参数取,注册仅为对齐真实命令)
"pattern", "type", "start", "end", // 决定候选集的过滤条件
} {
cmd.Flags().String(name, "", "")
}
for name, value := range flags {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s=%s: %v", name, value, err)
}
}
return cmd
}
// assertDriveLatestSuggestion 钉住 Suggestion 的三条约束:
// 1. 每条示例命令都带原查询域 wantScope(空串表示原调用无查询域,此时只跳过该项检查);
// 2. 含 --latest 的引导子句存在;
// 3. 「去掉 --latest」子句给出的示例命令本身不带 --latest(否则照抄复现同一错误)。
func assertDriveLatestSuggestion(t *testing.T, suggestion, wantScope string) {
t.Helper()
clauses := strings.Split(suggestion, ";")
sawLatestGuide := false
for _, clause := range clauses {
cmd := extractTrailingDwsCommand(clause)
if cmd == "" {
continue
}
if wantScope != "" && !strings.Contains(cmd, wantScope) {
t.Fatalf("示例命令丢失原查询域 %q(照抄会切换查询域): %q", wantScope, cmd)
}
if strings.Contains(clause, "去掉 --latest") {
if strings.Contains(cmd, "--latest") {
t.Fatalf("「去掉 --latest」子句的示例命令仍含 --latest: %q", cmd)
}
continue
}
if strings.Contains(cmd, "--latest") {
sawLatestGuide = true
}
}
if !sawLatestGuide {
t.Fatalf("no --latest-bearing guidance clause in suggestion: %q", suggestion)
}
}
// extractTrailingDwsCommand 抽子句里以 "dws " 开头的尾部命令片段(到子句末),无则空串。
func extractTrailingDwsCommand(clause string) string {
idx := strings.LastIndex(clause, "dws ")
if idx < 0 {
return ""
}
return clause[idx:]
}
@@ -0,0 +1,13 @@
//go:build !windows
package helpers
// driveLatestScopeValue 按**本次构建的目标 shell** 渲染恢复命令里的用户值:返回可内联的片段,
// 第二个返回值为 false 时表示该值不能安全进入可执行命令,调用方须改用占位符。
//
// 非 Windows 构建面向 POSIX shell,单引号可靠地关闭所有展开,故含元字符的值引用后内联即安全。
// Windows 构建见 drive_latest_scope_windows.go —— 两个平台的策略本体都是
// shell_quote.go 里的纯函数,可在任意平台被测试直接调用;本文件只做编译期绑定。
func driveLatestScopeValue(value string) (string, bool) {
return driveLatestPosixScopeValue(value)
}
@@ -0,0 +1,13 @@
//go:build windows
package helpers
// driveLatestScopeValue 按**本次构建的目标 shell** 渲染恢复命令里的用户值:返回可内联的片段,
// 第二个返回值为 false 时表示该值不能安全进入可执行命令,调用方须改用占位符。
//
// Windows 构建下没有对 cmd.exe 与 PowerShell 同时成立的引用形式(cmd.exe 不认单引号,双引号
// 又挡不住 %VAR% 展开),故只内联本身就安全的值;理由与取舍详见
// driveLatestWindowsScopeValue 的注释。POSIX 构建见 drive_latest_scope_posix.go。
func driveLatestScopeValue(value string) (string, bool) {
return driveLatestWindowsScopeValue(value)
}
@@ -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)
}
})
}
@@ -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)
}
}
@@ -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)
})
}
+974
View File
@@ -0,0 +1,974 @@
package helpers
import (
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
"github.com/google/uuid"
"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 + items 始终打印到 stdout;
// summary.failed > 0 时额外以非零退出码退出。
// ──────────────────────────────────────────────────────────
// --if-exists 的三种策略。
const (
ifExistsOverwrite = "overwrite" // 总是下载覆盖(Drive 为权威源)
ifExistsSmart = "smart" // 推荐增量:本地 mtime 已 ≥ 远端 modified_time 则跳过
ifExistsSkip = "skip" // 默认:本地已存在则保持不动
)
// 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 (
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"`
}
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 时返回:结构化结果已打印到 stdout,
// 这里只负责以 exit=1 退出并向 stderr 输出一行简短说明(与 push/sync 一致)。
type drivePartialFailure struct{ failed int }
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 把本地目标路径归一化成「目标文件系统下的等价键」,用于探测多个
// 远端条目是否会落到同一个本地文件。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 是大小写探测的注入点(与 httpGetFile / httpPutFile 同样的 seam):
// 生产路径始终是 detectCaseInsensitiveFS,测试可替换它,以便在大小写敏感的 CI 文件系统上
// 也能走到「等价路径冲突」分支。
var isCaseInsensitiveFS = detectCaseInsensitiveFS
// caseProbePattern 是探针文件名模板。抽成变量只为可测:纯数字模板的大写与原名相同,
// 可覆盖「名称无大小写差异、无法据此判定」的回退分支。
var caseProbePattern = "dws-caseprobe-*"
// detectCaseInsensitiveFS 探测 dir 所在文件系统是否大小写不敏感:在 dir 下创建一个随机
// 小写名探针文件,再用其大写名 stat;命中同一文件即为不敏感。dir 必须已存在。
// 探测失败时回退到平台默认(Windows / macOS 视为不敏感,其它敏感)。
func detectCaseInsensitiveFS(dir string) bool {
platformDefault := runtime.GOOS == "windows" || runtime.GOOS == "darwin"
f, err := os.CreateTemp(dir, caseProbePattern)
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 = ifExistsSkip
}
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
}
// 在任何远端读取之前固定本地根计划:既有根立即持有 os.Root;不存在的根只固定
// 最近既存祖先并记录尚缺的逐层后缀。远端清单不完整或预检失败时不得创建本地根。
rootPlan, err := planPinnedPullRoot(absDir)
if err != nil {
return err
}
defer rootPlan.close()
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)
preflight, localInfo, err := buildDrivePullPreflightFromPlan(rootPlan, remote)
if err != nil {
return err
}
if deps.Caller.DryRun() {
return printDrivePullDryRunWithLocalInfo(ifExists, remote, relPaths, preflight, localInfo)
}
// 镜像契约独立于当前主机文件系统:远端 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}
}
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]
// preflight 与真正下载之间仍需二次解析,挡住祖先目录被并发替换为软链的
// TOCTOU 逃逸;pullRemoteFile 将该检查收口在写入入口前。
action, perr := pullRemoteFilePinned(ctx, spaceID, rf, pinnedRoot, rel, 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)
}
// 结构化结果始终打印到 stdout;有失败则额外以非零退出码退出。
if perr := deps.Out.PrintJSON(res); perr != nil {
return perr
}
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 {
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 {
localPath, err := resolveLocalTarget(absDir, rel)
if err != nil {
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 := localInfo[rel]; fi != nil {
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,
})
}
// 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 initialInfo != nil {
switch ifExists {
case ifExistsSkip:
return pullActionSkipped, nil
case ifExistsSmart:
// 远端时间可信且本地 mtime 已 ≥ 远端 → 视为已对齐,跳过;
// 时间缺失/非法时不盲跳,退回继续下载。
if rf.ModifiedTimeValid && initialInfo.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
}
// download_file 可执行任意时长的远端读。在任何 HTTP GET 或临时文件写入前,
// 必须确认目标父目录仍是最初固定在本地根下的同一目录。
if err := target.verifyParent(); err != nil {
return pullActionFailed, err
}
// 临时文件通过已固定的 os.Root 直接打开,下载直接写已打开的句柄;
// 生产路径不会再按可被换链的绝对路径重新打开临时文件。
tmp, tmpName, err := pullCreateTemp(target.parentRoot)
if err != nil {
return pullActionFailed, fmt.Errorf("创建临时文件失败: %w", err)
}
// 除非成功 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 {
_ = target.parentRoot.Remove(tmpName)
}
}()
if err := pullDownloadFile(ctx, resourceURL, headers, tmp); err != nil {
return pullActionFailed, err
}
// 通过仍打开的文件句柄设置 mtime,避免按 tmpName 重新解析路径产生 symlink TOCTOU。
// 时间对齐延续既有 best-effort 语义,失败不影响已完整下载内容的发布。
if rf.ModifiedTimeValid {
_ = setPullFileTimes(tmp, time.UnixMilli(rf.ModifiedTime))
}
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
}
@@ -0,0 +1,259 @@
package helpers
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
// ──────────────────────────────────────────────────────────
// 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 TestCrossPlatformCoverageDrivePull_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}`)
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
return os.WriteFile(dest, []byte("remote"), 0o644)
})
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 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}`)
swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error {
t.Error("skip must not download")
return 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 TestCrossPlatformCoverageDrivePull_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) + `}`)
swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error {
t.Error("smart must not download when local is newer")
return 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) + `}`)
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
return os.WriteFile(dest, []byte("fresh"), 0o644)
})
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 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") {
t.Fatalf("expected --if-exists rejection, got %v", err)
}
}
// space-id 透传到 download_file。
func TestCrossPlatformCoverageDrivePull_passesSpaceIDToDownload(t *testing.T) {
root := t.TempDir()
caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":1}`)
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
return os.WriteFile(dest, []byte("x"), 0o644)
})
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 TestCrossPlatformCoverageDrivePull_downloadFailureIsPartialFailure(t *testing.T) {
root := t.TempDir()
caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":1}`)
swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error { return errTestDownload })
var out strings.Builder
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"})
err := runDrivePull(cmd, nil)
var pf *drivePartialFailure
if !errors.As(err, &pf) {
t.Fatalf("expected drivePartialFailure, got %T %v", err, err)
}
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)
}
}
// download_file 未返回下载链接 → failed(不是 panic,也不落盘)。
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" {
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 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" {
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 TestCrossPlatformCoverageDrivePull_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
}}
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
return os.WriteFile(dest, []byte("leak"), 0o644)
})
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 TestCrossPlatformCoveragePullOneFile_mkdirFailureIsFailed(t *testing.T) {
root := t.TempDir()
// "sub" 是普通文件,MkdirAll("sub") 必然失败。
mustWrite(t, filepath.Join(root, "sub"), "occupied")
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)
if action != pullActionFailed || err == nil {
t.Fatalf("action=%q err=%v, want failed + error", action, err)
}
}
// 探测目录不存在时 isCaseInsensitiveFS 回退平台默认,不 panic。
func TestCrossPlatformCoverageIsCaseInsensitiveFS_missingDirFallsBackToPlatformDefault(t *testing.T) {
missing := filepath.Join(t.TempDir(), "absent")
// 只断言不 panic 且返回布尔(具体值取决于平台默认)。
_ = isCaseInsensitiveFS(missing)
}
@@ -0,0 +1,512 @@
package helpers
import (
"context"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"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 {
// Windows 在固定目录上持有句柄时会锁住它,移走于该平台物理
// 不可达;注入等价的根身份变化,命中同一条二次校验分支。
if runtime.GOOS != "windows" {
t.Fatal(renameErr)
}
swapPinnedRootIdentity(t, dir)
} else 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")
}
}
@@ -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)
}
}
+366
View File
@@ -0,0 +1,366 @@
package helpers
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
// drivePullMockCaller 按工具名分发:list_files 返回可配置的树,download_file 返回预签名 URL。
type drivePullMockCaller struct{ listJSON string }
func (m *drivePullMockCaller) CallTool(_ context.Context, _ string, tool string, _ map[string]any) (*edition.ToolResult, error) {
text := `{"result":{"downloadUrl":"https://oss.example.com/dl"}}`
if tool == "list_files" {
text = m.listJSON
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
func (m *drivePullMockCaller) Format() string { return "raw" }
func (m *drivePullMockCaller) DryRun() bool { return false }
func (m *drivePullMockCaller) Fields() string { return "" }
func (m *drivePullMockCaller) JQ() string { return "" }
// ──────────────────────────────────────────────────────────
// isSafeRemoteSegment — 远端名称逐段安全校验(拒绝逃逸成分)
// ──────────────────────────────────────────────────────────
func TestCrossPlatformCoverageIsSafeRemoteSegment(t *testing.T) {
cases := []struct {
name string
ok bool
}{
{"report.pdf", true},
{"报告 2024.xlsx", true},
{"a.b.c", true},
{"", false},
{".", false},
{"..", false},
{"a/b", false}, // 正斜杠分隔符
{`a\b`, false}, // 反斜杠分隔符(Windows)
{"../etc", false}, // 相对上跳
{"foo/../bar", false},
{"/abs", false}, // 绝对路径成分
}
for _, c := range cases {
if got := isSafeRemoteSegment(c.name); got != c.ok {
t.Errorf("isSafeRemoteSegment(%q) = %v, want %v", c.name, got, c.ok)
}
}
}
// ──────────────────────────────────────────────────────────
// resolveLocalTarget — 拼接后必须仍位于本地根目录内
// ──────────────────────────────────────────────────────────
func TestCrossPlatformCoverageResolveLocalTarget_withinRoot(t *testing.T) {
root := filepath.Clean(t.TempDir())
got, err := resolveLocalTarget(root, "sub/a.txt")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := filepath.Join(root, "sub", "a.txt")
if got != want {
t.Errorf("target = %q, want %q", got, want)
}
}
// 即便 isSafeRemoteSegment 被绕过,逐段拼出的逃逸路径也必须被 resolveLocalTarget 兜住。
func TestCrossPlatformCoverageResolveLocalTarget_escapeRejected(t *testing.T) {
root := filepath.Clean(t.TempDir())
for _, rel := range []string{"../evil.txt", "../../etc/passwd", "sub/../../out.txt"} {
if _, err := resolveLocalTarget(root, rel); err == nil {
t.Errorf("resolveLocalTarget(%q) should reject escaping path", rel)
}
}
}
// 根目录内的目录符号链接指向外部时,词法检查会放过(root/sub 仍在 root 下),
// 必须靠符号链接解析挡住:sub -> /outside 时 sub/a.txt 应被拒绝。
func TestCrossPlatformCoverageResolveLocalTarget_dirSymlinkEscapeRejected(t *testing.T) {
root := filepath.Clean(t.TempDir())
outside := filepath.Clean(t.TempDir())
link := filepath.Join(root, "sub")
if err := os.Symlink(outside, link); err != nil {
t.Skipf("平台不支持创建符号链接: %v", err)
}
if _, err := resolveLocalTarget(root, "sub/a.txt"); err == nil {
t.Fatal("root/sub -> 外部目录时,sub/a.txt 应被拒绝(防符号链接逃逸)")
}
// 对照:根目录内的真实子目录不应被误伤。
if err := os.MkdirAll(filepath.Join(root, "real"), 0o755); err != nil {
t.Fatal(err)
}
if _, err := resolveLocalTarget(root, "real/a.txt"); err != nil {
t.Errorf("真实子目录不应被拒绝: %v", err)
}
}
// 不存在的本地根目录必须放行(不误判逃逸),由后续 MkdirAll 创建。
func TestCrossPlatformCoverageResolveLocalTarget_nonexistentRootAllowed(t *testing.T) {
parent := filepath.Clean(t.TempDir())
absDir := filepath.Join(parent, "not", "created", "yet") // 尚不存在
got, err := resolveLocalTarget(absDir, "sub/a.txt")
if err != nil {
t.Fatalf("不存在的根目录不应被误判为逃逸: %v", err)
}
if want := filepath.Join(absDir, "sub", "a.txt"); got != want {
t.Errorf("target = %q, want %q", got, want)
}
}
// 回归:--local-folder 指向尚不存在的绝对路径时,pull 应自动创建目录并下载,
// 而不是把每个远端文件都误判为符号链接逃逸而 failed。
func TestCrossPlatformCoverageDrivePull_nonexistentLocalRoot(t *testing.T) {
parent := t.TempDir()
absDir := filepath.Join(parent, "new", "repo") // 尚不存在
caller := &drivePullMockCaller{
listJSON: `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"F1","modifyTime":1000}],"nextToken":""}}`,
}
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}})
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
return os.WriteFile(dest, []byte("REMOTE"), 0o644)
})
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().BoolP("yes", "y", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.AddCommand(newDriveCommand())
// pull/push 是 user_required 叶子,非交互环境需 --yes 才能越过统一确认门。
full := []string{"pull", "--local-folder", absDir, "--remote-folder", "ROOT", "--yes"}
testseam.Swap(t, &os.Args, append([]string{"dws", "drive"}, full...))
root.SetArgs(append([]string{"drive"}, full...))
// 全部下载成功时 runDrivePull 返回 nil;若误判逃逸则会有 failed 并返回 partial_failure。
if err := root.Execute(); err != nil {
t.Fatalf("pull 到不存在的本地根目录不应失败: %v", err)
}
got, rerr := os.ReadFile(filepath.Join(absDir, "a.txt"))
if rerr != nil {
t.Fatalf("目标文件应被创建: %v", rerr)
}
if string(got) != "REMOTE" {
t.Errorf("目标内容 = %q, want REMOTE", got)
}
}
// ──────────────────────────────────────────────────────────
// detectTargetCollisions — 多个远端条目映射到同一本地目标
// ──────────────────────────────────────────────────────────
// 大小写不敏感 FS:A.txt 与 a.txt 落到同一目标 → 两者都判冲突;无关文件不受影响。
func TestCrossPlatformCoverageDetectTargetCollisions_caseInsensitive(t *testing.T) {
root := filepath.Clean(t.TempDir())
rels := []string{"A.txt", "a.txt", "b.txt", "sub/C.md", "sub/c.md"}
collided := detectTargetCollisions(root, rels, true)
for _, r := range []string{"A.txt", "a.txt", "sub/C.md", "sub/c.md"} {
if !collided[r] {
t.Errorf("%q 应被判为大小写冲突", r)
}
}
if collided["b.txt"] {
t.Error("b.txt 无冲突,不应被标记")
}
}
// 大小写敏感 FS:A.txt 与 a.txt 是两个合法文件 → 不应误判冲突。
func TestCrossPlatformCoverageDetectTargetCollisions_caseSensitive(t *testing.T) {
root := filepath.Clean(t.TempDir())
rels := []string{"A.txt", "a.txt", "b.txt"}
collided := detectTargetCollisions(root, rels, false)
if len(collided) != 0 {
t.Errorf("大小写敏感 FS 不应有冲突, got %v", collided)
}
}
// 平台名称规范化冲突:同名的 NFC 与 NFD 记法在不敏感 FS 上落到同一目标 → 冲突。
func TestCrossPlatformCoverageDetectTargetCollisions_unicodeNormalization(t *testing.T) {
root := filepath.Clean(t.TempDir())
nfc := "café.txt" // café(预组合 é)
nfd := "café.txt" // café(e + 组合尖音符)
if nfc == nfd {
t.Fatal("测试数据应为不同字节序列")
}
ci := detectTargetCollisions(root, []string{nfc, nfd}, true)
if !ci[nfc] || !ci[nfd] {
t.Errorf("NFC/NFD 异写在大小写不敏感 FS 上应判冲突, got %v", ci)
}
// 大小写/规范化敏感 FS 上视为两个不同文件,不冲突。
cs := detectTargetCollisions(root, []string{nfc, nfd}, false)
if len(cs) != 0 {
t.Errorf("敏感 FS 上 NFC/NFD 不应冲突, got %v", cs)
}
}
// isCaseInsensitiveFS 应能对临时目录给出与实际探针一致的判定(本机自洽)。
func TestCrossPlatformCoverageIsCaseInsensitiveFS_selfConsistent(t *testing.T) {
dir := t.TempDir()
got := isCaseInsensitiveFS(dir)
// 实测:写一个小写名,再用大写名 stat。
lower := filepath.Join(dir, "probe_case_xyz.txt")
if err := os.WriteFile(lower, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
_, err := os.Stat(filepath.Join(dir, "PROBE_CASE_XYZ.TXT"))
actualInsensitive := err == nil
if got != actualInsensitive {
t.Errorf("isCaseInsensitiveFS=%v,但实测大写名可 stat=%v", got, actualInsensitive)
}
}
// ──────────────────────────────────────────────────────────
// pullOneFile — 覆盖下载中断不得破坏原文件(原子替换)
// ──────────────────────────────────────────────────────────
// 下载中途失败时:命令报告 failed,原有本地文件内容必须原封不动,且不留临时残file。
func TestCrossPlatformCoveragePullOneFile_downloadFailureKeepsOriginal(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "a.txt")
const original = "ORIGINAL-CONTENT-DO-NOT-LOSE"
mustWrite(t, target, original)
// download_file 的 MCP 调用走 mock;httpGetFile 注入为“写入部分内容后报错”。
testseam.Swap(t, &deps, &Deps{Caller: &drivePullMockCaller{}, Out: &Formatter{w: io.Discard}})
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
// 模拟传输中断:先截断/写半截,再返回错误。
_ = os.WriteFile(dest, []byte("HALF"), 0o644)
return errors.New("connection reset")
})
rf := &remoteFile{RelPath: "a.txt", FileID: "F1"}
action, err := pullOneFile(context.Background(), "", rf, target, ifExistsOverwrite)
if action != pullActionFailed || err == nil {
t.Fatalf("expected failed action with error, got action=%q err=%v", action, err)
}
// 原文件必须保持原内容。
got, rerr := os.ReadFile(target)
if rerr != nil {
t.Fatalf("原文件应仍存在: %v", rerr)
}
if string(got) != original {
t.Errorf("原文件被破坏: got %q, want %q", got, original)
}
// 不应残留临时文件。
entries, _ := os.ReadDir(root)
for _, e := range entries {
if e.Name() != "a.txt" {
t.Errorf("下载失败后残留了临时文件: %s", e.Name())
}
}
}
// 下载成功时:临时文件被原子重命名为目标,内容与远端一致。
func TestCrossPlatformCoveragePullOneFile_downloadSuccessReplacesTarget(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "a.txt")
mustWrite(t, target, "OLD")
testseam.Swap(t, &deps, &Deps{Caller: &drivePullMockCaller{}, Out: &Formatter{w: io.Discard}})
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
return os.WriteFile(dest, []byte("NEW-CONTENT"), 0o644)
})
rf := &remoteFile{RelPath: "a.txt", FileID: "F1"}
action, err := pullOneFile(context.Background(), "", rf, target, ifExistsOverwrite)
if err != nil || action != pullActionDownloaded {
t.Fatalf("expected downloaded, got action=%q err=%v", action, err)
}
got, _ := os.ReadFile(target)
if string(got) != "NEW-CONTENT" {
t.Errorf("目标未被替换为新内容: got %q", got)
}
entries, _ := os.ReadDir(root)
if len(entries) != 1 {
t.Errorf("成功后目录应只有目标文件,got %d 项", len(entries))
}
}
// ──────────────────────────────────────────────────────────
// parseDriveDownloadInfo — drive download_file 返回解析
// ──────────────────────────────────────────────────────────
// drive 的正常返回:result.downloadUrl 是预签名 URL,无需额外 header。
func TestCrossPlatformCoverageParseDriveDownloadInfo_downloadUrl(t *testing.T) {
text := `{"result":{"downloadType":"urlPreSignature","downloadUrl":"https://oss.example.com/f.file?Expires=1&Signature=xyz","fileName":"a.txt"},"success":true}`
url, headers, err := parseDriveDownloadInfo(text)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if url != "https://oss.example.com/f.file?Expires=1&Signature=xyz" {
t.Errorf("url = %q", url)
}
if len(headers) != 0 {
t.Errorf("预签名 URL 不应带 header, got %v", headers)
}
}
// 兼容 flat resourceUrl 字段。
func TestCrossPlatformCoverageParseDriveDownloadInfo_resourceUrlFallback(t *testing.T) {
text := `{"resourceUrl":"https://flat.example.com/dl"}`
url, _, err := parseDriveDownloadInfo(text)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if url != "https://flat.example.com/dl" {
t.Errorf("url = %q", url)
}
}
// 兼容 resourceUrls[].url 数组格式(在 result 包裹下)。
func TestCrossPlatformCoverageParseDriveDownloadInfo_resourceUrlsArrayFallback(t *testing.T) {
text := `{"result":{"resourceUrls":[{"url":"https://arr.example.com/dl","headers":{}}]}}`
url, _, err := parseDriveDownloadInfo(text)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if url != "https://arr.example.com/dl" {
t.Errorf("url = %q", url)
}
}
func TestCrossPlatformCoverageParseDriveDownloadInfo_missingURL(t *testing.T) {
text := `{"result":{"fileName":"a.txt"}}`
if _, _, err := parseDriveDownloadInfo(text); err == nil {
t.Fatal("expected error when downloadUrl is empty")
}
}
func TestCrossPlatformCoverageParseDriveDownloadInfo_invalidJSON(t *testing.T) {
if _, _, err := parseDriveDownloadInfo("not json"); err == nil {
t.Fatal("expected error for invalid JSON")
}
}
// ──────────────────────────────────────────────────────────
// drivePartialFailure — pull 部分失败错误:exit=1,stderr 仅保留简短说明
// ──────────────────────────────────────────────────────────
func TestCrossPlatformCoverageDrivePartialFailure(t *testing.T) {
e := &drivePartialFailure{failed: 2}
if e.ExitCode() != 1 {
t.Errorf("ExitCode() = %d, want 1", e.ExitCode())
}
want := "drive pull: 2 file(s) failed"
if e.Error() != want || e.RawStderr() != want {
t.Errorf("Error()/RawStderr() = %q / %q, want %q", e.Error(), e.RawStderr(), want)
}
}
+16
View File
@@ -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})
}
@@ -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)
}
+933
View File
@@ -0,0 +1,933 @@
package helpers
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"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 forcePinnedFallbackForTest {
swapPinnedParentIdentity(t, "sub")
return sub
}
if err := os.Rename(sub, parked); err != nil {
// 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)
}
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"}
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 {
replacePullCommandRootWithOutsideLink(t, root, parked, outside)
}
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)
}
replacePullCommandRootWithOutsideLink(t, root, parked, outside)
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 {
replacePullCommandRootWithOutsideLink(t, root, parked, outside)
}
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)
}
})
}
}
}
+862
View File
@@ -0,0 +1,862 @@
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"
)
// ==========================================================
// 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
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"`
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"`
}
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 }
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
}
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,用于复用/定位父目录)。
remoteFiles, remoteFolders, err := fetchRemoteTreeForPush(ctx, spaceID, remoteDirID)
if err != nil {
return err
}
preflight := buildMirrorPreflight(localDirs, localFiles, remoteFiles, remoteFolders)
res := drivePushResult{Items: make([]drivePushItem, 0, len(localDirs)+len(localFiles))}
if deps.Caller.DryRun() {
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 _, 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
}
if err := pinnedRoot.verify(); err != nil {
return err
}
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 := 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
}
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}
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, occupied map[string]string, depth int) error {
if depth > remoteMaxDepth {
return fmt.Errorf("drive 目录层级超过 %d 层,疑似循环引用,已中止", remoteMaxDepth)
}
nextToken := ""
seenTokens := make(map[string]struct{})
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 := callDriveListFiles(ctx, args)
if err != nil {
return err
}
items, token, err := parseDriveList(text)
if err != nil {
return err
}
for _, it := range items {
name, included, err := remoteMirrorEntryName(it, relBase)
if err != nil {
return err
}
if !included {
continue
}
childRel := name
if relBase != "" {
childRel = relBase + "/" + name
}
if it.isFolder() {
if err := claimRemotePath(occupied, childRel, "目录"); err != nil {
return err
}
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
}
// 非文件类型已由 remoteMirrorEntryName 过滤;目录在上方完成递归后返回。
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: fileID,
Hash: it.hash(),
ModifiedTime: modMillis,
ModifiedTimeValid: modValid,
}
}
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 _, 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,
})
}
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 {
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) {
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 := 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 {
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(),
scanInfo: info,
})
return 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 {
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 传输期间若源文件被原地改写(编辑器覆盖、截断重写、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()
})
}
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
}
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 := put(resourceURL, ossHeaders); 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
}
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)。
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 工具自行报错。
@@ -0,0 +1,449 @@
package helpers
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
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()
testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { return nil })
}
// ──────────────────────────────────────────────────────────
// runDrivePush — 端到端
// ──────────────────────────────────────────────────────────
// 本地子目录在远端缺失 → 按需 create_folder 并留 folder_created 痕迹,再上传其中文件。
func TestCrossPlatformCoverageDrivePush_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 TestCrossPlatformCoverageDrivePush_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 TestCrossPlatformCoverageDrivePush_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 TestCrossPlatformCoverageDrivePush_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 TestCrossPlatformCoverageDrivePush_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 TestCrossPlatformCoverageDrivePush_uploadFailureExitsNonZero(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "a.txt"), "local")
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")
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 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)
}
}
// space-id 必须透传到 create_folder / get_upload_info / ln 三处。
func TestCrossPlatformCoverageDrivePush_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 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 }}
if err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT"); err == nil {
t.Fatal("expected remote listing failure to propagate")
}
}
// 本地根目录不存在 → 扫描失败。
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(), "扫描本地目录失败") {
t.Fatalf("expected local scan failure, got %v", err)
}
}
// ──────────────────────────────────────────────────────────
// walkRemoteForPush — 分页、递归、深度上限、非镜像类型跳过
// ──────────────────────────────────────────────────────────
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)
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":"online.adoc","type":"document","fileId":"DOC"},` +
`{"name":"ok.txt","type":"file","fileId":"OK"}` +
`],"nextToken":""}}`, nil
}
return `{"result":{"items":[],"nextToken":""}}`, nil
}}
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 {
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")
}
if _, ok := files["online.adoc"]; ok {
t.Error("non-file document must remain outside the folder mirror index")
}
}
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
}}
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(), "循环引用") {
t.Fatalf("expected depth-limit abort, got %v", err)
}
}
// ──────────────────────────────────────────────────────────
// walkLocalForPush — 非常规文件与错误分支
// ──────────────────────────────────────────────────────────
func TestCrossPlatformCoverageWalkLocalForPush_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 TestCrossPlatformCoveragePushUploadFile_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) {
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 }
}
testseam.Swap(t, &httpPutFile, put)
if err := pushUploadFile(context.Background(), "SP", "PARENT", "", "a.txt", p, 7); err == nil {
t.Fatal("expected upload failure")
}
})
}
}
// pushCreateFolder:MCP 失败上抛,成功时从返回体提取 fileId。
func TestCrossPlatformCoveragePushCreateFolder_errorAndSuccess(t *testing.T) {
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
testseam.Swap(t, &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
}}
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)
}
if ok.calls[0].args["spaceId"] != "SP" || ok.calls[0].args["parentId"] != "P" {
t.Errorf("create_folder args = %v", ok.calls[0].args)
}
}
@@ -0,0 +1,719 @@
package helpers
import (
"context"
"crypto/md5"
"encoding/base64"
"errors"
"io"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"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)
}
})
// 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) {
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)
}
})
// 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")
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)
}
})
}
}
+360
View File
@@ -0,0 +1,360 @@
package helpers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"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"
)
// ──────────────────────────────────────────────────────────
// splitRel — rel_path 拆父路径 / 末段名
// ──────────────────────────────────────────────────────────
func TestCrossPlatformCoverageSplitRel(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 TestCrossPlatformCoverageParseNodeID(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 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
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 TestCrossPlatformCoverageDrivePushFailure(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 返回体
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) {
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":{}}}`
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
}
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 "" }
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`,返回捕获的调用。
// 注入已打开文件句柄的 PUT seam 为 no-op,避免真实 OSS 上传。
func runDrivePushTest(t *testing.T, caller *driveSyncMockCaller, localDir string, args ...string) error {
t.Helper()
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, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.AddCommand(newDriveCommand())
// pull/push 是 user_required 叶子,非交互环境需 --yes 才能越过统一确认门。
full := append([]string{"push", "--local-folder", localDir, "--remote-folder", "ROOT", "--yes"}, args...)
testseam.Swap(t, &os.Args, append([]string{"dws", "drive"}, full...))
root.SetArgs(append([]string{"drive"}, full...))
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()
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 TestCrossPlatformCoverageDrivePush_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 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
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+164
View File
@@ -0,0 +1,164 @@
package helpers
import (
"context"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
// 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 {
// 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 前若命令行根已
// 被另一目录替换,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
realMove := false
caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, _ int) (string, error) {
if tool == "list_files" {
if !replaced {
realMove = 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)
}
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)
}
})
}
}
// 即使根在 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
realMove := false
testseam.Swap(t, &pushPutOpenedFile, func(_ context.Context, _ string, _ map[string]string, file *os.File, _ int64) error {
putCalls++
realMove = 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 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)
}
})
}
}
@@ -0,0 +1,108 @@
package helpers
import (
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageDrivePushDryRun_typeConflictsFail(t *testing.T) {
var out bytes.Buffer
testseam.Swap(t, &deps, &Deps{Out: &Formatter{w: &out}})
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)
}
})
}
}
@@ -0,0 +1,86 @@
package helpers
import (
"context"
"io"
"os"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
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}
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) {
t.Fatalf("error = %v, want duplicate remote path %q", err, tt.path)
}
})
}
})
}
}
@@ -0,0 +1,77 @@
package helpers
import (
"context"
"io"
"os"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
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
}}
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)
}
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
}}
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") {
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)
}
})
}
}
@@ -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
}
@@ -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
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
})
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")
}
+11
View File
@@ -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)
}
@@ -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)
}
}
+11
View File
@@ -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)
}
@@ -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)
}
}
+671
View File
@@ -0,0 +1,671 @@
package helpers
import (
"bytes"
"context"
"crypto/md5"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"io/fs"
"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)
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),
// 并递归进入子文件夹。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)
}
nextToken := ""
seenTokens := make(map[string]struct{})
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 := callDriveListFiles(ctx, args)
if err != nil {
return err
}
items, token, err := parseDriveList(text)
if err != nil {
return err
}
for _, it := range items {
name, included, err := remoteMirrorEntryName(it, relBase)
if err != nil {
return err
}
if !included {
continue
}
// rel_path 由文件夹名逐层拼接,统一用 / 分隔,与本地相对路径对齐。
childRel := name
if relBase != "" {
childRel = relBase + "/" + name
}
if it.isFolder() {
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, folderID, childRel, out, occupied, depth+1); err != nil {
return err
}
continue
}
// 非文件类型已由 remoteMirrorEntryName 过滤;目录在上方完成递归后返回。
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: fileID,
Hash: it.hash(),
Size: it.size(),
ModifiedTime: modMillis,
ModifiedTimeValid: modValid,
}
}
if token == "" {
break
}
if _, exists := seenTokens[token]; exists {
return fmt.Errorf("远端目录 %q 的分页 token 重复,疑似分页循环,已中止", relBase)
}
seenTokens[token] = struct{}{}
nextToken = token
}
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
}
// 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) {
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
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", "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") }
// 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 map[string]json.RawMessage
if err := json.Unmarshal([]byte(text), &envelope); err != nil {
return nil, "", fmt.Errorf("解析 list_files 返回失败: %w", err)
}
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, error) {
items := make([]driveItem, 0, len(ms))
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, 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:[]} 也不是数组")
}
// 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 校验单个远端条目名称是否可安全用作一层相对路径段。
// 拒绝空串、"."、".."、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
}
// 余下的卷标/规范化检查只在 Windows 上可达:Unix 的 filepath.VolumeName 恒为空,
// 且经上面的分隔符过滤后 filepath.Clean 不会再改写名称。按平台分文件承载。
return isSafeRemoteSegmentPlatform(name)
}
// 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 严格变短,要么越界后由下面的 ".."
// 分支退出,要么下降到 absDir 自身——absDir 必然存在(EvalSymlinks 刚成功),于是
// 在 Lstat 成功分支退出。即便 absDir 在此期间被删除,下一轮 ancestor 升到 absDir
// 之上,同样由 ".." 分支退出。
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
}
ancestor = filepath.Dir(ancestor)
}
}
// md5File 计算文件内容的 MD5,并按 base64 编码返回(与钉盘 list_files 返回的
// md5 字段编码一致,便于直接字符串比较)。变量形式只用于测试确定性注入读取失败;
// 测试必须通过 testseam.Swap 替换并自动恢复。
var md5File = func(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
}
// statusRootLstat 是根类型校验的注入 seam;测试通过 testseam.Swap 注入不同的
// FileInfo 来复现 Windows 上无法创建的符号链接场景。
var statusRootLstat = os.Lstat
// statusWalkDir 是本地目录遍历的注入 seam。改造根类型校验后,filepath.WalkDir
// 的错误分支(walkLocalTreeEntry 之外的外层错误)在 macOS/Windows 上都难以自然
// 触发,需要 seam 让该分支可以确定性回归。
var statusWalkDir = filepath.WalkDir
// 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 = statusWalkDir(root, func(path string, d fs.DirEntry, err error) error {
return walkLocalTreeEntry(root, path, d, err, files)
})
if err != nil {
return nil, err
}
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 {
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"
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 := runDriveStatusWalkLocalTree(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)
}
@@ -0,0 +1,418 @@
package helpers
import (
"context"
"encoding/json"
"errors"
"io"
"io/fs"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"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) 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 "" }
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 <args...>`。
//
// 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()
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())
testseam.Swap(t, &os.Args, append([]string{"dws", "drive"}, args...))
root.SetArgs(append([]string{"drive"}, args...))
return root.Execute()
}
// ──────────────────────────────────────────────────────────
// runDriveStatus — 端到端:本地 + 远端树 → diff
// ──────────────────────────────────────────────────────────
// 端到端 status:递归进入子文件夹、拼接 rel_path、跳过非 file 类型。
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 {
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"}` +
`],"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)
}
}
// 分页 token 未推进意味着远端清单可能被截断,必须失败而不是把当前页当作 EOF。
func TestCrossPlatformCoverageDriveStatus_stalledPaginationTokenFailsClosed(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 → 未推进,必须 fail closed。
return `{"result":{"items":[{"name":"b.txt","type":"file","fileId":"B"}],"nextToken":"T1"}}`, nil
}
return `{"result":{"items":[],"nextToken":""}}`, nil
}}
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 fail after 2 calls, got %d", got)
}
}
// space-id 显式传入时必须透传到 list_files。
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
}}
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 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 {
t.Fatal("expected list_files error to propagate")
}
}
// 返回体无法解析 → status 报解析失败。
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
}}
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 TestCrossPlatformCoverageDriveStatus_unreadableLocalFileFailsExactCompare(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "secret.txt")
mustWrite(t, p, "data")
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
}}
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)
}
}
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) {
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 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
}}
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(), "循环引用") {
t.Fatalf("expected depth-limit abort, got %v", err)
}
}
// ──────────────────────────────────────────────────────────
// isSafeRemoteSegment / resolveLocalTarget 的剩余分支
// ──────────────────────────────────────────────────────────
func TestCrossPlatformCoverageIsSafeRemoteSegment_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 TestCrossPlatformCoverageMD5File_missingFile(t *testing.T) {
if _, err := md5File(filepath.Join(t.TempDir(), "nope.bin")); err == nil {
t.Fatal("expected error for missing file")
}
}
func TestCrossPlatformCoverageMD5File_directoryIsNotReadable(t *testing.T) {
// 目录不是常规文件:io.Copy 读取时必然报错。
if _, err := md5File(t.TempDir()); err == nil {
t.Fatal("expected error when hashing a directory")
}
}
func TestCrossPlatformCoverageWalkLocalTree_missingRootErrors(t *testing.T) {
if _, err := walkLocalTree(filepath.Join(t.TempDir(), "absent")); err == nil {
t.Fatal("expected error for missing local root")
}
}
// 根路径是指向目录的符号链接时,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)
}
}
// 根类型校验通过后 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")
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()
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 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)
}
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 TestCrossPlatformCoverageDriveItem_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)
}
}
+400
View File
@@ -0,0 +1,400 @@
package helpers
import (
"os"
"path/filepath"
"testing"
)
// ──────────────────────────────────────────────────────────
// validateLocalDirAbs — 本地路径校验(必须为绝对路径)
// ──────────────────────────────────────────────────────────
func TestCrossPlatformCoverageValidateLocalDirAbs_empty(t *testing.T) {
if _, err := validateLocalDirAbs(""); err == nil {
t.Fatal("expected error for empty --local-folder")
}
}
func TestCrossPlatformCoverageValidateLocalDirAbs_relative(t *testing.T) {
if _, err := validateLocalDirAbs(filepath.Join(".", "repo")); err == nil {
t.Fatal("expected error for relative path")
}
}
func TestCrossPlatformCoverageValidateLocalDirAbs_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 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")
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 TestCrossPlatformCoverageCompareTrees_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 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)
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 TestCrossPlatformCoverageCompareTrees_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 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},
"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 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==
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 TestCrossPlatformCoverageCompareTrees_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 TestCrossPlatformCoverageCompareTrees_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 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 {
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 TestCrossPlatformCoverageParseDriveList_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 TestCrossPlatformCoverageParseDriveList_invalid(t *testing.T) {
if _, _, err := parseDriveList("not json"); err == nil {
t.Fatal("expected error for invalid JSON")
}
}
// ──────────────────────────────────────────────────────────
// driveItem — 字段访问器(带 fallback)
// ──────────────────────────────────────────────────────────
func TestCrossPlatformCoverageDriveItem_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 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")
}
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 TestCrossPlatformCoverageToMillis(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)
}
})
}
}
+8
View File
@@ -0,0 +1,8 @@
//go:build !windows
package helpers
// isSafeRemoteSegmentPlatform 在非 Windows 平台没有额外约束:filepath.VolumeName
// 恒为空字符串,而调用方已过滤掉 ""、"."、".." 与任意平台分隔符,剩下的名称
// filepath.Clean 不会再改写。
func isSafeRemoteSegmentPlatform(string) bool { return true }
+52
View File
@@ -0,0 +1,52 @@
//go:build windows
package helpers
import (
"path/filepath"
"strings"
)
// isSafeRemoteSegmentPlatform 承载 Windows 专有的单层名称校验。
//
// 除盘符与 Clean 改写外,还拒绝 Win32 非法字符、尾随点/空格以及设备保留名。
// 这些名称即使不造成词法逃逸,也可能无法落盘、落到 ADS,或与另一个名称映射到
// 同一 Win32 对象;文件夹镜像必须在构建权威远端树时统一 fail closed。
func isSafeRemoteSegmentPlatform(name string) bool {
if filepath.VolumeName(name) != "" {
return false
}
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])
}
+684
View File
@@ -0,0 +1,684 @@
package helpers
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"github.com/spf13/cobra"
)
// keep-both 的本地保留副本通过同一个固定父目录 os.Root 原子建立硬链接;以下 seam
// 仅用于文件系统失败与并发替换的确定性测试,测试必须通过 testseam.Swap 替换。
var (
syncRootLink = func(root *os.Root, oldName, newName string) error { return root.Link(oldName, newName) }
syncPullOneFilePinned = pullOneFilePinned
)
// ==========================================================
// 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 解决:
// skip 默认:两侧都不动并保留两边内容
// 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" // 交互式逐个询问
syncConflictSkip = "skip" // 默认:两侧都变更时什么都不做,两边内容都保留
)
// 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"`
}
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 }
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 = syncConflictSkip
}
switch onConflict {
case syncConflictSkip, syncConflictLocalWins, syncConflictRemoteWins, syncConflictKeepBoth, syncConflictAsk:
default:
return fmt.Errorf("--on-conflict 取值非法: %s(可选 skip|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
}
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()
// 远端现状:文件(含 hash/mtime/fileId)与目录(rel_path → fileId,"" 即根本身)。
remoteFiles, remoteFolders, err := fetchRemoteTreeForPush(ctx, spaceID, remoteDirID)
if err != nil {
return 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
}
}
for rel := range localByRel {
if msg := pushTypeConflictError(rel, false, remoteFiles, remoteFolders); msg != "" {
typeConflicts[rel] = msg
}
}
// 计算 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 {
if syncPathBlockedByTypeConflict(rel, typeConflicts) {
continue
}
rf, ok := remoteFiles[rel]
if !ok {
newLocal = append(newLocal, rel)
continue
}
verdict, jerr := judgePinnedPushFileMatch(pinnedRoot, pf, 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 syncPathBlockedByTypeConflict(rel, typeConflicts) {
continue
}
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{}}
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],
})
}
if err := pinnedRoot.verify(); err != nil {
return err
}
// --dry-run:只算差异、不执行任何同步动作。
if deps.Caller.DryRun() {
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。
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
switch strategy {
case syncConflictAsk:
strategy, err = driveSyncAskConflict(rel)
if err != nil {
return err
}
case syncConflictSkip:
strategy = "" // 与 ask 选择「跳过」同一条落地路径
}
resolutions[rel] = strategy
}
// 交互决策期间本地根也可能被替换;首个双端写动作前再次核对固定身份。
if err := pinnedRoot.verify(); err != nil {
return err
}
// 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
}
if err := pinnedRoot.verify(); err != nil {
return err
}
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 {
syncPullFilePinned(&res, ctx, spaceID, remoteFiles[rel], pinnedRoot, rel, syncDirectionPull)
}
// 阶段 3:new_local 上传到远端。
for _, rel := range newLocal {
pf := localByRel[rel]
syncPushFile(&res, ctx, spaceID, remoteFolders, pinnedRoot, pf, rel, "")
}
// 阶段 4:modified 按 --on-conflict 解决。
for _, rel := range modified {
pf := localByRel[rel]
rf := remoteFiles[rel]
switch resolutions[rel] {
case syncConflictRemoteWins:
syncPullFilePinned(&res, ctx, spaceID, rf, pinnedRoot, rel, syncDirectionPull)
case syncConflictLocalWins:
syncPushFile(&res, ctx, spaceID, remoteFolders, pinnedRoot, pf, rel, rf.FileID)
case syncConflictKeepBoth:
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})
}
}
// 结构化结果始终打印到 stdout;有失败则额外以非零退出码退出。
if perr := deps.Out.PrintJSON(res); perr != nil {
return perr
}
if res.Summary.Failed > 0 {
return &driveSyncFailure{failed: res.Summary.Failed}
}
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
}
// 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, 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: err.Error()})
return
}
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})
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, root *pinnedPullRoot, 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 := 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
}
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 策略:先为本地文件原子建立带冲突后缀
// 的 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: err.Error()})
return
}
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
}
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
}
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: "本地保留版本身份不一致"})
return
}
occupied[suffixedRel] = true
action, perr := syncPullOneFilePinned(ctx, spaceID, rf, pinned, ifExistsOverwrite)
if action != pullActionDownloaded {
res.Summary.Failed++
msg := ""
if perr != nil {
msg = perr.Error()
}
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},
driveSyncItem{RelPath: rel, Action: syncActionDownloaded, Direction: syncDirectionPull},
)
}
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 {
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 生成候选并通过 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 // 本次运行内已知占用,快速跳过
}
candidateParent, candidateName := splitRel(cand)
if candidateParent != parentRel || !isSafeRemoteSegment(candidateName) {
return "", "", fmt.Errorf("keep-both 候选路径 %q 不在原目标目录内", cand)
}
if err := target.verifyParent(); err != nil {
return "", "", err
}
oerr := syncRootLink(target.parentRoot, target.name, filepath.FromSlash(candidateName))
if oerr == nil {
return cand, filepath.FromSlash(candidateName), 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))
}
}
@@ -0,0 +1,416 @@
package helpers
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
// 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
}}
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)
})
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
})
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
swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error {
t.Error("dry-run must not download")
return nil
})
testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error {
t.Error("dry-run must not upload")
return 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")
}
}
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
swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error {
t.Error("dry-run must not download")
return nil
})
var out bytes.Buffer
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 {
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
testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error {
t.Error("dry-run must not upload")
return nil
})
var out bytes.Buffer
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 {
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
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 {
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"}, false); 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
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"},
"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. 明确确认后才发出精确的工具调用
// ──────────────────────────────────────────────────────────
// 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}`)
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
return os.WriteFile(dest, []byte("remote-new"), 0o644)
})
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}`)
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
})
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))
}
}

Some files were not shown because too many files have changed in this diff Show More