Files
dingtalk-workspace-cli/internal/helpers/shell_quote.go
T
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

86 lines
4.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}