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

186 lines
8.4 KiB
Go
Raw Permalink 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 (
"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)
}
}