The new root-type guard shifted `filepath.WalkDir`'s outer error branch into the diff, and neither the macOS nor the Windows runner reaches it naturally — raising Windows coverage to 99.9365% and blocking the gate. Add a `statusWalkDir` seam and a `TestCrossPlatformCoverage` regression that swaps in a WalkDir returning a sentinel error, asserting it is surfaced unchanged. Verified locally: changed code coverage back to 100.0000%.
672 lines
25 KiB
Go
672 lines
25 KiB
Go
package helpers
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/md5"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"io/fs"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
|
||
"github.com/spf13/cobra"
|
||
)
|
||
|
||
// ==========================================================
|
||
// drive status — 比较本地文件夹与钉盘文件夹的差异
|
||
// ==========================================================
|
||
// ──────────────────────────────────────────────────────────
|
||
// dws drive status — 比较本地文件夹与钉盘文件夹的差异
|
||
//
|
||
// 只读命令:本地取 --local-folder(绝对路径),钉盘取 --remote-folder
|
||
// (文件夹 dentryUuid)指向的文件夹,按精确 MD5(默认)或快速 modified_time
|
||
// (--quick)逐文件比对,输出 new_local / new_remote / modified / unchanged /
|
||
// unknown 五类差异(exact 模式远端无可靠 md5 的文件归入 unknown)。两侧各自递归
|
||
// 遍历,rel_path 相对各自根目录。
|
||
//
|
||
// 后端交互(按 parentId 递归拉取 list_files、只保留 type=file 的二进制文件、并为
|
||
// 每个条目填充 hash 或者 modified_time),见 fetchRemoteDriveTree。
|
||
// ──────────────────────────────────────────────────────────
|
||
|
||
// driveStatusEntry 是输出 schema 中每个差异项的形态:{"rel_path": "..."}。
|
||
type driveStatusEntry struct {
|
||
RelPath string `json:"rel_path"`
|
||
}
|
||
|
||
// driveStatusResult 是 status 命令成功时的输出 schema。
|
||
// 五个切片都初始化为非 nil,保证 JSON 序列化为 [] 而不是 null。
|
||
//
|
||
// unknown:exact 模式下双端都存在、但远端未返回可靠 md5 的文件——此时无法核对
|
||
// 内容,既不能判 unchanged(会误报未变更),也不宜硬判 modified(会误报变更),
|
||
// 如实归入 unknown,让降级模式在输出里显式可见。quick 模式不会产生 unknown。
|
||
type driveStatusResult struct {
|
||
Detection string `json:"detection"`
|
||
NewLocal []driveStatusEntry `json:"new_local"`
|
||
NewRemote []driveStatusEntry `json:"new_remote"`
|
||
Modified []driveStatusEntry `json:"modified"`
|
||
Unchanged []driveStatusEntry `json:"unchanged"`
|
||
Unknown []driveStatusEntry `json:"unknown"`
|
||
}
|
||
|
||
// localFile 描述一个本地常规文件。
|
||
type localFile struct {
|
||
RelPath string
|
||
AbsPath string // 绝对路径,用于按需计算 MD5
|
||
Hash string // MD5(base64);仅在双端都存在且非 quick 模式时按需计算
|
||
Size int64 // 本地文件字节数
|
||
ModTimeMillis int64 // 本地 mtime,Unix 毫秒
|
||
}
|
||
|
||
// remoteFile 描述一个云端 type=file 的二进制文件。
|
||
// 由 fetchRemoteDriveTree 填充。
|
||
type remoteFile struct {
|
||
RelPath string
|
||
FileID string // dentryUuid;pull 下载时作为 download_file 的 fileId
|
||
Hash string // 远端 md5(base64,exact 模式用;为空时该文件归入 unknown)
|
||
Size int64 // 远端文件字节数
|
||
ModifiedTime int64 // 远端 modified_time,Unix 毫秒(quick / smart 模式比较用)
|
||
ModifiedTimeValid bool // 远端时间戳是否可信;不可信时走保守路径
|
||
}
|
||
|
||
// 每次 list_files 请求的分页大小。后端约束 maxResults 必须在 1..50 之间。
|
||
const driveListPageSize = 50
|
||
|
||
// remoteMaxDepth 是递归遍历子文件夹的最大层级,防止云端目录出现环形引用时死循环。
|
||
const remoteMaxDepth = 64
|
||
|
||
// fetchRemoteDriveTree 递归拉取云端文件夹 parentID 下的文件树,返回所有 type=FILE 的
|
||
// 二进制文件索引(key 为 rel_path)。spaceID 为空时省略 spaceId 参数,由 MCP Server
|
||
// 默认到「我的文件」(与其它 drive 命令一致);parentID 为待比对的云端根文件夹 dentryUuid。
|
||
//
|
||
// 实现要点:
|
||
// - 通过 drive 的 list_files MCP 工具,按 parentId(文件夹 dentryUuid)逐层遍历;
|
||
// - 单个目录文件过多时按 nextToken 分批拉取(maxResults ≤ 50);
|
||
// - 遇到子文件夹用其 dentryUuid 作为下一层的 parentId 递归进入;
|
||
// - rel_path 由各层文件夹名逐层拼接得到(相对 parentID 根),与本地相对路径对齐;
|
||
// - 只纳入明确 type=FILE 的条目,其余类型(在线文档、快捷方式等)一律跳过。
|
||
func fetchRemoteDriveTree(ctx context.Context, spaceID, parentID string, quick bool) (map[string]*remoteFile, error) {
|
||
out := make(map[string]*remoteFile)
|
||
occupied := make(map[string]string)
|
||
if err := walkRemoteDir(ctx, spaceID, parentID, "", out, occupied, 0); err != nil {
|
||
return nil, err
|
||
}
|
||
_ = quick // hash 与 modified_time 都会被填充,比对模式由 compareTrees 决定
|
||
return out, nil
|
||
}
|
||
|
||
// walkRemoteDir 遍历 parentID 指向的文件夹,把其中的二进制文件写入 out(key 为 rel_path),
|
||
// 并递归进入子文件夹。occupied 同时记录文件和目录;精确路径被第二个条目占用时立即失败,
|
||
// 防止分页或返回顺序决定最终镜像。relBase 是当前文件夹相对遍历起点的路径前缀。
|
||
func walkRemoteDir(ctx context.Context, spaceID, parentID, relBase string, out map[string]*remoteFile, occupied map[string]string, depth int) error {
|
||
if depth > remoteMaxDepth {
|
||
return fmt.Errorf("drive 目录层级超过 %d 层,疑似循环引用,已中止", remoteMaxDepth)
|
||
}
|
||
|
||
nextToken := ""
|
||
seenTokens := make(map[string]struct{})
|
||
for {
|
||
args := map[string]any{"maxResults": float64(driveListPageSize)}
|
||
// space-id 为空则省略,交给 MCP Server 默认「我的文件」。
|
||
if spaceID != "" {
|
||
args["spaceId"] = spaceID
|
||
}
|
||
// 按 parentId(文件夹 dentryUuid)导航;空则由 MCP Server 默认列空间根目录。
|
||
if parentID != "" {
|
||
args["parentId"] = parentID
|
||
}
|
||
if nextToken != "" {
|
||
args["nextToken"] = nextToken
|
||
}
|
||
|
||
text, err := callDriveListFiles(ctx, args)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
items, token, err := parseDriveList(text)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
for _, it := range items {
|
||
name, included, err := remoteMirrorEntryName(it, relBase)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !included {
|
||
continue
|
||
}
|
||
// rel_path 由文件夹名逐层拼接,统一用 / 分隔,与本地相对路径对齐。
|
||
childRel := name
|
||
if relBase != "" {
|
||
childRel = relBase + "/" + name
|
||
}
|
||
|
||
if it.isFolder() {
|
||
if err := claimRemotePath(occupied, childRel, "目录"); err != nil {
|
||
return err
|
||
}
|
||
folderID := it.id()
|
||
if folderID == "" {
|
||
return fmt.Errorf("远端目录 %q 缺少目录 ID,已中止递归", childRel)
|
||
}
|
||
// 用子文件夹的 dentryUuid 作为下一层 parentId 递归进入。
|
||
if err := walkRemoteDir(ctx, spaceID, folderID, childRel, out, occupied, depth+1); err != nil {
|
||
return err
|
||
}
|
||
continue
|
||
}
|
||
// 非文件类型已由 remoteMirrorEntryName 过滤;目录在上方完成递归后返回。
|
||
if err := claimRemotePath(occupied, childRel, "文件"); err != nil {
|
||
return err
|
||
}
|
||
fileID := it.id()
|
||
if fileID == "" {
|
||
return fmt.Errorf("远端文件 %q 缺少文件 ID,已中止遍历", childRel)
|
||
}
|
||
|
||
modMillis, modValid := it.modifiedMillis()
|
||
out[childRel] = &remoteFile{
|
||
RelPath: childRel,
|
||
FileID: fileID,
|
||
Hash: it.hash(),
|
||
Size: it.size(),
|
||
ModifiedTime: modMillis,
|
||
ModifiedTimeValid: modValid,
|
||
}
|
||
}
|
||
|
||
if token == "" {
|
||
break
|
||
}
|
||
if _, exists := seenTokens[token]; exists {
|
||
return fmt.Errorf("远端目录 %q 的分页 token 重复,疑似分页循环,已中止", relBase)
|
||
}
|
||
seenTokens[token] = struct{}{}
|
||
nextToken = token
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// claimRemotePath 保证一棵远端树中的每个精确 rel_path 只对应一个文件或目录。
|
||
// 重复条目会让本地镜像不可完整表达,因此必须失败,不能任意选择其中一个 fileId。
|
||
func claimRemotePath(occupied map[string]string, rel, kind string) error {
|
||
if previous, exists := occupied[rel]; exists {
|
||
return fmt.Errorf("检测到重复远端路径 %q(已有%s,重复%s),无法构建确定性镜像", rel, previous, kind)
|
||
}
|
||
occupied[rel] = kind
|
||
return nil
|
||
}
|
||
|
||
// remoteMirrorEntryName 只选择文件夹镜像支持的 file / folder 条目,并验证其名称
|
||
// 能安全、无歧义地表示为本地单层路径。无法表示的镜像条目必须中止整棵远端树构建;
|
||
// 静默跳过会让 status/pull/push/sync 在遗漏文件或目录子树时误报成功。
|
||
func remoteMirrorEntryName(it driveItem, relBase string) (string, bool, error) {
|
||
kind := ""
|
||
switch {
|
||
case it.isFolder():
|
||
kind = "目录"
|
||
case it.isFile():
|
||
kind = "文件"
|
||
default:
|
||
return "", false, nil
|
||
}
|
||
|
||
name := it.name()
|
||
if !isSafeRemoteSegment(name) {
|
||
return "", false, fmt.Errorf("远端%s名称 %q 无法安全映射到本地路径(父路径 %q),已中止", kind, name, relBase)
|
||
}
|
||
return name, true, nil
|
||
}
|
||
|
||
// callDriveListFiles 让文件夹同步族在 --dry-run 下仍可通过受控只读通道获取
|
||
// 远端现状;普通 CallTool 在 dry-run 下只会返回执行计划,不能被当作业务数据解析。
|
||
func callDriveListFiles(ctx context.Context, args map[string]any) (string, error) {
|
||
if deps.Caller.DryRun() {
|
||
return callMCPReadToolReturnTextOnServer(ctx, "drive", "list_files", args)
|
||
}
|
||
return callMCPToolReturnText(ctx, "list_files", args)
|
||
}
|
||
|
||
// driveItem 是 list_files 返回里的一个 dentry 条目。
|
||
// 后端字段命名存在多种历史写法,这里用带 fallback 的访问器做防御式解析。
|
||
type driveItem map[string]any
|
||
|
||
func (d driveItem) str(keys ...string) string {
|
||
for _, k := range keys {
|
||
if s, ok := d[k].(string); ok && s != "" {
|
||
return s
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (d driveItem) name() string { return d.str("name") }
|
||
func (d driveItem) id() string { return d.str("fileId", "dentryUuid", "dentryId", "id", "nodeId") }
|
||
func (d driveItem) typ() string { return d.str("type") }
|
||
func (d driveItem) hash() string { return d.str("md5") }
|
||
func (d driveItem) path() string { return d.str("path") }
|
||
|
||
// size 返回远端文件字节数(fileSize 字段),用于 exact 无 md5 时的降级比对。
|
||
func (d driveItem) size() int64 {
|
||
switch v := d["fileSize"].(type) {
|
||
case float64:
|
||
return int64(v)
|
||
case json.Number:
|
||
if n, err := v.Int64(); err == nil {
|
||
return n
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func (d driveItem) isFolder() bool {
|
||
return strings.ToUpper(d.typ()) == "FOLDER"
|
||
}
|
||
|
||
func (d driveItem) isFile() bool {
|
||
return strings.ToUpper(d.typ()) == "FILE"
|
||
}
|
||
|
||
func (d driveItem) modifiedMillis() (int64, bool) {
|
||
for _, k := range []string{"modifiedTime", "modifyTime", "modified_time", "gmtModified", "lastModifiedTime", "updateTime"} {
|
||
if v, ok := d[k]; ok {
|
||
if ms, ok := toMillis(v); ok {
|
||
return ms, true
|
||
}
|
||
}
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
// parseDriveList 解析 list_files 返回文本,抽出 items 列表与 nextToken。
|
||
// 兼容两种 result 形态:
|
||
// - {"result":{"items":[...],"nextToken":"..."}}
|
||
// - {"result":[...]}(result 直接是条目数组,无分页)
|
||
func parseDriveList(text string) ([]driveItem, string, error) {
|
||
var envelope map[string]json.RawMessage
|
||
if err := json.Unmarshal([]byte(text), &envelope); err != nil {
|
||
return nil, "", fmt.Errorf("解析 list_files 返回失败: %w", err)
|
||
}
|
||
result, exists := envelope["result"]
|
||
if !exists {
|
||
return nil, "", fmt.Errorf("解析 list_files 返回失败: 缺少 result")
|
||
}
|
||
result = json.RawMessage(bytes.TrimSpace(result))
|
||
if bytes.Equal(result, []byte("null")) {
|
||
return nil, "", fmt.Errorf("解析 list_files 返回失败: result 为 null")
|
||
}
|
||
|
||
toItems := func(ms []map[string]any) ([]driveItem, error) {
|
||
items := make([]driveItem, 0, len(ms))
|
||
for index, m := range ms {
|
||
item := driveItem(m)
|
||
// FILE / FOLDER 之外的显式类型(在线文档、快捷方式等)可以由镜像
|
||
// walker 跳过;但 null、空对象或缺少有效 type 的条目不是一个可判定的
|
||
// 业务对象。静默跳过会把不完整清单伪装成权威目录,必须失败关闭。
|
||
if m == nil || strings.TrimSpace(item.typ()) == "" {
|
||
return nil, fmt.Errorf("解析 list_files 返回失败: result.items[%d] 缺少有效 type", index)
|
||
}
|
||
items = append(items, item)
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
switch {
|
||
case len(result) > 0 && result[0] == '{':
|
||
// 形态一:result 是对象 {items, nextToken}。items 必须显式存在,不能把
|
||
// 缺失/null 的非权威响应误判为空目录。
|
||
var obj struct {
|
||
Items json.RawMessage `json:"items"`
|
||
NextToken string `json:"nextToken"`
|
||
HasMore *bool `json:"hasMore"`
|
||
}
|
||
if err := json.Unmarshal(result, &obj); err != nil {
|
||
return nil, "", fmt.Errorf("解析 list_files 返回失败: %w", err)
|
||
}
|
||
if len(obj.Items) == 0 {
|
||
return nil, "", fmt.Errorf("解析 list_files 返回失败: result 缺少 items")
|
||
}
|
||
var items []map[string]any
|
||
if err := json.Unmarshal(obj.Items, &items); err != nil || items == nil {
|
||
return nil, "", fmt.Errorf("解析 list_files 返回失败: result.items 不是数组")
|
||
}
|
||
if obj.HasMore != nil && *obj.HasMore && obj.NextToken == "" {
|
||
return nil, "", fmt.Errorf("解析 list_files 返回失败: hasMore=true 但 nextToken 为空,远端清单可能被截断")
|
||
}
|
||
parsed, err := toItems(items)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
return parsed, obj.NextToken, nil
|
||
case len(result) > 0 && result[0] == '[':
|
||
// 形态二:result 直接是条目数组。
|
||
var arr []map[string]any
|
||
if err := json.Unmarshal(result, &arr); err != nil {
|
||
return nil, "", fmt.Errorf("解析 list_files 返回失败: result 数组无效: %w", err)
|
||
}
|
||
parsed, err := toItems(arr)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
return parsed, "", nil
|
||
}
|
||
|
||
return nil, "", fmt.Errorf("解析 list_files 返回失败: result 既不是 {items:[]} 也不是数组")
|
||
}
|
||
|
||
// validateLocalDirAbs 校验 --local-folder 必须是绝对路径,返回其清理后的绝对路径。
|
||
func validateLocalDirAbs(localDir string) (string, error) {
|
||
if localDir == "" {
|
||
return "", fmt.Errorf("flag --local-folder is required")
|
||
}
|
||
if !filepath.IsAbs(localDir) {
|
||
return "", fmt.Errorf("--local-folder 必须是绝对路径: %s", localDir)
|
||
}
|
||
return filepath.Clean(localDir), nil
|
||
}
|
||
|
||
// isSafeRemoteSegment 校验单个远端条目名称是否可安全用作一层相对路径段。
|
||
// 拒绝空串、"."、".."、ASCII 控制字符、含卷标(如 Windows 的 "C:")以及任意平台
|
||
// 的路径分隔符(/ 与 \)——这些成分无法安全表示为本地文件名,或可能让下载目标逃逸
|
||
// 出本地根目录。
|
||
func isSafeRemoteSegment(name string) bool {
|
||
if name == "" || name == "." || name == ".." {
|
||
return false
|
||
}
|
||
for i := 0; i < len(name); i++ {
|
||
if name[i] <= 0x1f || name[i] == 0x7f {
|
||
return false
|
||
}
|
||
}
|
||
// 任意平台分隔符都不允许出现在单层名称里。
|
||
if strings.ContainsAny(name, `/\`) {
|
||
return false
|
||
}
|
||
// 余下的卷标/规范化检查只在 Windows 上可达:Unix 的 filepath.VolumeName 恒为空,
|
||
// 且经上面的分隔符过滤后 filepath.Clean 不会再改写名称。按平台分文件承载。
|
||
return isSafeRemoteSegmentPlatform(name)
|
||
}
|
||
|
||
// resolveLocalTarget 把远端 rel_path 拼到本地根目录 absDir 下,并确认结果仍位于
|
||
// absDir 内。除词法检查(filepath.Rel)外,还解析已存在路径组件的符号链接,防止
|
||
// root 内的目录软链(如 root/sub -> /outside)把落盘点指到根目录外。逃逸时返回错误。
|
||
func resolveLocalTarget(absDir, rel string) (string, error) {
|
||
target := filepath.Join(absDir, filepath.FromSlash(rel))
|
||
back, err := filepath.Rel(absDir, target)
|
||
if err != nil || back == ".." || strings.HasPrefix(back, ".."+string(filepath.Separator)) {
|
||
return "", fmt.Errorf("远端路径 %q 逃逸出本地根目录", rel)
|
||
}
|
||
if err := verifyNoSymlinkEscape(absDir, target); err != nil {
|
||
return "", err
|
||
}
|
||
return target, nil
|
||
}
|
||
|
||
// verifyNoSymlinkEscape 挡住「absDir 之内的目录软链指向外部」的逃逸:从 target 的父
|
||
// 目录向上、在**不越过 absDir 边界**的前提下,找到最深的已存在祖先,解析其真实路径
|
||
// (EvalSymlinks 展开整条软链),确认仍位于 absDir 真实路径之内。
|
||
//
|
||
// absDir 尚不存在时直接放行:其下不可能有已存在的组件可供逃逸,后续 MkdirAll 只会
|
||
// 创建真实目录、绝不新建软链——这也是 pull 自动创建目标根目录的正常路径。target 尚不
|
||
// 存在的末段同理无软链可谈,故只需校验 [absDir, target] 之间的现有组件。
|
||
func verifyNoSymlinkEscape(absDir, target string) error {
|
||
absDir = filepath.Clean(absDir)
|
||
realRoot, err := filepath.EvalSymlinks(absDir)
|
||
if err != nil {
|
||
// 根目录尚不存在(无软链可解析)→ 放行,交给 MkdirAll 按需创建真实目录。
|
||
return nil
|
||
}
|
||
|
||
// 循环必然终止,无需不动点兜底:每轮 ancestor 严格变短,要么越界后由下面的 ".."
|
||
// 分支退出,要么下降到 absDir 自身——absDir 必然存在(EvalSymlinks 刚成功),于是
|
||
// 在 Lstat 成功分支退出。即便 absDir 在此期间被删除,下一轮 ancestor 升到 absDir
|
||
// 之上,同样由 ".." 分支退出。
|
||
ancestor := filepath.Dir(target)
|
||
for {
|
||
// 越过 absDir 边界(祖先跑到根目录之上)就停止:根目录之上不属于逃逸判定范围。
|
||
rel, relErr := filepath.Rel(absDir, ancestor)
|
||
if relErr != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||
return nil
|
||
}
|
||
if _, statErr := os.Lstat(ancestor); statErr == nil {
|
||
real, evErr := filepath.EvalSymlinks(ancestor)
|
||
if evErr != nil {
|
||
return fmt.Errorf("解析路径 %q 失败: %w", ancestor, evErr)
|
||
}
|
||
back, bErr := filepath.Rel(realRoot, real)
|
||
if bErr != nil || back == ".." || strings.HasPrefix(back, ".."+string(filepath.Separator)) {
|
||
return fmt.Errorf("路径 %q 经符号链接逃逸出本地根目录", target)
|
||
}
|
||
return nil
|
||
}
|
||
ancestor = filepath.Dir(ancestor)
|
||
}
|
||
}
|
||
|
||
// md5File 计算文件内容的 MD5,并按 base64 编码返回(与钉盘 list_files 返回的
|
||
// md5 字段编码一致,便于直接字符串比较)。变量形式只用于测试确定性注入读取失败;
|
||
// 测试必须通过 testseam.Swap 替换并自动恢复。
|
||
var md5File = func(path string) (string, error) {
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer f.Close()
|
||
|
||
h := md5.New()
|
||
if _, err := io.Copy(h, f); err != nil {
|
||
return "", err
|
||
}
|
||
return base64.StdEncoding.EncodeToString(h.Sum(nil)), nil
|
||
}
|
||
|
||
// statusRootLstat 是根类型校验的注入 seam;测试通过 testseam.Swap 注入不同的
|
||
// FileInfo 来复现 Windows 上无法创建的符号链接场景。
|
||
var statusRootLstat = os.Lstat
|
||
|
||
// statusWalkDir 是本地目录遍历的注入 seam。改造根类型校验后,filepath.WalkDir
|
||
// 的错误分支(walkLocalTreeEntry 之外的外层错误)在 macOS/Windows 上都难以自然
|
||
// 触发,需要 seam 让该分支可以确定性回归。
|
||
var statusWalkDir = filepath.WalkDir
|
||
|
||
// walkLocalTree 递归遍历本地目录,只收集常规文件(跳过符号链接、设备文件等)。
|
||
// rel_path 统一使用 / 作为分隔符,相对于 root。
|
||
//
|
||
// 这里只记录路径与 mtime,不计算 MD5:本地 hash 采用惰性策略,仅在文件双端
|
||
// 都存在且非 quick 模式时,由 compareTrees 按需计算(见 judgeFileMatch)。
|
||
func walkLocalTree(root string) (map[string]*localFile, error) {
|
||
// filepath.WalkDir 不会跟随根本身的符号链接:若 root 是指向目录的 symlink,
|
||
// WalkDir 只以 symlink 身份触发一次回调,随后 !IsRegular 分支静默跳过,最终
|
||
// 返回空索引,让 status 把所有远端文件报成 new_remote。fail-closed 于遍历前:
|
||
// 根必须是真实目录,任何其它类型都必须报错。
|
||
info, err := statusRootLstat(root)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取本地根目录身份失败: %w", err)
|
||
}
|
||
if info.Mode()&os.ModeSymlink != 0 {
|
||
return nil, fmt.Errorf("本地根目录 %q 是符号链接,请传入真实目录路径", root)
|
||
}
|
||
if !info.IsDir() {
|
||
return nil, fmt.Errorf("本地根目录 %q 不是目录", root)
|
||
}
|
||
files := make(map[string]*localFile)
|
||
err = statusWalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||
return walkLocalTreeEntry(root, path, d, err, files)
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return files, nil
|
||
}
|
||
|
||
// runDriveStatusWalkLocalTree 仅作为 runDriveStatus 扫描失败分支的确定性测试 seam;
|
||
// 测试必须通过 testseam.Swap 替换并自动恢复。
|
||
var runDriveStatusWalkLocalTree = walkLocalTree
|
||
|
||
// walkLocalTreeEntry 是 walkLocalTree 的单条目处理逻辑。抽成命名函数只为可测:
|
||
// Info() / filepath.Rel 的失败在真实 WalkDir 下几乎不可复现,单测可直接注入。
|
||
func walkLocalTreeEntry(root, path string, d fs.DirEntry, err error, files map[string]*localFile) error {
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if d.IsDir() {
|
||
return nil
|
||
}
|
||
info, err := d.Info()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// 只比对常规文件;符号链接、设备文件等被忽略。
|
||
if !info.Mode().IsRegular() {
|
||
return nil
|
||
}
|
||
rel, err := filepath.Rel(root, path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
relSlash := filepath.ToSlash(rel)
|
||
|
||
files[relSlash] = &localFile{
|
||
RelPath: relSlash,
|
||
AbsPath: path,
|
||
Size: info.Size(),
|
||
ModTimeMillis: info.ModTime().UnixMilli(),
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// 双端都存在文件的比对结论。
|
||
const (
|
||
matchUnchanged = "unchanged"
|
||
matchModified = "modified"
|
||
matchUnknown = "unknown" // exact 模式且远端无可靠 md5,内容无法核对
|
||
)
|
||
|
||
// judgeFileMatch 判定双端都存在的文件本次的比对结论:unchanged / modified / unknown。
|
||
//
|
||
// exact 模式下,本地 MD5 在此处按需计算——只有走到这里的文件才是双端都存在的,
|
||
// local-only 文件不会触发 hash 计算。若远端未返回 md5(list_files 目前通常如此),
|
||
// 无法核对内容,则返回 unknown:不判 unchanged(避免把不同内容误报为未变更),
|
||
// 也不硬判 modified(避免把相同内容误报为已变更),让降级如实反映在输出中。
|
||
func judgeFileMatch(lf *localFile, rf *remoteFile, quick bool) (string, error) {
|
||
if quick {
|
||
// quick:远端时间戳必须可信且与本地 mtime 相等才算 unchanged;
|
||
// 远端时间缺失/非法时走保守路径记为 modified。
|
||
if rf.ModifiedTimeValid && lf.ModTimeMillis == rf.ModifiedTime {
|
||
return matchUnchanged, nil
|
||
}
|
||
return matchModified, nil
|
||
}
|
||
|
||
// exact:远端缺少可靠 md5 时无法核对内容 → unknown(既不判 unchanged 也不判 modified)。
|
||
if rf.Hash == "" {
|
||
return matchUnknown, nil
|
||
}
|
||
|
||
// exact:按需计算本地 MD5(base64),再与远端 md5(钉盘也返回 base64)直接比较。
|
||
if lf.Hash == "" {
|
||
h, err := md5File(lf.AbsPath)
|
||
if err != nil {
|
||
return "", fmt.Errorf("计算 %s 的 MD5 失败: %w", lf.RelPath, err)
|
||
}
|
||
lf.Hash = h
|
||
}
|
||
if lf.Hash == rf.Hash {
|
||
return matchUnchanged, nil
|
||
}
|
||
return matchModified, nil
|
||
}
|
||
|
||
// compareTrees 比较本地与云端文件树,产出五类差异。
|
||
func compareTrees(local map[string]*localFile, remote map[string]*remoteFile, detection string, quick bool) (driveStatusResult, error) {
|
||
res := driveStatusResult{
|
||
Detection: detection,
|
||
NewLocal: []driveStatusEntry{},
|
||
NewRemote: []driveStatusEntry{},
|
||
Modified: []driveStatusEntry{},
|
||
Unchanged: []driveStatusEntry{},
|
||
Unknown: []driveStatusEntry{},
|
||
}
|
||
|
||
for rel, lf := range local {
|
||
rf, ok := remote[rel]
|
||
if !ok {
|
||
res.NewLocal = append(res.NewLocal, driveStatusEntry{RelPath: rel})
|
||
continue
|
||
}
|
||
verdict, err := judgeFileMatch(lf, rf, quick)
|
||
if err != nil {
|
||
return res, err
|
||
}
|
||
switch verdict {
|
||
case matchUnchanged:
|
||
res.Unchanged = append(res.Unchanged, driveStatusEntry{RelPath: rel})
|
||
case matchUnknown:
|
||
res.Unknown = append(res.Unknown, driveStatusEntry{RelPath: rel})
|
||
default:
|
||
res.Modified = append(res.Modified, driveStatusEntry{RelPath: rel})
|
||
}
|
||
}
|
||
for rel := range remote {
|
||
if _, ok := local[rel]; !ok {
|
||
res.NewRemote = append(res.NewRemote, driveStatusEntry{RelPath: rel})
|
||
}
|
||
}
|
||
|
||
sortEntries(res.NewLocal)
|
||
sortEntries(res.NewRemote)
|
||
sortEntries(res.Modified)
|
||
sortEntries(res.Unchanged)
|
||
sortEntries(res.Unknown)
|
||
return res, nil
|
||
}
|
||
|
||
func sortEntries(entries []driveStatusEntry) {
|
||
sort.Slice(entries, func(i, j int) bool { return entries[i].RelPath < entries[j].RelPath })
|
||
}
|
||
|
||
func runDriveStatus(cmd *cobra.Command, _ []string) error {
|
||
if err := validateRequiredFlags(cmd, "local-folder", "remote-folder"); err != nil {
|
||
return err
|
||
}
|
||
localDir := mustGetFlag(cmd, "local-folder")
|
||
// remote-folder 必传:待比对的云端根文件夹 dentryUuid。
|
||
remoteDirID := mustGetFlag(cmd, "remote-folder")
|
||
// space-id 可选:不传则由 fetchRemoteDriveTree 使用「我的文件」对应的空间。
|
||
spaceID := mustGetFlag(cmd, "space-id")
|
||
|
||
quick, _ := cmd.Flags().GetBool("quick")
|
||
detection := "exact"
|
||
if quick {
|
||
detection = "quick"
|
||
}
|
||
|
||
absDir, err := validateLocalDirAbs(localDir)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
local, err := runDriveStatusWalkLocalTree(absDir)
|
||
if err != nil {
|
||
return fmt.Errorf("扫描本地目录失败: %w", err)
|
||
}
|
||
|
||
ctx := cmd.Context()
|
||
remoteIndex, err := fetchRemoteDriveTree(ctx, spaceID, remoteDirID, quick)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
result, err := compareTrees(local, remoteIndex, detection, quick)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return deps.Out.PrintJSON(result)
|
||
}
|