975 lines
35 KiB
Go
975 lines
35 KiB
Go
package helpers
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"runtime"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
"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" // 默认:本地已存在则保持不动
|
||
)
|
||
|
||
// pull 下载路径使用 os.Root 固定根目录和目标父目录。以下 seam 只用于
|
||
// 确定性覆盖文件系统与传输失败分支;测试必须通过 testseam.Swap 替换。
|
||
var (
|
||
pullMkdirAll = os.MkdirAll
|
||
pullOpenRoot = os.OpenRoot
|
||
pullRelPath = filepath.Rel
|
||
pullPathStat = os.Stat
|
||
pullPathLstat = os.Lstat
|
||
pullTargetLstat = os.Lstat
|
||
pullRootMkdir = func(root *os.Root, name string, mode os.FileMode) error {
|
||
return root.Mkdir(name, mode)
|
||
}
|
||
pullOpenParentRoot = func(root *os.Root, name string) (*os.Root, error) {
|
||
return root.OpenRoot(name)
|
||
}
|
||
pullRootStat = func(root *os.Root, name string) (os.FileInfo, error) { return root.Stat(name) }
|
||
pullRootLstat = func(root *os.Root, name string) (os.FileInfo, error) { return root.Lstat(name) }
|
||
pullCreateTemp = createPinnedPullTemp
|
||
pullTempName = uuid.NewString
|
||
pullSyncTemp = func(file *os.File) error { return file.Sync() }
|
||
pullCloseTemp = func(file *os.File) error { return file.Close() }
|
||
pullRename = func(root *os.Root, oldName, newName string) error {
|
||
return root.Rename(oldName, newName)
|
||
}
|
||
pullLink = func(root *os.Root, oldName, newName string) error { return root.Link(oldName, newName) }
|
||
pullRemove = func(root *os.Root, name string) error { return root.Remove(name) }
|
||
pullDownloadFile = defaultPullDownloadFile
|
||
pullHTTPDo = func(request *http.Request) (*http.Response, error) {
|
||
return (&http.Client{Timeout: 10 * time.Minute}).Do(request)
|
||
}
|
||
)
|
||
|
||
// 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
|
||
}
|
||
// 在任何远端读取之前固定本地根计划:既有根立即持有 os.Root;不存在的根只固定
|
||
// 最近既存祖先并记录尚缺的逐层后缀。远端清单不完整或预检失败时不得创建本地根。
|
||
rootPlan, err := planPinnedPullRoot(absDir)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rootPlan.close()
|
||
|
||
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)
|
||
preflight, localInfo, err := buildDrivePullPreflightFromPlan(rootPlan, remote)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if deps.Caller.DryRun() {
|
||
return printDrivePullDryRunWithLocalInfo(ifExists, remote, relPaths, preflight, localInfo)
|
||
}
|
||
// 镜像契约独立于当前主机文件系统:远端 A/a 或 NFC/NFD 异写(包括目录
|
||
// 前缀)在任何落盘前整批拒绝,避免同一远端树在不同平台得到不同镜像。
|
||
if len(preflight) > 0 {
|
||
res := drivePullPreflightFailureResult(relPaths, preflight)
|
||
if perr := deps.Out.PrintJSON(res); perr != nil {
|
||
return perr
|
||
}
|
||
return &drivePartialFailure{failed: res.Summary.Failed}
|
||
}
|
||
pinnedRoot, err := rootPlan.materialize()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer pinnedRoot.close()
|
||
|
||
res := drivePullResult{Items: make([]drivePullItem, 0, len(relPaths))}
|
||
for _, rel := range relPaths {
|
||
rf := remote[rel]
|
||
// preflight 与真正下载之间仍需二次解析,挡住祖先目录被并发替换为软链的
|
||
// TOCTOU 逃逸;pullRemoteFile 将该检查收口在写入入口前。
|
||
action, perr := pullRemoteFilePinned(ctx, spaceID, rf, pinnedRoot, rel, 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 {
|
||
return printDrivePullDryRunWithPreflight(absDir, ifExists, remote, relPaths, caseInsensitive,
|
||
buildDrivePullPreflight(absDir, remote))
|
||
}
|
||
|
||
func printDrivePullDryRunWithPreflight(absDir, ifExists string, remote map[string]*remoteFile, relPaths []string, caseInsensitive bool, preflight map[string]string) error {
|
||
localInfo := make(map[string]os.FileInfo, len(relPaths))
|
||
for _, rel := range relPaths {
|
||
localPath, err := resolveLocalTarget(absDir, rel)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if info, statErr := os.Stat(localPath); statErr == nil && info.Mode().IsRegular() {
|
||
localInfo[rel] = info
|
||
}
|
||
}
|
||
_ = caseInsensitive // 保留 helper 的平台预览参数兼容性。
|
||
return printDrivePullDryRunWithLocalInfo(ifExists, remote, relPaths, preflight, localInfo)
|
||
}
|
||
|
||
func printDrivePullDryRunWithLocalInfo(ifExists string, remote map[string]*remoteFile, relPaths []string, preflight map[string]string, localInfo map[string]os.FileInfo) error {
|
||
plan := drivePullResult{Items: make([]drivePullItem, 0, len(relPaths))}
|
||
if len(preflight) > 0 {
|
||
plan = drivePullPreflightFailureResult(relPaths, preflight)
|
||
return deps.Out.PrintJSON(drivePullDryRunResult{
|
||
DryRun: true, Executed: false, PreviewKind: "plan", Operation: "drive pull",
|
||
IfExists: ifExists, Plan: plan,
|
||
})
|
||
}
|
||
for _, rel := range relPaths {
|
||
action := pullActionDownloaded
|
||
if fi := localInfo[rel]; fi != nil {
|
||
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,
|
||
})
|
||
}
|
||
|
||
// buildDrivePullPreflight 在创建本地根目录、探测文件系统或下载任一文件之前,
|
||
// 一次性验证完整远端树及每个本地落盘目标。任一目标是目录、符号链接、设备文件,
|
||
// 或经既有符号链接逃逸时,整批 pull 都必须零写入中止。
|
||
func buildDrivePullPreflight(absDir string, remote map[string]*remoteFile) map[string]string {
|
||
failures := buildMirrorPreflight(nil, nil, remote, nil)
|
||
addLocalTargetPreflightFailures(absDir, remote, failures)
|
||
return failures
|
||
}
|
||
|
||
// buildDrivePullPreflightFromPlan 只从 list_files 前已固定的根计划读取本地状态。
|
||
// existing 根不再按 absDir 重开路径;missing 根在 ancestor Root 下仍必须完整缺失。
|
||
func buildDrivePullPreflightFromPlan(plan *pinnedPullRootPlan, remote map[string]*remoteFile) (map[string]string, map[string]os.FileInfo, error) {
|
||
failures := buildMirrorPreflight(nil, nil, remote, nil)
|
||
localInfo := make(map[string]os.FileInfo, len(remote))
|
||
if err := plan.verify(); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
if plan.existing != nil {
|
||
addPinnedLocalTargetPreflightFailures(plan.existing, remote, failures)
|
||
for rel := range remote {
|
||
if mirrorPreflightFailureForRel(rel, failures) != "" {
|
||
continue
|
||
}
|
||
info, err := pullRootLstat(plan.existing.root, filepath.FromSlash(rel))
|
||
if err == nil && info.Mode().IsRegular() {
|
||
localInfo[rel] = info
|
||
}
|
||
}
|
||
}
|
||
if err := plan.verify(); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
return failures, localInfo, nil
|
||
}
|
||
|
||
func addLocalTargetPreflightFailures(absDir string, remote map[string]*remoteFile, failures map[string]string) {
|
||
for rel := range remote {
|
||
// 已被名称/类型冲突根屏蔽的后代不重复计错;一个冲突根只报告一次。
|
||
if mirrorPreflightFailureForRel(rel, failures) != "" {
|
||
continue
|
||
}
|
||
localPath, err := resolveLocalTarget(absDir, rel)
|
||
if err == nil {
|
||
err = rejectNonRegularLocalTarget(localPath)
|
||
}
|
||
if err != nil {
|
||
failures[rel] = err.Error()
|
||
}
|
||
}
|
||
}
|
||
|
||
func drivePullPreflightFailureResult(relPaths []string, preflight map[string]string) drivePullResult {
|
||
res := drivePullResult{Items: make([]drivePullItem, 0, len(relPaths))}
|
||
for _, rel := range relPaths {
|
||
msg := mirrorPreflightFailureForRel(rel, preflight)
|
||
if msg == "" {
|
||
msg = "另一镜像条目未通过预检,整批拉取已中止"
|
||
}
|
||
res.Summary.Failed++
|
||
res.Items = append(res.Items, drivePullItem{RelPath: rel, Action: pullActionFailed, Error: msg})
|
||
}
|
||
return res
|
||
}
|
||
|
||
func mirrorPreflightFailureForRel(rel string, preflight map[string]string) string {
|
||
for rel != "" {
|
||
if msg := preflight[rel]; msg != "" {
|
||
return msg
|
||
}
|
||
rel, _ = splitRel(rel)
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func pullRemoteFile(ctx context.Context, spaceID string, rf *remoteFile, absDir, rel, ifExists string) (string, error) {
|
||
return pullOneFileAtRoot(ctx, spaceID, rf, absDir, rel, ifExists)
|
||
}
|
||
|
||
func pullRemoteFilePinned(ctx context.Context, spaceID string, rf *remoteFile, root *pinnedPullRoot, rel, ifExists string) (string, error) {
|
||
target, err := root.openTarget(rel)
|
||
if err != nil {
|
||
return pullActionFailed, err
|
||
}
|
||
defer target.close()
|
||
return pullOneFilePinned(ctx, spaceID, rf, target, ifExists)
|
||
}
|
||
|
||
// pullOneFile 处理单个远端文件:按 --if-exists 决定是否跳过,否则下载到 localPath。
|
||
// 返回动作分类(downloaded / skipped / failed)及失败时的 error。
|
||
func pullOneFile(ctx context.Context, spaceID string, rf *remoteFile, localPath, ifExists string) (string, error) {
|
||
return pullOneFileAtRoot(ctx, spaceID, rf, filepath.Dir(localPath), filepath.Base(localPath), ifExists)
|
||
}
|
||
|
||
func pullOneFileAtRoot(ctx context.Context, spaceID string, rf *remoteFile, absDir, rel, ifExists string) (string, error) {
|
||
root, err := openPinnedPullRoot(absDir)
|
||
if err != nil {
|
||
return pullActionFailed, err
|
||
}
|
||
defer root.close()
|
||
target, err := root.openTarget(rel)
|
||
if err != nil {
|
||
return pullActionFailed, err
|
||
}
|
||
defer target.close()
|
||
return pullOneFilePinned(ctx, spaceID, rf, target, ifExists)
|
||
}
|
||
|
||
func pullOneFilePinned(ctx context.Context, spaceID string, rf *remoteFile, target *pinnedPullTarget, ifExists string) (string, error) {
|
||
// 镜像文件的目标若已存在,必须是常规文件。目录、设备、FIFO 等都不能交给
|
||
// download_file / 临时文件写入流程,否则 dry-run 与执行结论会分叉,且目录目标
|
||
// 会在发出远端读请求后才失败。
|
||
initialInfo, err := target.regularTargetInfo()
|
||
if err != nil {
|
||
return pullActionFailed, err
|
||
}
|
||
// 本地已存在常规文件时,按策略判断是否跳过下载。
|
||
if initialInfo != nil {
|
||
switch ifExists {
|
||
case ifExistsSkip:
|
||
return pullActionSkipped, nil
|
||
case ifExistsSmart:
|
||
// 远端时间可信且本地 mtime 已 ≥ 远端 → 视为已对齐,跳过;
|
||
// 时间缺失/非法时不盲跳,退回继续下载。
|
||
if rf.ModifiedTimeValid && initialInfo.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
|
||
}
|
||
|
||
// download_file 可执行任意时长的远端读。在任何 HTTP GET 或临时文件写入前,
|
||
// 必须确认目标父目录仍是最初固定在本地根下的同一目录。
|
||
if err := target.verifyParent(); err != nil {
|
||
return pullActionFailed, err
|
||
}
|
||
|
||
// 临时文件通过已固定的 os.Root 直接打开,下载直接写已打开的句柄;
|
||
// 生产路径不会再按可被换链的绝对路径重新打开临时文件。
|
||
tmp, tmpName, err := pullCreateTemp(target.parentRoot)
|
||
if err != nil {
|
||
return pullActionFailed, fmt.Errorf("创建临时文件失败: %w", err)
|
||
}
|
||
// 除非成功 rename,否则始终清理临时文件,不留半成品。
|
||
committed := false
|
||
tmpInfo, err := tmp.Stat()
|
||
if err != nil {
|
||
_ = tmp.Close()
|
||
_ = target.parentRoot.Remove(tmpName)
|
||
return pullActionFailed, fmt.Errorf("读取临时文件身份失败: %w", err)
|
||
}
|
||
defer func() {
|
||
_ = tmp.Close()
|
||
if !committed {
|
||
_ = target.parentRoot.Remove(tmpName)
|
||
}
|
||
}()
|
||
|
||
if err := pullDownloadFile(ctx, resourceURL, headers, tmp); err != nil {
|
||
return pullActionFailed, err
|
||
}
|
||
// 通过仍打开的文件句柄设置 mtime,避免按 tmpName 重新解析路径产生 symlink TOCTOU。
|
||
// 时间对齐延续既有 best-effort 语义,失败不影响已完整下载内容的发布。
|
||
if rf.ModifiedTimeValid {
|
||
_ = setPullFileTimes(tmp, time.UnixMilli(rf.ModifiedTime))
|
||
}
|
||
if err := pullSyncTemp(tmp); err != nil {
|
||
return pullActionFailed, fmt.Errorf("同步临时文件失败: %w", err)
|
||
}
|
||
if err := pullCloseTemp(tmp); err != nil {
|
||
return pullActionFailed, fmt.Errorf("关闭临时文件失败: %w", err)
|
||
}
|
||
currentTmp, err := pullRootLstat(target.parentRoot, tmpName)
|
||
if err != nil || !os.SameFile(tmpInfo, currentTmp) {
|
||
return pullActionFailed, fmt.Errorf("临时文件在下载期间被替换,已中止发布")
|
||
}
|
||
// 网络传输期间父目录仍可能被移走或替换。发布前再核对父目录身份和
|
||
// 目标类型;不一致时只删除固定目录内的临时文件,绝不发布。
|
||
if err := target.verifyParent(); err != nil {
|
||
return pullActionFailed, err
|
||
}
|
||
currentTarget, err := target.regularTargetInfo()
|
||
if err != nil {
|
||
return pullActionFailed, err
|
||
}
|
||
if ifExists != ifExistsOverwrite {
|
||
if currentTarget != nil && (initialInfo == nil || !os.SameFile(initialInfo, currentTarget)) {
|
||
return pullActionSkipped, nil
|
||
}
|
||
if ifExists == ifExistsSmart && currentTarget != nil && rf.ModifiedTimeValid && currentTarget.ModTime().UnixMilli() >= rf.ModifiedTime {
|
||
return pullActionSkipped, nil
|
||
}
|
||
}
|
||
if currentTarget == nil && ifExists != ifExistsOverwrite {
|
||
if err := pullLink(target.parentRoot, tmpName, target.name); err != nil {
|
||
if concurrent, statErr := target.regularTargetInfo(); statErr == nil && concurrent != nil {
|
||
return pullActionSkipped, nil
|
||
}
|
||
return pullActionFailed, fmt.Errorf("发布目标文件失败: %w", err)
|
||
}
|
||
if err := pullRemove(target.parentRoot, tmpName); err != nil {
|
||
return pullActionFailed, fmt.Errorf("清理临时文件失败: %w", err)
|
||
}
|
||
} else if err := pullRename(target.parentRoot, tmpName, target.name); err != nil {
|
||
return pullActionFailed, fmt.Errorf("替换目标文件失败: %w", err)
|
||
}
|
||
finalInfo, err := pullRootLstat(target.parentRoot, target.name)
|
||
if err != nil || !os.SameFile(tmpInfo, finalInfo) {
|
||
// 身份不匹配意味着发布后 terminal entry 可能已被并发替换;保留未知对象并报错,
|
||
// 不做 Lstat→Remove 的第二次 check/use,以免误删用户并发创建的文件。
|
||
return pullActionFailed, fmt.Errorf("发布后的目标文件身份不一致")
|
||
}
|
||
if err := target.verifyParent(); err != nil {
|
||
// 固定目录已从命令根路径移走时,保留句柄内已完成的结果并报失败;不能无条件
|
||
// Remove,因为发布后同名 terminal entry 仍可能被并发替换。
|
||
return pullActionFailed, err
|
||
}
|
||
committed = true
|
||
return pullActionDownloaded, nil
|
||
}
|
||
|
||
type pinnedPullRoot struct {
|
||
absDir string
|
||
root *os.Root
|
||
rootInfo os.FileInfo
|
||
pathInfo os.FileInfo
|
||
}
|
||
|
||
// pinnedPullRootPlan 在远端清单读取前固定 pull 的本地根状态。existing 非空表示
|
||
// absDir 当时已存在;否则 ancestor 固定最近既存祖先,missing 保存从祖先到 absDir
|
||
// 的逐层目录名。missing 只有在完整远端清单和本地预检通过后才允许物化。
|
||
type pinnedPullRootPlan struct {
|
||
absDir string
|
||
existing *pinnedPullRoot
|
||
ancestor *pinnedPullRoot
|
||
missing []string
|
||
}
|
||
|
||
type pinnedPullTarget struct {
|
||
base *pinnedPullRoot
|
||
parentRoot *os.Root
|
||
parentChain []pinnedPullDirIdentity
|
||
name string
|
||
ownsBase bool
|
||
}
|
||
|
||
type pinnedPullDirIdentity struct {
|
||
rel string
|
||
info os.FileInfo
|
||
}
|
||
|
||
func planPinnedPullRoot(absDir string) (*pinnedPullRootPlan, error) {
|
||
info, err := pullPathStat(absDir)
|
||
if err == nil {
|
||
if !info.IsDir() {
|
||
return nil, fmt.Errorf("创建本地目录失败: %s 已存在且不是目录", absDir)
|
||
}
|
||
root, openErr := openExistingPinnedPullRoot(absDir)
|
||
if openErr != nil {
|
||
return nil, openErr
|
||
}
|
||
return &pinnedPullRootPlan{absDir: absDir, existing: root}, nil
|
||
}
|
||
if !os.IsNotExist(err) {
|
||
return nil, fmt.Errorf("检查本地目录失败: %w", err)
|
||
}
|
||
|
||
ancestorPath := absDir
|
||
missing := make([]string, 0, 4)
|
||
for {
|
||
parent := filepath.Dir(ancestorPath)
|
||
// absDir 已是验证过的绝对路径;文件系统根目录始终存在,
|
||
// 因此循环必然在到达 parent == ancestorPath 之前以 Stat 成功结束。
|
||
name := filepath.Base(ancestorPath)
|
||
missing = append([]string{name}, missing...)
|
||
ancestorPath = parent
|
||
info, statErr := pullPathStat(ancestorPath)
|
||
if statErr == nil {
|
||
if !info.IsDir() {
|
||
return nil, fmt.Errorf("创建本地目录失败: %s 已存在且不是目录", ancestorPath)
|
||
}
|
||
break
|
||
}
|
||
if !os.IsNotExist(statErr) {
|
||
return nil, fmt.Errorf("检查本地目录失败: %w", statErr)
|
||
}
|
||
}
|
||
ancestor, openErr := openExistingPinnedPullRoot(ancestorPath)
|
||
if openErr != nil {
|
||
return nil, openErr
|
||
}
|
||
plan := &pinnedPullRootPlan{absDir: absDir, ancestor: ancestor, missing: missing}
|
||
if verifyErr := plan.verify(); verifyErr != nil {
|
||
plan.close()
|
||
return nil, verifyErr
|
||
}
|
||
return plan, nil
|
||
}
|
||
|
||
func (plan *pinnedPullRootPlan) verify() error {
|
||
if plan.existing != nil {
|
||
return plan.existing.verify()
|
||
}
|
||
if plan.ancestor == nil {
|
||
return fmt.Errorf("本地根目录计划无效")
|
||
}
|
||
if err := plan.ancestor.verify(); err != nil {
|
||
return err
|
||
}
|
||
prefix := ""
|
||
for _, segment := range plan.missing {
|
||
prefix = filepath.Join(prefix, segment)
|
||
_, err := pullRootLstat(plan.ancestor.root, prefix)
|
||
if err == nil {
|
||
return fmt.Errorf("本地根目录 %q 在远端读取期间被占用,已中止写入", plan.absDir)
|
||
}
|
||
if !os.IsNotExist(err) {
|
||
return fmt.Errorf("检查本地根目录计划失败: %w", err)
|
||
}
|
||
// 第一层缺失后更深的路径不可能存在;物化时每层 Mkdir 仍以 no-clobber
|
||
// 语义拒绝 verify 与创建之间的并发抢占。
|
||
break
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (plan *pinnedPullRootPlan) materialize() (*pinnedPullRoot, error) {
|
||
if err := plan.verify(); err != nil {
|
||
return nil, err
|
||
}
|
||
if plan.existing != nil {
|
||
root := plan.existing
|
||
plan.existing = nil
|
||
return root, nil
|
||
}
|
||
|
||
current, err := pullOpenParentRoot(plan.ancestor.root, ".")
|
||
if err != nil {
|
||
return nil, fmt.Errorf("固定本地根祖先失败: %w", err)
|
||
}
|
||
for _, segment := range plan.missing {
|
||
if err := pullRootMkdir(current, segment, 0o755); err != nil {
|
||
_ = current.Close()
|
||
return nil, fmt.Errorf("创建本地目录失败: %w", err)
|
||
}
|
||
createdInfo, err := pullRootLstat(current, segment)
|
||
if err != nil || !createdInfo.IsDir() {
|
||
_ = current.Close()
|
||
return nil, fmt.Errorf("读取新建本地目录身份失败")
|
||
}
|
||
next, err := pullOpenParentRoot(current, segment)
|
||
if err != nil {
|
||
_ = current.Close()
|
||
return nil, fmt.Errorf("固定新建本地目录失败: %w", err)
|
||
}
|
||
nextInfo, statErr := pullRootStat(next, ".")
|
||
if statErr != nil || !os.SameFile(createdInfo, nextInfo) {
|
||
_ = next.Close()
|
||
_ = current.Close()
|
||
return nil, fmt.Errorf("新建本地目录在固定期间被替换,已中止写入")
|
||
}
|
||
_ = current.Close()
|
||
current = next
|
||
}
|
||
rootInfo, err := pullRootStat(current, ".")
|
||
if err != nil {
|
||
_ = current.Close()
|
||
return nil, fmt.Errorf("读取本地根目录身份失败: %w", err)
|
||
}
|
||
pathInfo, err := pullPathLstat(plan.absDir)
|
||
if err != nil {
|
||
_ = current.Close()
|
||
return nil, fmt.Errorf("读取本地根目录路径身份失败: %w", err)
|
||
}
|
||
root := &pinnedPullRoot{absDir: plan.absDir, root: current, rootInfo: rootInfo, pathInfo: pathInfo}
|
||
if err := root.verify(); err != nil {
|
||
root.close()
|
||
return nil, err
|
||
}
|
||
return root, nil
|
||
}
|
||
|
||
func (plan *pinnedPullRootPlan) close() {
|
||
if plan.existing != nil {
|
||
plan.existing.close()
|
||
plan.existing = nil
|
||
}
|
||
if plan.ancestor != nil {
|
||
plan.ancestor.close()
|
||
plan.ancestor = nil
|
||
}
|
||
}
|
||
|
||
func openPinnedPullRoot(absDir string) (*pinnedPullRoot, error) {
|
||
if err := pullMkdirAll(absDir, 0o755); err != nil {
|
||
return nil, fmt.Errorf("创建本地目录失败: %w", err)
|
||
}
|
||
return openExistingPinnedPullRoot(absDir)
|
||
}
|
||
|
||
// openExistingPinnedPullRoot 只固定已经存在的本地目录。push/sync 必须用这一入口:
|
||
// 本地源根不存在时直接失败,不能为了扫描或上传隐式创建一个空源目录。
|
||
func openExistingPinnedPullRoot(absDir string) (*pinnedPullRoot, error) {
|
||
baseRoot, err := pullOpenRoot(absDir)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("打开本地根目录失败: %w", err)
|
||
}
|
||
rootInfo, err := pullRootStat(baseRoot, ".")
|
||
if err != nil {
|
||
_ = baseRoot.Close()
|
||
return nil, fmt.Errorf("读取本地根目录身份失败: %w", err)
|
||
}
|
||
pathInfo, err := pullPathLstat(absDir)
|
||
if err != nil {
|
||
_ = baseRoot.Close()
|
||
return nil, fmt.Errorf("读取本地根目录路径身份失败: %w", err)
|
||
}
|
||
root := &pinnedPullRoot{absDir: absDir, root: baseRoot, rootInfo: rootInfo, pathInfo: pathInfo}
|
||
if err := root.verify(); err != nil {
|
||
root.close()
|
||
return nil, err
|
||
}
|
||
return root, nil
|
||
}
|
||
|
||
func openPinnedPullTarget(absDir, rel string) (*pinnedPullTarget, error) {
|
||
root, err := openPinnedPullRoot(absDir)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
target, err := root.openTarget(rel)
|
||
if err != nil {
|
||
root.close()
|
||
return nil, err
|
||
}
|
||
target.ownsBase = true
|
||
return target, nil
|
||
}
|
||
|
||
func (root *pinnedPullRoot) openTarget(rel string) (*pinnedPullTarget, error) {
|
||
if err := root.verify(); err != nil {
|
||
return nil, err
|
||
}
|
||
localPath := filepath.Join(root.absDir, filepath.FromSlash(rel))
|
||
localRel, err := pullRelPath(root.absDir, localPath)
|
||
if err != nil || localRel == ".." || strings.HasPrefix(localRel, ".."+string(filepath.Separator)) {
|
||
return nil, fmt.Errorf("远端路径 %q 逃逸出本地根目录", rel)
|
||
}
|
||
parentRel := filepath.Dir(localRel)
|
||
parentRoot, parentChain, err := root.openTargetParent(parentRel)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
target := &pinnedPullTarget{
|
||
base: root, parentRoot: parentRoot, parentChain: parentChain,
|
||
name: filepath.Base(localRel),
|
||
}
|
||
if err := target.verifyParent(); err != nil {
|
||
target.close()
|
||
return nil, err
|
||
}
|
||
return target, nil
|
||
}
|
||
|
||
// openTargetParent 从固定根开始逐层创建并打开目标父目录。os.Root 会安全地阻止
|
||
// 软链接逃逸根外,但允许跟随仍指向根内的相对软链接;镜像语义不能接受这种重定向,
|
||
// 否则 remote sub/a.txt 可能覆盖同一根下 victim/a.txt。因此每一层都必须:
|
||
//
|
||
// 1. 以 Lstat 拒绝软链接 / junction 及非目录;
|
||
// 2. 通过上一层已固定的 Root 打开下一层;
|
||
// 3. 再以 Lstat + SameFile 证明打开的就是目录项本身,而不是竞态中换入的链接目标。
|
||
//
|
||
// parentChain 保存每一层目录项身份,后续每次 verifyParent 都从命令根重新复核。
|
||
func (root *pinnedPullRoot) openTargetParent(parentRel string) (*os.Root, []pinnedPullDirIdentity, error) {
|
||
current, err := pullOpenParentRoot(root.root, ".")
|
||
if err != nil {
|
||
return nil, nil, fmt.Errorf("固定本地目标目录失败: %w", err)
|
||
}
|
||
chain := make([]pinnedPullDirIdentity, 0, 4)
|
||
if parentRel == "." {
|
||
info, err := pullRootLstat(root.root, ".")
|
||
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||
_ = current.Close()
|
||
return nil, nil, fmt.Errorf("本地目标目录不是可固定的真实目录")
|
||
}
|
||
openedInfo, statErr := pullRootStat(current, ".")
|
||
if statErr != nil || !os.SameFile(info, openedInfo) {
|
||
_ = current.Close()
|
||
return nil, nil, fmt.Errorf("本地目标目录在固定期间被替换,已中止写入")
|
||
}
|
||
chain = append(chain, pinnedPullDirIdentity{rel: ".", info: info})
|
||
return current, chain, nil
|
||
}
|
||
|
||
prefix := ""
|
||
for _, segment := range strings.Split(parentRel, string(filepath.Separator)) {
|
||
info, lstatErr := pullRootLstat(current, segment)
|
||
if os.IsNotExist(lstatErr) {
|
||
if mkdirErr := pullRootMkdir(current, segment, 0o755); mkdirErr != nil {
|
||
_ = current.Close()
|
||
return nil, nil, fmt.Errorf("创建本地目录失败: %w", mkdirErr)
|
||
}
|
||
info, lstatErr = pullRootLstat(current, segment)
|
||
}
|
||
if lstatErr != nil {
|
||
_ = current.Close()
|
||
return nil, nil, fmt.Errorf("检查本地目标目录失败: %w", lstatErr)
|
||
}
|
||
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||
_ = current.Close()
|
||
return nil, nil, fmt.Errorf("本地目标目录 %q 已存在且不是可固定的真实目录,已拒绝镜像", filepath.Join(prefix, segment))
|
||
}
|
||
|
||
next, openErr := pullOpenParentRoot(current, segment)
|
||
if openErr != nil {
|
||
_ = current.Close()
|
||
return nil, nil, fmt.Errorf("固定本地目标目录失败: %w", openErr)
|
||
}
|
||
openedInfo, statErr := pullRootStat(next, ".")
|
||
currentInfo, currentErr := pullRootLstat(current, segment)
|
||
if statErr != nil || currentErr != nil || !currentInfo.IsDir() || currentInfo.Mode()&os.ModeSymlink != 0 ||
|
||
!os.SameFile(info, currentInfo) || !os.SameFile(currentInfo, openedInfo) {
|
||
_ = next.Close()
|
||
_ = current.Close()
|
||
return nil, nil, fmt.Errorf("本地目标目录在固定期间被替换,已中止写入")
|
||
}
|
||
prefix = filepath.Join(prefix, segment)
|
||
chain = append(chain, pinnedPullDirIdentity{rel: prefix, info: currentInfo})
|
||
_ = current.Close()
|
||
current = next
|
||
}
|
||
return current, chain, nil
|
||
}
|
||
|
||
func (root *pinnedPullRoot) verify() error {
|
||
current, err := pullPathStat(root.absDir)
|
||
currentPath, pathErr := pullPathLstat(root.absDir)
|
||
if err != nil || pathErr != nil || !os.SameFile(root.rootInfo, current) || !os.SameFile(root.pathInfo, currentPath) {
|
||
return fmt.Errorf("本地根目录在同步期间被替换,已中止写入")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (root *pinnedPullRoot) close() { _ = root.root.Close() }
|
||
|
||
func (target *pinnedPullTarget) close() {
|
||
_ = target.parentRoot.Close()
|
||
if target.ownsBase {
|
||
target.base.close()
|
||
}
|
||
}
|
||
|
||
func (target *pinnedPullTarget) verifyParent() error {
|
||
if err := target.base.verify(); err != nil {
|
||
return err
|
||
}
|
||
for _, expected := range target.parentChain {
|
||
current, err := pullRootLstat(target.base.root, expected.rel)
|
||
if err != nil || !current.IsDir() || current.Mode()&os.ModeSymlink != 0 || !os.SameFile(expected.info, current) {
|
||
return fmt.Errorf("本地目标目录在下载期间被替换,已中止写入")
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (target *pinnedPullTarget) regularTargetInfo() (os.FileInfo, error) {
|
||
info, err := pullRootLstat(target.parentRoot, target.name)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return nil, nil
|
||
}
|
||
return nil, fmt.Errorf("检查本地目标失败: %w", err)
|
||
}
|
||
if !info.Mode().IsRegular() {
|
||
return nil, fmt.Errorf("本地目标 %q 已存在且不是常规文件,已拒绝覆盖", target.name)
|
||
}
|
||
return info, nil
|
||
}
|
||
|
||
func createPinnedPullTemp(root *os.Root) (*os.File, string, error) {
|
||
for i := 0; i < 100; i++ {
|
||
name := ".dws-pull-" + pullTempName()
|
||
file, err := root.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600)
|
||
if err == nil {
|
||
return file, name, nil
|
||
}
|
||
if !os.IsExist(err) {
|
||
return nil, "", err
|
||
}
|
||
}
|
||
return nil, "", fmt.Errorf("创建不重名临时文件失败")
|
||
}
|
||
|
||
func defaultPullDownloadFile(ctx context.Context, url string, headers map[string]string, destination *os.File) error {
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for key, value := range headers {
|
||
req.Header.Set(key, value)
|
||
}
|
||
resp, err := pullHTTPDo(req)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
body, _ := io.ReadAll(resp.Body)
|
||
return &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)}
|
||
}
|
||
_, err = io.Copy(destination, resp.Body)
|
||
return err
|
||
}
|
||
|
||
// rejectNonRegularLocalTarget 在任何下载工具调用或临时文件创建之前,拒绝已存在但
|
||
// 不是常规文件的镜像目标。不存在的目标可由后续流程安全创建。
|
||
func rejectNonRegularLocalTarget(localPath string) error {
|
||
info, err := pullTargetLstat(localPath)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return nil
|
||
}
|
||
return fmt.Errorf("检查本地目标失败: %w", err)
|
||
}
|
||
if !info.Mode().IsRegular() {
|
||
return fmt.Errorf("本地目标 %q 已存在且不是常规文件,已拒绝覆盖", localPath)
|
||
}
|
||
return nil
|
||
}
|