Files
dingtalk-workspace-cli/internal/helpers/drive_pull.go
T

358 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package helpers
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
"github.com/spf13/cobra"
"golang.org/x/text/unicode/norm"
)
// ==========================================================
// drive pull — 把钉盘文件夹镜像到本地(Drive → 本地)
// ==========================================================
// ──────────────────────────────────────────────────────────
// dws drive pull — 把钉盘文件夹单向、文件级镜像到本地(Drive → 本地)
//
// 递归列出 --remote-folder 指向的钉盘文件夹下所有 type=FILE 的文件,逐一下载到
// --local-folder 对应的相对路径。已存在的本地文件按 --if-exists 决定
// overwrite / smart / skip。结构化 summary + items 始终打印到 stdout;
// summary.failed > 0 时额外以非零退出码退出。
// ──────────────────────────────────────────────────────────
// --if-exists 的三种策略。
const (
ifExistsOverwrite = "overwrite" // 总是下载覆盖(Drive 为权威源)
ifExistsSmart = "smart" // 推荐增量:本地 mtime 已 ≥ 远端 modified_time 则跳过
ifExistsSkip = "skip" // 默认:本地已存在则保持不动
)
// pullCreateTemp 仅作为文件系统失败分支的确定性测试 seam;测试必须通过
// testseam.Swap 替换并自动恢复。
var pullCreateTemp = os.CreateTemp
// pull 动作分类。
const (
pullActionDownloaded = "downloaded"
pullActionSkipped = "skipped"
pullActionFailed = "failed"
)
// drivePullItem 是输出 items[] 中每个文件的明细。
type drivePullItem struct {
RelPath string `json:"rel_path"`
Action string `json:"action"`
Error string `json:"error,omitempty"`
}
// drivePullSummary 是各动作的计数汇总。
type drivePullSummary struct {
Downloaded int `json:"downloaded"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
}
// drivePullResult 是 pull 命令的输出 schema。
type drivePullResult struct {
Summary drivePullSummary `json:"summary"`
Items []drivePullItem `json:"items"`
}
type drivePullDryRunResult struct {
DryRun bool `json:"dry_run"`
Executed bool `json:"executed"`
PreviewKind string `json:"preview_kind"`
Operation string `json:"operation"`
IfExists string `json:"if_exists"`
Plan drivePullResult `json:"plan"`
}
// drivePartialFailure 在 summary.failed > 0 时返回:结构化结果已打印到 stdout,
// 这里只负责以 exit=1 退出并向 stderr 输出一行简短说明(与 push/sync 一致)。
type drivePartialFailure struct{ failed int }
func (e *drivePartialFailure) Error() string {
return fmt.Sprintf("drive pull: %d file(s) failed", e.failed)
}
func (e *drivePartialFailure) RawStderr() string { return e.Error() }
func (e *drivePartialFailure) ExitCode() int { return 1 }
// pathCollisionKey 把本地目标路径归一化成「目标文件系统下的等价键」,用于探测多个
// 远端条目是否会落到同一个本地文件。caseInsensitive 为真时(Windows / 默认 macOS)
// 折叠大小写并做 Unicode NFC 规范化,从而把 A.txt/a.txt、NFC/NFD 记法视为同一目标;
// 大小写敏感文件系统(如 Linux ext4)则按精确路径区分,避免误判合法的异名文件。
func pathCollisionKey(target string, caseInsensitive bool) string {
p := filepath.Clean(target)
if caseInsensitive {
p = norm.NFC.String(strings.ToLower(p))
}
return p
}
// isCaseInsensitiveFS 是大小写探测的注入点(与 httpGetFile / httpPutFile 同样的 seam):
// 生产路径始终是 detectCaseInsensitiveFS,测试可替换它,以便在大小写敏感的 CI 文件系统上
// 也能走到「等价路径冲突」分支。
var isCaseInsensitiveFS = detectCaseInsensitiveFS
// caseProbePattern 是探针文件名模板。抽成变量只为可测:纯数字模板的大写与原名相同,
// 可覆盖「名称无大小写差异、无法据此判定」的回退分支。
var caseProbePattern = "dws-caseprobe-*"
// detectCaseInsensitiveFS 探测 dir 所在文件系统是否大小写不敏感:在 dir 下创建一个随机
// 小写名探针文件,再用其大写名 stat;命中同一文件即为不敏感。dir 必须已存在。
// 探测失败时回退到平台默认(Windows / macOS 视为不敏感,其它敏感)。
func detectCaseInsensitiveFS(dir string) bool {
platformDefault := runtime.GOOS == "windows" || runtime.GOOS == "darwin"
f, err := os.CreateTemp(dir, caseProbePattern)
if err != nil {
return platformDefault
}
name := f.Name()
_ = f.Close()
defer os.Remove(name)
base := filepath.Base(name)
upper := strings.ToUpper(base)
if upper == base {
return platformDefault // 名称无大小写差异,无法据此判定
}
_, statErr := os.Stat(filepath.Join(dir, upper))
return statErr == nil
}
// detectTargetCollisions 按 caseInsensitive 指定的等价规则,找出会映射到同一本地目标
// 的多个远端 rel_path,返回被判定冲突的 rel_path 集合(其中每个都不应落盘)。
func detectTargetCollisions(absDir string, rels []string, caseInsensitive bool) map[string]bool {
groups := make(map[string][]string, len(rels))
for _, rel := range rels {
target := filepath.Join(absDir, filepath.FromSlash(rel))
key := pathCollisionKey(target, caseInsensitive)
groups[key] = append(groups[key], rel)
}
collided := make(map[string]bool)
for _, g := range groups {
if len(g) > 1 {
for _, r := range g {
collided[r] = true
}
}
}
return collided
}
func runDrivePull(cmd *cobra.Command, _ []string) error {
if err := validateRequiredFlags(cmd, "local-folder", "remote-folder"); err != nil {
return err
}
localDir := mustGetFlag(cmd, "local-folder")
remoteDirID := mustGetFlag(cmd, "remote-folder")
// space-id 可选:不传则由 fetchRemoteDriveTree 使用「我的文件」对应的空间。
spaceID := mustGetFlag(cmd, "space-id")
ifExists, _ := cmd.Flags().GetString("if-exists")
if ifExists == "" {
// 安全默认:不自动覆盖本地既有文件。
ifExists = ifExistsSkip
}
switch ifExists {
case ifExistsOverwrite, ifExistsSmart, ifExistsSkip:
default:
return fmt.Errorf("--if-exists 取值非法: %s(可选 overwrite|smart|skip)", ifExists)
}
absDir, err := validateLocalDirAbs(localDir)
if err != nil {
return err
}
ctx := cmd.Context()
// 复用 status 的远端遍历:按 parentId 递归拿到所有 type=FILE 的文件(key 为 rel_path)。
remote, err := fetchRemoteDriveTree(ctx, spaceID, remoteDirID, false)
if err != nil {
return err
}
// 稳定顺序输出(rel_path 升序)。
relPaths := make([]string, 0, len(remote))
for rel := range remote {
relPaths = append(relPaths, rel)
}
sort.Strings(relPaths)
if deps.Caller.DryRun() {
caseInsensitive := runtime.GOOS == "windows" || runtime.GOOS == "darwin"
return printDrivePullDryRun(absDir, ifExists, remote, relPaths, caseInsensitive)
}
// 落盘前先按目标文件系统的路径等价规则做一次全局冲突检查:大小写不敏感 FS 上
// A.txt 与 a.txt(或 NFC/NFD 异写)会落到同一本地文件;overwrite 会顺序覆盖、
// 却把两项都报 downloaded 而静默丢文件。冲突的条目一律标记 failed、都不写入。
// 探针需要根目录存在,这里按 pull「自动创建目标根」的语义先建好。
if err := os.MkdirAll(absDir, 0o755); err != nil {
return fmt.Errorf("创建本地目录失败: %w", err)
}
collided := detectTargetCollisions(absDir, relPaths, isCaseInsensitiveFS(absDir))
res := drivePullResult{Items: make([]drivePullItem, 0, len(relPaths))}
for _, rel := range relPaths {
rf := remote[rel]
// 多个远端条目映射到同一本地目标 → 全部 failed,不写入任何一个,避免静默覆盖丢文件。
if collided[rel] {
res.Summary.Failed++
res.Items = append(res.Items, drivePullItem{
RelPath: rel, Action: pullActionFailed,
Error: "多个远端条目在当前文件系统上映射到同一本地路径(大小写/规范化冲突),已跳过以避免覆盖丢失",
})
continue
}
// 二次确认下载目标仍在本地根目录内,逃逸路径记为 failed 而非落盘。
localPath, terr := resolveLocalTarget(absDir, rel)
if terr != nil {
res.Summary.Failed++
res.Items = append(res.Items, drivePullItem{RelPath: rel, Action: pullActionFailed, Error: terr.Error()})
continue
}
action, perr := pullOneFile(ctx, spaceID, rf, localPath, ifExists)
item := drivePullItem{RelPath: rel, Action: action}
switch action {
case pullActionDownloaded:
res.Summary.Downloaded++
case pullActionSkipped:
res.Summary.Skipped++
case pullActionFailed:
res.Summary.Failed++
if perr != nil {
item.Error = perr.Error()
}
}
res.Items = append(res.Items, item)
}
// 结构化结果始终打印到 stdout;有失败则额外以非零退出码退出。
if perr := deps.Out.PrintJSON(res); perr != nil {
return perr
}
if res.Summary.Failed > 0 {
return &drivePartialFailure{failed: res.Summary.Failed}
}
return nil
}
func printDrivePullDryRun(absDir, ifExists string, remote map[string]*remoteFile, relPaths []string, caseInsensitive bool) error {
plan := drivePullResult{Items: make([]drivePullItem, 0, len(relPaths))}
collided := detectTargetCollisions(absDir, relPaths, caseInsensitive)
for _, rel := range relPaths {
if collided[rel] {
plan.Summary.Failed++
plan.Items = append(plan.Items, drivePullItem{
RelPath: rel, Action: pullActionFailed,
Error: "多个远端条目在目标平台默认文件系统上映射到同一本地路径,计划已拒绝",
})
continue
}
localPath, err := resolveLocalTarget(absDir, rel)
if err != nil {
plan.Summary.Failed++
plan.Items = append(plan.Items, drivePullItem{RelPath: rel, Action: pullActionFailed, Error: err.Error()})
continue
}
action := pullActionDownloaded
if fi, statErr := os.Stat(localPath); statErr == nil && fi.Mode().IsRegular() {
switch ifExists {
case ifExistsSkip:
action = pullActionSkipped
case ifExistsSmart:
rf := remote[rel]
if rf.ModifiedTimeValid && fi.ModTime().UnixMilli() >= rf.ModifiedTime {
action = pullActionSkipped
}
}
}
if action == pullActionSkipped {
plan.Summary.Skipped++
} else {
plan.Summary.Downloaded++
}
plan.Items = append(plan.Items, drivePullItem{RelPath: rel, Action: action})
}
return deps.Out.PrintJSON(drivePullDryRunResult{
DryRun: true, Executed: false, PreviewKind: "plan", Operation: "drive pull",
IfExists: ifExists, Plan: plan,
})
}
// pullOneFile 处理单个远端文件:按 --if-exists 决定是否跳过,否则下载到 localPath。
// 返回动作分类(downloaded / skipped / failed)及失败时的 error。
func pullOneFile(ctx context.Context, spaceID string, rf *remoteFile, localPath, ifExists string) (string, error) {
// 本地已存在常规文件时,按策略判断是否跳过下载。
if fi, statErr := os.Stat(localPath); statErr == nil && fi.Mode().IsRegular() {
switch ifExists {
case ifExistsSkip:
return pullActionSkipped, nil
case ifExistsSmart:
// 远端时间可信且本地 mtime 已 ≥ 远端 → 视为已对齐,跳过;
// 时间缺失/非法时不盲跳,退回继续下载。
if rf.ModifiedTimeValid && fi.ModTime().UnixMilli() >= rf.ModifiedTime {
return pullActionSkipped, nil
}
case ifExistsOverwrite:
// 总是下载覆盖。
}
}
// download_file → 拿到带签名的下载 URL 与请求头,再 HTTP GET 落盘。
args := map[string]any{"fileId": rf.FileID}
if spaceID != "" {
args["spaceId"] = spaceID
}
text, err := callMCPToolReturnText(ctx, "download_file", args)
if err != nil {
return pullActionFailed, err
}
resourceURL, headers, err := parseDriveDownloadInfo(text)
if err != nil {
return pullActionFailed, err
}
dir := filepath.Dir(localPath)
if err := os.MkdirAll(dir, 0o755); err != nil {
return pullActionFailed, fmt.Errorf("创建本地目录失败: %w", err)
}
// 先下载到同目录下的临时文件,完整落盘后再原子 rename 覆盖目标:任何中途失败
// (网络中断、超时、写盘错误)都不会截断或破坏已存在的原文件。同目录保证
// rename 在同一文件系统内、可原子替换;rename 会替换目标符号链接本身而非跟随它。
tmp, err := pullCreateTemp(dir, ".dws-pull-*")
if err != nil {
return pullActionFailed, fmt.Errorf("创建临时文件失败: %w", err)
}
tmpPath := tmp.Name()
_ = tmp.Close() // httpGetFile 会自行 os.Create 覆盖该临时文件
// 除非成功 rename,否则始终清理临时文件,不留半成品。
committed := false
defer func() {
if !committed {
_ = os.Remove(tmpPath)
}
}()
if err := httpGetFile(ctx, resourceURL, headers, tmpPath); err != nil {
return pullActionFailed, err
}
// 对齐 mtime 放在 rename 之前,保证替换后的目标文件时间戳即为远端时间。
if rf.ModifiedTimeValid {
t := time.UnixMilli(rf.ModifiedTime)
_ = os.Chtimes(tmpPath, t, t)
}
if err := os.Rename(tmpPath, localPath); err != nil {
return pullActionFailed, fmt.Errorf("替换目标文件失败: %w", err)
}
committed = true
return pullActionDownloaded, nil
}