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。
This commit is contained in:
@@ -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.
|
||||
@@ -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)(可选)")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
没有共同安全的引用形式),照抄时需手动替换。
|
||||
|
||||
### 获取钉盘空间列表
|
||||
|
||||
|
||||
@@ -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
|
||||
没有共同安全的引用形式),照抄时需手动替换。
|
||||
|
||||
### 获取钉盘空间列表
|
||||
|
||||
|
||||
Reference in New Issue
Block a user