From a6f69a06ced798801c586fdef4ae91a15e4e8a9e Mon Sep 17 00:00:00 2001 From: chichuan Date: Fri, 14 Aug 2026 11:58:18 +0800 Subject: [PATCH] =?UTF-8?q?fix(drive):=20--latest=20=E6=89=AB=E6=8F=8F?= =?UTF-8?q?=E4=B8=8D=E5=AE=8C=E6=95=B4=E6=97=B6=E6=8B=92=E7=BB=9D=E4=BA=A7?= =?UTF-8?q?=E5=87=BA=20Top-N=20=E5=B9=B6=E6=9D=9C=E7=BB=9D=20sortTime=20?= =?UTF-8?q?=E6=B3=84=E9=9C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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。 --- .changes/899-drive-latest-incomplete-scan.md | 20 + internal/helpers/drive.go | 2 +- internal/helpers/drive_depth.go | 217 +++++- .../helpers/drive_latest_incomplete_test.go | 731 ++++++++++++++++++ internal/helpers/drive_latest_scope_posix.go | 13 + .../helpers/drive_latest_scope_windows.go | 13 + internal/helpers/shell_quote.go | 85 ++ .../shell_quote_roundtrip_unix_test.go | 76 ++ internal/helpers/shell_quote_test.go | 185 +++++ skills/mono/references/products/drive.md | 6 + .../multi/dingtalk-drive/references/drive.md | 6 + 11 files changed, 1347 insertions(+), 7 deletions(-) create mode 100644 .changes/899-drive-latest-incomplete-scan.md create mode 100644 internal/helpers/drive_latest_incomplete_test.go create mode 100644 internal/helpers/drive_latest_scope_posix.go create mode 100644 internal/helpers/drive_latest_scope_windows.go create mode 100644 internal/helpers/shell_quote.go create mode 100644 internal/helpers/shell_quote_roundtrip_unix_test.go create mode 100644 internal/helpers/shell_quote_test.go diff --git a/.changes/899-drive-latest-incomplete-scan.md b/.changes/899-drive-latest-incomplete-scan.md new file mode 100644 index 00000000..cc16c576 --- /dev/null +++ b/.changes/899-drive-latest-incomplete-scan.md @@ -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. diff --git a/internal/helpers/drive.go b/internal/helpers/drive.go index da4509ab..08243328 100644 --- a/internal/helpers/drive.go +++ b/internal/helpers/drive.go @@ -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)(可选)") diff --git a/internal/helpers/drive_depth.go b/internal/helpers/drive_depth.go index 09432f7c..a10c28bd 100644 --- a/internal/helpers/drive_depth.go +++ b/internal/helpers/drive_depth.go @@ -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,两者都空回落 。 +// +// 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 = "" + } + 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) } diff --git a/internal/helpers/drive_latest_incomplete_test.go b/internal/helpers/drive_latest_incomplete_test.go new file mode 100644 index 00000000..f2894056 --- /dev/null +++ b/internal/helpers/drive_latest_incomplete_test.go @@ -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 也空回落 。 + 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=") { + t.Fatalf("fallback to : %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:] +} diff --git a/internal/helpers/drive_latest_scope_posix.go b/internal/helpers/drive_latest_scope_posix.go new file mode 100644 index 00000000..a92ff0d5 --- /dev/null +++ b/internal/helpers/drive_latest_scope_posix.go @@ -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) +} diff --git a/internal/helpers/drive_latest_scope_windows.go b/internal/helpers/drive_latest_scope_windows.go new file mode 100644 index 00000000..f921a456 --- /dev/null +++ b/internal/helpers/drive_latest_scope_windows.go @@ -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) +} diff --git a/internal/helpers/shell_quote.go b/internal/helpers/shell_quote.go new file mode 100644 index 00000000..a06f78f5 --- /dev/null +++ b/internal/helpers/shell_quote.go @@ -0,0 +1,85 @@ +package helpers + +import "strings" + +// shellQuoteArg 按 POSIX sh 规则把 s 引用成可安全放进可复制命令的单个 argv 元素。 +// +// 为什么需要它:错误提示里的恢复命令是给用户直接复制到 shell 执行的,其中的查询域取自用户 +// 输入(--workspace 的常见形态就是带查询串的 URL)。裸拼接下,合法 URL 里的 `&` 就会把命令 +// 拆成后台任务,空格会拆参,`;` 与 `$()` 还能执行额外内容。 +// +// 为什么不用 strconv.Quote(internal/auth 侧展示 profile 标识的既有做法):那是 Go 语法引号, +// 产出双引号串,而 shell 双引号内 `$()`、反引号、`$VAR` 仍会展开——正是要防的场景。auth 那处 +// 成立是因为它显式声明「仅作数据展示,不是可执行命令」,本函数的产物恰恰要能执行。 +// +// 策略是「必要时才引用」:全由安全字符组成时原样返回,命令保持可读、在 PowerShell/cmd 下同样 +// 可复制;只要含一个非安全字符就整体单引号包裹——单引号内 POSIX sh 不做任何展开($ ` \ ! 全部 +// 字面化),是唯一无需逐字符转义的形式。单引号自身无法出现在单引号串内,按 POSIX 标准写法 +// 先闭合、拼一个反斜杠转义的单引号、再重开——即把每个单引号替换成下面这四个字符: +// +// '\'' +// +// 空串必须显式引用成一对空单引号,否则该参数会从 argv 里整个消失,后面的 token 会被前一个 +// flag 吞掉。 +func shellQuoteArg(s string) string { + if s == "" { + return "''" + } + if shellValueIsBare(s) { + return s + } + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// shellValueIsBare 报告 s 能否原样出现在命令行里而不改变**任何**常见 shell 的解析:非空, +// 且每个字符都落在保守白名单内。这类值在 POSIX sh、PowerShell 与 cmd.exe 下含义一致, +// 无需引用即可内联进可复制命令。 +func shellValueIsBare(s string) bool { + return s != "" && !strings.ContainsFunc(s, shellNeedsQuote) +} + +// driveLatestPosixScopeValue 是 POSIX shell 下把用户值放进可复制命令的策略:单引号内 sh 不做 +// 任何展开,因此含元字符的值引用后即可安全内联。第二个返回值恒为 true。 +func driveLatestPosixScopeValue(value string) (string, bool) { + return shellQuoteArg(value), true +} + +// driveLatestWindowsScopeValue 是 Windows 构建下的策略:只内联本身就安全的值,其余一律不进 +// 命令,由调用方降级为占位符 + 展示行。 +// +// Windows 上不存在「一条命令同时对 cmd.exe 与 PowerShell 都安全」的引用形式: +// - cmd.exe 根本不把单引号当引号,`--space-id 'sp-7&whoami'` 里的 & 仍然分隔命令, +// 粘贴即执行 whoami; +// - cmd.exe 的双引号能挡 & | < >,却挡不住 %VAR% 展开; +// - PowerShell 的单引号是引号,但内嵌单引号写作两个连续单引号,与 POSIX 的 +// 闭合-反斜杠转义-重开写法不兼容。 +// +// 而生成命令时无法知道用户会粘贴进哪个 shell。既然引用不可靠,就不把不受信任的值放进可执行 +// 命令——与 internal/auth 侧「标识仅作数据展示,不是可执行命令」的既有做法同一思路。 +func driveLatestWindowsScopeValue(value string) (string, bool) { + if shellValueIsBare(value) { + return value, true + } + return "", false +} + +// shellNeedsQuote 报告 r 是否落在「无需引用」白名单之外。 +// +// 用白名单而非黑名单:漏掉一个元字符就是一个注入口,而白名单漏一个字符只会多加一对无害的 +// 引号。非 ASCII 一律视为需引用——中文目录名很常见,保守处理不会错。 +// +// 白名单的门槛是「在 POSIX sh、PowerShell、cmd.exe 下含义都一致」,因此 % 被刻意排除: +// cmd.exe 会展开 %VAR%,一个看似普通的值(URL 里的 %20、含 %PATH% 的名字)原样内联后在 +// cmd 里就会变形甚至泄露环境变量。@ 保留:PowerShell 的 splatting 只在 @( 、@{ 与 +// @变量名 形态下生效,而那些字符本身都不在白名单里,且值总出现在 --flag 之后而非语句开头。 +func shellNeedsQuote(r rune) bool { + switch { + case r >= 'a' && r <= 'z': + return false + case r >= 'A' && r <= 'Z': + return false + case r >= '0' && r <= '9': + return false + } + return !strings.ContainsRune("_@+=:,./-", r) +} diff --git a/internal/helpers/shell_quote_roundtrip_unix_test.go b/internal/helpers/shell_quote_roundtrip_unix_test.go new file mode 100644 index 00000000..9afc5213 --- /dev/null +++ b/internal/helpers/shell_quote_roundtrip_unix_test.go @@ -0,0 +1,76 @@ +//go:build !windows + +package helpers + +import ( + "os/exec" + "testing" +) + +// TestCrossPlatformCoverageShellQuoteArgRoundTrip 把引用后的串交给真 /bin/sh 求值,证明它被解析回 +// **恰好一个、且内容完全相同**的参数。上面那条表驱动测试只能证明「我以为的形态」,这条证明 +// 「shell 认的形态」——两者不是一回事,恢复命令是给用户复制到 shell 里跑的,后者才是契约。 +// +// 它同时是注入的负向对照:一旦引用失效,$(id) / `id` 会真的执行、空格与 & 会拆参,argc 或 +// 取回的值必然不等于原值,测试立刻红。用 build tag 排除 Windows(无 POSIX sh),跨平台形态 +// 断言留在 shell_quote_test.go。 +func TestCrossPlatformCoverageShellQuoteArgRoundTrip(t *testing.T) { + sh, err := exec.LookPath("sh") + if err != nil { + t.Skipf("POSIX sh unavailable: %v", err) + } + + values := []string{ + "ws-1", + "https://alidocs.dingtalk.com/i/nodes/x?spaceId=1&type=doc", + "sp 7", + "a;id", + "a&b", + "a|b", + "$(id)", + "`id`", + "$HOME", + "*.txt", + "a>b", + `a\b`, + `a"b`, + "a!b", + "{a,b}", + "(a)", + "~/x", + "a#b", + "it's", + "'", + "a'b'c", + "", + "报表", + "a\tb", + "a\nb", + "--not-a-flag", + } + + for _, want := range values { + t.Run(want, func(t *testing.T) { + quoted := shellQuoteArg(want) + + // 1) 未被拆参也未被吞掉:位置参数个数必须恰好为 1。 + // 引用失效时 "sp 7" 会变 2 个、"" 会变 0 个、$(id) 会变 id 的输出词数。 + argc, err := exec.Command(sh, "-c", "set -- "+quoted+`; printf %s "$#"`).Output() + if err != nil { + t.Fatalf("argc probe failed for %q (quoted %s): %v", want, quoted, err) + } + if string(argc) != "1" { + t.Fatalf("argc = %s, want 1 — %q quoted as %s split or vanished", argc, want, quoted) + } + + // 2) 内容逐字节相同:展开、命令替换、转义都不得改动值。 + got, err := exec.Command(sh, "-c", "set -- "+quoted+`; printf %s "$1"`).Output() + if err != nil { + t.Fatalf("value probe failed for %q (quoted %s): %v", want, quoted, err) + } + if string(got) != want { + t.Fatalf("round-trip mismatch\n input: %q\n quoted: %s\n shell: %q", want, quoted, got) + } + }) + } +} diff --git a/internal/helpers/shell_quote_test.go b/internal/helpers/shell_quote_test.go new file mode 100644 index 00000000..2dacbf7d --- /dev/null +++ b/internal/helpers/shell_quote_test.go @@ -0,0 +1,185 @@ +package helpers + +import ( + "runtime" + "testing" +) + +// TestCrossPlatformCoverageShellQuoteArg 表驱动锁定 argv 引用规则。分两类断言: +// - 安全值原样返回(保持恢复命令可读,且在 PowerShell/cmd 下同样可复制); +// - 含任何 shell 元字符的值整体单引号包裹,内嵌单引号按 POSIX 标准写法处理 +// (闭合、拼反斜杠转义的单引号、重开)。 +// +// 往返正确性另有 Unix 下用真 sh 求值的对照测试(shell_quote_roundtrip_unix_test.go)。 +// +// TestCrossPlatformCoverage 前缀是门禁约定:平台覆盖率门禁只跑该前缀(与 TestAllShortcuts)的 +// 测试,shell_quote.go 的覆盖全靠这一条,去掉前缀会让 Coverage (macOS)/(Windows) 直接红。 +// 详见 drive_latest_incomplete_test.go 头部说明。 +func TestCrossPlatformCoverageShellQuoteArg(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + // —— 无需引用:白名单字符 —— + {name: "plain_id", in: "ws-1", want: "ws-1"}, + {name: "digits", in: "1234567890", want: "1234567890"}, + {name: "upper_lower", in: "abcXYZ", want: "abcXYZ"}, + {name: "all_safe_punct", in: "_@+=:,./-", want: "_@+=:,./-"}, + {name: "path_like", in: "/tmp/a.b/c-d_e", want: "/tmp/a.b/c-d_e"}, + + // —— 必须引用:真实业务形态 —— + // workspace 常见形态就是带查询串的 URL:? 与 & 都不在白名单,& 在裸拼接下会拆命令。 + {name: "url_with_query", in: "https://alidocs.dingtalk.com/i/nodes/x?spaceId=1&type=doc", + want: `'https://alidocs.dingtalk.com/i/nodes/x?spaceId=1&type=doc'`}, + + // —— 必须引用:shell 元字符 —— + {name: "space", in: "sp 7", want: `'sp 7'`}, + {name: "tab", in: "a\tb", want: "'a\tb'"}, + {name: "newline", in: "a\nb", want: "'a\nb'"}, + {name: "semicolon", in: "a;id", want: `'a;id'`}, + {name: "ampersand", in: "a&b", want: `'a&b'`}, + {name: "pipe", in: "a|b", want: `'a|b'`}, + {name: "command_substitution", in: "$(id)", want: `'$(id)'`}, + {name: "backtick", in: "`id`", want: "'`id`'"}, + {name: "variable", in: "$HOME", want: `'$HOME'`}, + // % 不在白名单:cmd.exe 会展开 %VAR%,故含 % 的值一律视为需引用。 + {name: "windows_variable", in: "%PATH%", want: `'%PATH%'`}, + {name: "url_percent_escape", in: "a%20b", want: `'a%20b'`}, + {name: "glob", in: "*.txt", want: `'*.txt'`}, + {name: "redirect", in: "a>b", want: `'a>b'`}, + {name: "backslash", in: `a\b`, want: `'a\b'`}, + {name: "double_quote", in: `a"b`, want: `'a"b'`}, + {name: "history_expansion", in: "a!b", want: `'a!b'`}, + {name: "brace", in: "{a,b}", want: `'{a,b}'`}, + {name: "paren", in: "(a)", want: `'(a)'`}, + {name: "tilde", in: "~/x", want: `'~/x'`}, + {name: "hash", in: "a#b", want: `'a#b'`}, + + // —— 单引号:唯一无法直接放进单引号串的字符 —— + {name: "single_quote", in: "it's", want: `'it'\''s'`}, + {name: "only_single_quote", in: "'", want: `''\'''`}, + {name: "two_single_quotes", in: "a'b'c", want: `'a'\''b'\''c'`}, + + // —— 边界 —— + // 空串必须显式成 '',否则参数会从 argv 里整个消失(--workspace 吞掉下一个 token)。 + {name: "empty", in: "", want: `''`}, + // 非 ASCII 一律引用:保守优于漏字符,中文目录名很常见。 + {name: "cjk", in: "报表", want: `'报表'`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := shellQuoteArg(tc.in); got != tc.want { + t.Fatalf("shellQuoteArg(%q)\n got: %s\nwant: %s", tc.in, got, tc.want) + } + }) + } +} + +// TestCrossPlatformCoverageDriveLatestScopeValueStrategies 直接测两条平台策略的本体。它们是 +// shell_quote.go 里的纯函数、与构建平台无关,因此**在任一平台都能验证 Windows 侧的行为** —— +// 这一点是刻意设计:真 shell 往返测试只能在 Unix 跑,Windows 的安全属性必须由这里钉住, +// 否则「Windows 上不会把不受信任值放进可执行命令」就成了无人复核的断言。 +func TestCrossPlatformCoverageDriveLatestScopeValueStrategies(t *testing.T) { + // 这些值在至少一种目标 shell 下会改变命令解析。 + hostile := []struct { + name string + value string + }{ + {"cmd_command_separator", "sp-7&whoami"}, // cmd.exe:& 分隔命令,单引号不是引号 + {"cmd_variable", "%PATH%"}, // cmd.exe:无条件展开 + {"workspace_url", "https://x/y?a=1&b=2"}, // 合法 workspace 形态,含 & + {"space", "sp 7"}, + {"pwsh_statement_separator", "a;id"}, // PowerShell:; 分隔语句 + {"posix_substitution", "$(id)"}, + {"backtick", "`id`"}, + {"posix_variable", "$HOME"}, + {"pipe", "a|b"}, + {"redirect", "a>b"}, + {"caret", "a^b"}, // cmd.exe 转义符 + {"single_quote", "it's"}, + {"cjk", "报表"}, + {"empty", ""}, + } + for _, tc := range hostile { + t.Run("hostile/"+tc.name, func(t *testing.T) { + // POSIX:始终可内联,但必须是引用后的形态(不能等于原值)。 + got, ok := driveLatestPosixScopeValue(tc.value) + if !ok { + t.Fatalf("POSIX 策略应始终可内联: %q", tc.value) + } + if got == tc.value { + t.Fatalf("POSIX 策略未引用危险值: %q", tc.value) + } + // Windows:一律拒绝内联 —— 没有对 cmd.exe 与 PowerShell 同时安全的引用形式。 + if inline, ok := driveLatestWindowsScopeValue(tc.value); ok { + t.Fatalf("Windows 策略不得内联危险值 %q(得到 %q)", tc.value, inline) + } + }) + } + + // 安全值:两条策略都原样内联,命令保持可读、跨 shell 可复制。 + for _, v := range []string{"ws-1", "sp-7", "folder-root", "modifyTime", "7d", "2026-08-01", "a.b/c-d_e", "a@b"} { + t.Run("bare/"+v, func(t *testing.T) { + if got, ok := driveLatestPosixScopeValue(v); !ok || got != v { + t.Fatalf("POSIX 应原样内联安全值 %q: got %q ok=%v", v, got, ok) + } + if got, ok := driveLatestWindowsScopeValue(v); !ok || got != v { + t.Fatalf("Windows 应原样内联安全值 %q: got %q ok=%v", v, got, ok) + } + }) + } + + // 空串在 POSIX 下引用成一对空单引号(否则参数会从 argv 消失);Windows 侧归入不可内联。 + if got, ok := driveLatestPosixScopeValue(""); !ok || got != "''" { + t.Fatalf("POSIX 空串应引用成一对空单引号: got %q ok=%v", got, ok) + } +} + +// TestCrossPlatformCoverageShellBareCharsetHasNoMetacharacters 锁定「安全值可原样内联」的根本 +// 前提:白名单里不能含任何一种目标 shell 会特殊解释的字符。 +// +// 这比「用某个 shell 跑一遍」更本质 —— 内联路径的安全性不来自引用正确,而来自字符集本身无害; +// 而拒绝内联的路径(Windows 侧)连引用都不需要。三套元字符合并检查:POSIX sh、PowerShell、 +// cmd.exe。`%` 曾因 URL 转义(%20)被误列入白名单,而 cmd.exe 会无条件展开 %VAR%,这条测试 +// 同时是该缺陷的回归锁。 +func TestCrossPlatformCoverageShellBareCharsetHasNoMetacharacters(t *testing.T) { + // 逐字符列出,避免用字符串字面量时漏掉转义细节。 + meta := []rune{ + '&', '|', '<', '>', ';', '(', ')', '{', '}', '[', ']', // 分隔/分组 + '$', '`', '"', '\'', '\\', // 引用与替换 + '^', '%', // cmd.exe:转义符与变量展开 + '*', '?', '~', '#', '!', // 通配、家目录、注释、历史/取反 + ' ', '\t', '\n', '\r', // 空白:拆参与换行注入 + } + for _, r := range meta { + if !shellNeedsQuote(r) { + t.Fatalf("shell 元字符 %q 落在「无需引用」白名单内,安全值会被原样内联", string(r)) + } + } + // 反向哨兵:常规标识符字符必须留在白名单内,否则恢复命令会被无谓地全量引用/降级。 + for _, r := range []rune{'a', 'Z', '0', '9', '_', '-', '.', '/', ':', ',', '=', '+', '@'} { + if shellNeedsQuote(r) { + t.Fatalf("常规字符 %q 被判为需引用,会让恢复命令可读性无谓下降", string(r)) + } + } +} + +// TestCrossPlatformCoverageDriveLatestScopeValueBinding 断言当前构建绑定到了正确的策略。 +// 这条测试在每个平台各自成立,把「哪个平台用哪条策略」也纳入 CI(Coverage (Windows) 会跑它)。 +func TestCrossPlatformCoverageDriveLatestScopeValueBinding(t *testing.T) { + const hostile = "sp-7&whoami" + inline, ok := driveLatestScopeValue(hostile) + if runtime.GOOS == "windows" { + if ok { + t.Fatalf("windows 构建不得内联 %q(得到 %q)", hostile, inline) + } + return + } + if !ok { + t.Fatalf("posix 构建应可内联 %q", hostile) + } + if inline != `'sp-7&whoami'` { + t.Fatalf("posix 构建应单引号包裹: %s", inline) + } +} diff --git a/skills/mono/references/products/drive.md b/skills/mono/references/products/drive.md index 8bed0f87..63f9b30d 100644 --- a/skills/mono/references/products/drive.md +++ b/skills/mono/references/products/drive.md @@ -67,6 +67,12 @@ Flags: - 输出形态:带过滤时输出从单页透传变为聚合形态 `{items, maxDepth, truncated, errors}`。 - 已知代价:大目录(>2000 条)触顶截断时 `truncated=true`(退出码 0,结果每条都正确但没扫完); 建议用 `--folder` 指定子目录缩小扫描范围;带关键词的过滤场景改用 `dws drive search`。 +- 与 `--latest` 组合时上一条不适用:排序基不完整的 Top-N 不是全局最新,故触顶截断**或**递归途中 + 目录读取失败都拒绝产出并报错(`LATEST_SCAN_TRUNCATED` / `LATEST_SCAN_INCOMPLETE`),不会以 + 退出码 0 交出结果;错误消息里带首个失败目录的 folder/depth/reason,以及一条复现原候选集 + (查询域 + `--folder` + `--pattern`/`--type`/`--start`/`--end`)的恢复命令。Windows 构建下, + 若原值含 shell 元字符则命令里只给占位符、原值另起一行以数据形式列出(cmd.exe 与 PowerShell + 没有共同安全的引用形式),照抄时需手动替换。 ### 获取钉盘空间列表 diff --git a/skills/multi/dingtalk-drive/references/drive.md b/skills/multi/dingtalk-drive/references/drive.md index 8d921447..5da0ba4a 100644 --- a/skills/multi/dingtalk-drive/references/drive.md +++ b/skills/multi/dingtalk-drive/references/drive.md @@ -61,6 +61,12 @@ Flags: - 输出形态:带过滤时输出从单页透传变为聚合形态 `{items, maxDepth, truncated, errors}`。 - 已知代价:大目录(>2000 条)触顶截断时 `truncated=true`(退出码 0,结果每条都正确但没扫完); 建议用 `--folder` 指定子目录缩小扫描范围;带关键词的过滤场景改用 `dws drive search`。 +- 与 `--latest` 组合时上一条不适用:排序基不完整的 Top-N 不是全局最新,故触顶截断**或**递归途中 + 目录读取失败都拒绝产出并报错(`LATEST_SCAN_TRUNCATED` / `LATEST_SCAN_INCOMPLETE`),不会以 + 退出码 0 交出结果;错误消息里带首个失败目录的 folder/depth/reason,以及一条复现原候选集 + (查询域 + `--folder` + `--pattern`/`--type`/`--start`/`--end`)的恢复命令。Windows 构建下, + 若原值含 shell 元字符则命令里只给占位符、原值另起一行以数据形式列出(cmd.exe 与 PowerShell + 没有共同安全的引用形式),照抄时需手动替换。 ### 获取钉盘空间列表