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。
795 lines
32 KiB
Go
795 lines
32 KiB
Go
package helpers
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"os"
|
||
"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"
|
||
)
|
||
|
||
// ──────────────────────────────────────────────────────────
|
||
// drive list --depth 递归编排(BFS + 限流补偿)
|
||
//
|
||
// 服务端无递归 API,CLI 侧 BFS 过渡方案;服务端递归 API 上线后本块整体退役。
|
||
// 钉盘(list_files)与知识库(list_nodes)共用一份路由无关骨架,差异收敛在 driveDepthRoute。
|
||
// ──────────────────────────────────────────────────────────
|
||
|
||
const (
|
||
// 50 来源于服务端 MAX_PAGE_SIZE 硬校验(超限抛错不 clamp),服务端放宽后仅需修改此常量。
|
||
driveDepthPageSize = 50
|
||
// 来源与 driveDepthPageSize 不同(list_nodes pageSize 上限),数值碰巧相同但不合并。
|
||
docDepthPageSize = 50
|
||
// API 调用数随目录宽度指数增长,超过报错不 clamp。
|
||
driveDepthMax = 5
|
||
driveDepthMaxItems = 2000
|
||
// Sentinel 限流以 HTTP 200 + body errorCode 返回,transport 层不会自动重试,补偿只能在 BFS 层做。
|
||
driveDepthRateLimitedCode = "invalidRequest.rateLimited"
|
||
driveDepthCancelledMsg = "cancelled by user, partial result emitted"
|
||
)
|
||
|
||
type driveDepthFolder struct {
|
||
id string // 钉盘 dentryUuid / 知识库 nodeId
|
||
name string
|
||
depth int
|
||
relPath string
|
||
retried bool // 已因 rate_limited 重新入队过一次,不二次补偿
|
||
// 限流重入队时保留失败页游标:已成功页的条目已进 collected,
|
||
// 从头重扫会产生重复条目并提前耗尽全局 2000 上限。
|
||
pageToken string
|
||
}
|
||
|
||
type driveDepthError struct {
|
||
Depth int `json:"depth"`
|
||
FolderID string `json:"folderId"`
|
||
FolderName string `json:"folderName"`
|
||
Reason string `json:"reason"`
|
||
Message string `json:"message"`
|
||
}
|
||
|
||
// 取消不是错误:不记 errors[]、不复用 1~6 退出码,固定 130(128+SIGINT)。
|
||
type driveDepthCancelledError struct{}
|
||
|
||
func (e *driveDepthCancelledError) Error() string { return driveDepthCancelledMsg }
|
||
func (e *driveDepthCancelledError) RawStderr() string { return driveDepthCancelledMsg }
|
||
func (e *driveDepthCancelledError) ExitCode() int { return 130 }
|
||
|
||
// depth==1 走原有单层路径,仅做范围校验,不触发互斥检查。
|
||
func validateDriveListDepth(cmd *cobra.Command, depth int) error {
|
||
if depth < 1 {
|
||
return &CLIError{
|
||
Code: CodeInvalidParam,
|
||
Message: fmt.Sprintf("--depth 必须为 1~%d 的整数,当前: %d", driveDepthMax, depth),
|
||
}
|
||
}
|
||
if depth > driveDepthMax {
|
||
// 不 clamp:静默 clamp 会让用户误以为拿到了完整树。
|
||
return &CLIError{
|
||
Code: CodeInvalidParam,
|
||
Message: fmt.Sprintf("DEPTH_EXCEED_MAX: --depth 最大为 %d,当前: %d(API 调用数随目录宽度指数增长,不做静默 clamp)", driveDepthMax, depth),
|
||
}
|
||
}
|
||
if depth == 1 {
|
||
return nil
|
||
}
|
||
// --workspace 不互斥:depth>1 时作为路由开关切到知识库 BFS。
|
||
if v := flagOrFallback(cmd, "cursor", "next-token"); v != "" {
|
||
return driveDepthExclusiveError("cursor", "depth>1 时多文件夹合成一份清单,无连续游标")
|
||
}
|
||
if cmd.Flags().Changed("limit") || cmd.Flags().Changed("max") {
|
||
return driveDepthExclusiveError("limit", "递归模式数据量由全局上限 2000 与 --depth 层数控制")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func driveDepthExclusiveError(flag, reason string) error {
|
||
return &CLIError{
|
||
Code: CodeInvalidParam,
|
||
Message: fmt.Sprintf("--depth 不能与 --%s 同时使用:%s", flag, reason),
|
||
}
|
||
}
|
||
|
||
// 路由绑定差异全部收敛在此,BFS 主循环保持路由无关。
|
||
type driveDepthRoute struct {
|
||
serverID string // ""=钉盘自动路由 / "doc"=知识库
|
||
toolName string
|
||
pageSize int
|
||
// 钉盘契约无 hasMore 字段,只信空 nextToken;知识库契约含 hasMore,显式 false 即停。
|
||
// 钉盘的 hasMore 仅用于「hasMore=true 但 token 为空」异常检测。
|
||
hasMoreAuthoritative bool
|
||
buildArgs func(base map[string]any, folderID, pageToken string) map[string]any
|
||
parsePage func(text string) (items []map[string]any, nextToken string, hasMore bool)
|
||
isFolder func(item map[string]any) bool
|
||
itemID func(item map[string]any) string
|
||
}
|
||
|
||
func (r driveDepthRoute) fetchPage(ctx context.Context, args map[string]any) (string, error) {
|
||
if r.serverID == "" {
|
||
return callMCPToolReturnText(ctx, r.toolName, args)
|
||
}
|
||
return callMCPToolReturnTextOnServer(ctx, r.serverID, r.toolName, args)
|
||
}
|
||
|
||
func newDrivePanDepthRoute() driveDepthRoute {
|
||
return driveDepthRoute{
|
||
serverID: "",
|
||
toolName: "list_files",
|
||
pageSize: driveDepthPageSize,
|
||
hasMoreAuthoritative: false,
|
||
buildArgs: func(base map[string]any, folderID, pageToken string) map[string]any {
|
||
args := map[string]any{"maxResults": float64(driveDepthPageSize)}
|
||
for k, v := range base {
|
||
args[k] = v
|
||
}
|
||
if folderID != "" {
|
||
args["parentId"] = folderID
|
||
}
|
||
if pageToken != "" {
|
||
args["nextToken"] = pageToken
|
||
}
|
||
return args
|
||
},
|
||
parsePage: parseDriveDepthPage,
|
||
isFolder: isDriveDepthFolder,
|
||
itemID: func(item map[string]any) string {
|
||
id, _ := item["fileId"].(string)
|
||
return id
|
||
},
|
||
}
|
||
}
|
||
|
||
// folderId 为空 = 知识库根。
|
||
func newDocDepthRoute() driveDepthRoute {
|
||
return driveDepthRoute{
|
||
serverID: "doc",
|
||
toolName: "list_nodes",
|
||
pageSize: docDepthPageSize,
|
||
// 字段缺失(契约违背)时按停处理——保守方向,宁可少翻页不空转。
|
||
hasMoreAuthoritative: true,
|
||
buildArgs: func(base map[string]any, folderID, pageToken string) map[string]any {
|
||
args := map[string]any{"pageSize": float64(docDepthPageSize)}
|
||
for k, v := range base {
|
||
args[k] = v
|
||
}
|
||
if folderID != "" {
|
||
args["folderId"] = folderID
|
||
}
|
||
if pageToken != "" {
|
||
args["pageToken"] = pageToken
|
||
}
|
||
return args
|
||
},
|
||
parsePage: parseDocDepthPage,
|
||
isFolder: isDocDepthFolder,
|
||
itemID: func(item map[string]any) string {
|
||
id, _ := item["nodeId"].(string)
|
||
return id
|
||
},
|
||
}
|
||
}
|
||
|
||
// SIGINT 检查两点(出队后发首页前 + 翻页循环发每页前),入队是纯内存操作不检查。
|
||
// filter 零值 = 未启用;启用时由 emitDriveDepthResult 管线在 pattern 之后、latest 之前筛。
|
||
func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool, latest int, filter driveListFilter) error {
|
||
if deps.Caller.DryRun() {
|
||
return printDriveDepthDryRun(route, baseArgs, maxDepth)
|
||
}
|
||
|
||
parent := cmd.Context()
|
||
if parent == nil {
|
||
parent = context.Background()
|
||
}
|
||
ctx, stop := signal.NotifyContext(parent, os.Interrupt)
|
||
defer stop()
|
||
|
||
// 兜底服务端「空页 + 非空游标」类 bug:该形态下条目不增长、全局 2000 永不触发。
|
||
// 不用「token 不推进即停」:兜不住每页 token 都不同的空页流。
|
||
maxPagesPerFolder := driveDepthMaxItems/route.pageSize + 1
|
||
|
||
traceID := fmt.Sprintf("drive-depth-%d", time.Now().UnixNano())
|
||
var (
|
||
collected []map[string]any
|
||
errs = make([]driveDepthError, 0)
|
||
truncated bool
|
||
visited = map[string]bool{}
|
||
)
|
||
queue := []driveDepthFolder{{id: rootFolderID, depth: 0}}
|
||
if rootFolderID != "" {
|
||
visited[rootFolderID] = true
|
||
}
|
||
|
||
bfs:
|
||
for len(queue) > 0 {
|
||
if ctx.Err() != nil {
|
||
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth, route, filter)
|
||
}
|
||
folder := queue[0]
|
||
queue = queue[1:]
|
||
|
||
start := time.Now()
|
||
var folderErr error
|
||
pageToken := folder.pageToken
|
||
pages := 0
|
||
for {
|
||
if ctx.Err() != nil {
|
||
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth, route, filter)
|
||
}
|
||
pages++
|
||
if pages > maxPagesPerFolder {
|
||
folderErr = &CLIError{
|
||
Code: CodeMCPToolError,
|
||
Message: fmt.Sprintf("pagination anomaly: folder exceeded %d pages with non-empty nextToken, cursor loop suspected, directory may be truncated", maxPagesPerFolder),
|
||
}
|
||
break
|
||
}
|
||
args := route.buildArgs(baseArgs, folder.id, pageToken)
|
||
text, err := route.fetchPage(ctx, args)
|
||
if ctx.Err() != nil {
|
||
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth, route, filter)
|
||
}
|
||
if err != nil {
|
||
folderErr = err
|
||
break
|
||
}
|
||
items, next, hasMore := route.parsePage(text)
|
||
for _, item := range items {
|
||
name, _ := item["name"].(string)
|
||
rel := name
|
||
if folder.relPath != "" {
|
||
rel = folder.relPath + "/" + name
|
||
}
|
||
item["depth"] = folder.depth + 1
|
||
item["parentId"] = folder.id // 根级为空串
|
||
item["rel_path"] = rel // 不保证唯一,组树以 parentId 为准
|
||
// 时间戳归一:钉盘 modifyTime / 知识库 updateTime 统一为 sortTime(毫秒 int64)
|
||
if ms, ok := driveItemModifiedMillis(item); ok {
|
||
item["sortTime"] = ms
|
||
} else {
|
||
item["sortTime"] = int64(0)
|
||
}
|
||
collected = append(collected, item)
|
||
if len(collected) >= driveDepthMaxItems {
|
||
// 未访问目录不记 errors[](没失败只是没扫),避免 errors 数组被淹没
|
||
truncated = true
|
||
break
|
||
}
|
||
if route.isFolder(item) && folder.depth+1 < maxDepth {
|
||
if id := route.itemID(item); id != "" && !visited[id] {
|
||
// 重复条目静默丢弃
|
||
visited[id] = true
|
||
queue = append(queue, driveDepthFolder{id: id, name: name, depth: folder.depth + 1, relPath: rel})
|
||
}
|
||
}
|
||
}
|
||
if truncated {
|
||
break bfs
|
||
}
|
||
if hasMore && next == "" {
|
||
// 不静默截断——显式落 api_error,让消费方感知数据不完整。
|
||
folderErr = &CLIError{
|
||
Code: CodeMCPToolError,
|
||
Message: "pagination anomaly: hasMore=true but nextToken is empty, directory may be truncated",
|
||
}
|
||
break
|
||
}
|
||
if next == "" {
|
||
break
|
||
}
|
||
if route.hasMoreAuthoritative && !hasMore {
|
||
break
|
||
}
|
||
pageToken = next
|
||
}
|
||
|
||
slog.Info("drive list depth folder done",
|
||
"traceId", traceID, "depth", folder.depth, "folderId", folder.id,
|
||
"latency", time.Since(start).Milliseconds(), "failed", folderErr != nil)
|
||
|
||
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 {
|
||
return emitErr
|
||
}
|
||
return folderErr
|
||
}
|
||
if driveDepthErrorCode(folderErr) == driveDepthRateLimitedCode && !folder.retried {
|
||
// 限流命中在入口处、失败页本身无副作用;但此前成功页已进 collected,
|
||
// 必须从失败页游标续扫而非整目录重扫。不加 sleep/退避。
|
||
folder.retried = true
|
||
folder.pageToken = pageToken
|
||
queue = append(queue, folder)
|
||
continue
|
||
}
|
||
if folder.depth == 0 && len(collected) == 0 {
|
||
return folderErr
|
||
}
|
||
// 403 / transport 重试耗尽 / 限流重入队仍失败:记 errors[] 跳过
|
||
errs = append(errs, newDriveDepthError(folder, folderErr))
|
||
continue
|
||
}
|
||
|
||
if !quiet {
|
||
display := folder.name
|
||
if display == "" {
|
||
display = folder.id
|
||
}
|
||
if display == "" {
|
||
display = "<root>"
|
||
}
|
||
fmt.Fprintf(os.Stderr, "[drive-list] depth=%d folder=%s done\n", folder.depth, display)
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
return &driveDepthCancelledError{}
|
||
}
|
||
|
||
// depth>1 不输出 nextToken。
|
||
func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string, latest, reqDepth int, route driveDepthRoute, filter driveListFilter) error {
|
||
if pattern != "" {
|
||
// 先递归后过滤,过滤仅作用于输出项,不阻止文件夹下钻
|
||
filtered := make([]map[string]any, 0, len(items))
|
||
for _, item := range items {
|
||
name, _ := item["name"].(string)
|
||
if name == "" {
|
||
name, _ = item["fileName"].(string)
|
||
}
|
||
if matchDriveNamePattern(name, pattern) {
|
||
filtered = append(filtered, item)
|
||
}
|
||
}
|
||
items = filtered
|
||
}
|
||
if filter.active() {
|
||
// 类型(route.isFolder 反判)+ 时间(直读收集段注入的 sortTime 毫秒)区间筛
|
||
items = applyDriveListFilter(items, route, filter)
|
||
}
|
||
if latest > 0 {
|
||
// --type folder 时 latest 对过滤后的目录取 Top-N,避免「先留目录再剔目录」的空结果。
|
||
items = applyDriveListLatest(items, latest, filter.nodeType == "folder")
|
||
} else {
|
||
// 排列为 rel_path 树序:BFS 只决定截断时哪些条目入选,树序决定呈现顺序。
|
||
sort.SliceStable(items, func(i, j int) bool {
|
||
ri, _ := items[i]["rel_path"].(string)
|
||
rj, _ := items[j]["rel_path"].(string)
|
||
if ri != rj {
|
||
return ri < rj
|
||
}
|
||
return driveDepthItemID(items[i]) < driveDepthItemID(items[j])
|
||
})
|
||
}
|
||
maxDepth := 0
|
||
for _, item := range items {
|
||
if d, ok := item["depth"].(int); ok && d > maxDepth {
|
||
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)
|
||
}
|
||
if items == nil {
|
||
items = []map[string]any{}
|
||
}
|
||
return deps.Out.PrintJSON(map[string]any{
|
||
"items": items,
|
||
"maxDepth": maxDepth,
|
||
"truncated": truncated,
|
||
"errors": errs,
|
||
})
|
||
}
|
||
|
||
// 不输出预估调用次数:零调用下平均子文件夹数不可知,硬编码上界会被误读为真实估算。
|
||
func printDriveDepthDryRun(route driveDepthRoute, baseArgs map[string]any, maxDepth int) error {
|
||
if deps.Caller.Format() == "json" {
|
||
return deps.Out.PrintJSON(map[string]any{
|
||
"dry_run": true,
|
||
"executed": false,
|
||
"tool": route.toolName,
|
||
"baseArgs": baseArgs,
|
||
"maxDepth": maxDepth,
|
||
"pageSize": route.pageSize,
|
||
"totalLimit": driveDepthMaxItems,
|
||
"warning": "调用数随目录宽度指数增长",
|
||
})
|
||
}
|
||
bold := color.New(color.FgYellow, color.Bold)
|
||
bold.Println("[DRY-RUN] Preview only, not executed:")
|
||
deps.Out.PrintKeyValue("Tool", route.toolName)
|
||
argsJSON, _ := json.MarshalIndent(baseArgs, " ", " ")
|
||
deps.Out.PrintKeyValue("baseArgs", "\n "+string(argsJSON))
|
||
deps.Out.PrintKeyValue("maxDepth", fmt.Sprintf("%d", maxDepth))
|
||
deps.Out.PrintKeyValue("每页条数", fmt.Sprintf("%d", route.pageSize))
|
||
deps.Out.PrintKeyValue("全局上限", fmt.Sprintf("%d", driveDepthMaxItems))
|
||
deps.Out.PrintWarning("调用数随目录宽度指数增长")
|
||
return nil
|
||
}
|
||
|
||
// 钉盘契约无 hasMore 字段,hasMoreExplicit 仅供「hasMore=true 但 token 为空」异常检测。
|
||
func parseDriveDepthPage(text string) (items []map[string]any, nextToken string, hasMoreExplicit bool) {
|
||
var body map[string]any
|
||
if json.Unmarshal([]byte(text), &body) != nil {
|
||
return nil, "", false
|
||
}
|
||
target := body
|
||
if inner, ok := body["result"].(map[string]any); ok && driveDepthListItemsKey(inner) != "" {
|
||
target = inner
|
||
}
|
||
key := driveDepthListItemsKey(target)
|
||
if key == "" {
|
||
return nil, "", false
|
||
}
|
||
arr, _ := target[key].([]any)
|
||
for _, it := range arr {
|
||
if m, ok := it.(map[string]any); ok {
|
||
items = append(items, m)
|
||
}
|
||
}
|
||
nextToken, _ = target["nextToken"].(string)
|
||
hasMoreExplicit, _ = target["hasMore"].(bool)
|
||
return items, nextToken, hasMoreExplicit
|
||
}
|
||
|
||
func driveDepthListItemsKey(m map[string]any) string {
|
||
for _, k := range []string{"items", "dentryList"} {
|
||
if arr, ok := m[k].([]any); ok && arr != nil {
|
||
return k
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// 非法通配符降级为子串匹配,避免 filepath.Match 报错导致过滤静默失效。
|
||
func matchDriveNamePattern(name, pattern string) bool {
|
||
if pattern == "" {
|
||
return true
|
||
}
|
||
p := pattern
|
||
if !strings.ContainsAny(p, "*?[") {
|
||
p = "*" + p + "*"
|
||
}
|
||
ok, err := filepath.Match(p, name)
|
||
if err != nil {
|
||
return strings.Contains(name, pattern)
|
||
}
|
||
return ok
|
||
}
|
||
|
||
func isDriveDepthFolder(item map[string]any) bool {
|
||
for _, key := range []string{"type", "dentryType"} {
|
||
if v, ok := item[key].(string); ok && strings.EqualFold(v, "FOLDER") {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// list_nodes 游标字段叫 nextPageToken,与钉盘的 nextToken 不同名。
|
||
func parseDocDepthPage(text string) (items []map[string]any, nextToken string, hasMore bool) {
|
||
var body map[string]any
|
||
if json.Unmarshal([]byte(text), &body) != nil {
|
||
return nil, "", false
|
||
}
|
||
arr, _ := body["nodes"].([]any)
|
||
for _, it := range arr {
|
||
if m, ok := it.(map[string]any); ok {
|
||
items = append(items, m)
|
||
}
|
||
}
|
||
nextToken, _ = body["nextPageToken"].(string)
|
||
hasMore, _ = body["hasMore"].(bool)
|
||
return items, nextToken, hasMore
|
||
}
|
||
|
||
func isDocDepthFolder(item map[string]any) bool {
|
||
v, _ := item["nodeType"].(string)
|
||
return strings.EqualFold(v, "folder")
|
||
}
|
||
|
||
func driveDepthItemID(item map[string]any) string {
|
||
for _, key := range []string{"fileId", "nodeId"} {
|
||
if s, ok := item[key].(string); ok && s != "" {
|
||
return s
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func driveDepthUnrecoverable(err error) bool {
|
||
var cliErr *CLIError
|
||
if errors.As(err, &cliErr) {
|
||
switch cliErr.Code {
|
||
case CodeAuthTokenExpired, CodeAuthNotConfigured, CodeNetworkTimeout, CodeNetworkUnreachable:
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// CLIError.Message 保留了原始响应 JSON。
|
||
func driveDepthErrorCode(err error) string {
|
||
var cliErr *CLIError
|
||
if !errors.As(err, &cliErr) {
|
||
return ""
|
||
}
|
||
var body map[string]any
|
||
if json.Unmarshal([]byte(cliErr.Message), &body) != nil {
|
||
return ""
|
||
}
|
||
for _, key := range []string{"errorCode", "error_code", "code"} {
|
||
if s, ok := body[key].(string); ok && s != "" {
|
||
return s
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// 匹配顺序是硬约束:invalidRequest.rateLimited 自带 "invalidRequest." 前缀,
|
||
// 若先做 invalidRequest.* 前缀分类,限流会被吞进参数错误类。必须先精确匹配限流,
|
||
// 再 forbidden.* 前缀,最后兜底 api_error。未知 reason 按 api_error 兼容。
|
||
func classifyDriveDepthReason(errorCode string) string {
|
||
switch {
|
||
case errorCode == driveDepthRateLimitedCode:
|
||
return "rate_limited"
|
||
case strings.HasPrefix(errorCode, "forbidden."):
|
||
return "permission_denied"
|
||
default:
|
||
return "api_error"
|
||
}
|
||
}
|
||
|
||
func newDriveDepthError(folder driveDepthFolder, err error) driveDepthError {
|
||
return driveDepthError{
|
||
Depth: folder.depth,
|
||
FolderID: folder.id,
|
||
FolderName: folder.name,
|
||
Reason: classifyDriveDepthReason(driveDepthErrorCode(err)),
|
||
Message: driveDepthErrorMessage(err),
|
||
}
|
||
}
|
||
|
||
func driveDepthErrorMessage(err error) string {
|
||
var cliErr *CLIError
|
||
if errors.As(err, &cliErr) {
|
||
var body map[string]any
|
||
if json.Unmarshal([]byte(cliErr.Message), &body) == nil {
|
||
for _, key := range []string{"errorMsg", "message"} {
|
||
if s, ok := body[key].(string); ok && s != "" {
|
||
return s
|
||
}
|
||
}
|
||
}
|
||
return cliErr.Message
|
||
}
|
||
return err.Error()
|
||
}
|