Files

975 lines
35 KiB
Go
Raw Permalink 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"
"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
}