fix(drive): reject push path type conflicts

Check opposite-type remote entries before dry-run planning or actual writes, and cover both file-folder conflict directions.
This commit is contained in:
chichuan
2026-08-12 22:04:57 +08:00
parent 357b0955b1
commit ae6d9aa16d
2 changed files with 143 additions and 0 deletions
+35
View File
@@ -126,6 +126,11 @@ func runDrivePush(cmd *cobra.Command, _ []string) error {
// 第一阶段:按需创建远端目录(浅层在前,保证父目录先于子目录存在)。
for _, dir := range localDirs {
if msg := pushTypeConflictError(dir, true, remoteFiles, remoteFolders); msg != "" {
res.Summary.Failed++
res.Items = append(res.Items, drivePushItem{RelPath: dir, Action: pushActionFailed, Error: msg})
continue
}
if _, ok := remoteFolders[dir]; ok {
continue // 远端已存在,复用 fileId,不留痕
}
@@ -154,6 +159,11 @@ func runDrivePush(cmd *cobra.Command, _ []string) error {
for i := range localFiles {
lf := localFiles[i]
size := lf.Size
if msg := pushTypeConflictError(lf.RelPath, false, remoteFiles, remoteFolders); msg != "" {
res.Summary.Failed++
res.Items = append(res.Items, drivePushItem{RelPath: lf.RelPath, Action: pushActionFailed, SizeBytes: &size, Error: msg})
continue
}
parentRel, name := splitRel(lf.RelPath)
parentID, ok := remoteFolders[parentRel]
if !ok || parentID == "" {
@@ -294,6 +304,11 @@ func printDrivePushDryRun(ifExists string, remoteFiles map[string]*remoteFile, r
plannedFolders[rel] = fileID
}
for _, dir := range localDirs {
if msg := pushTypeConflictError(dir, true, remoteFiles, remoteFolders); msg != "" {
plan.Summary.Failed++
plan.Items = append(plan.Items, drivePushItem{RelPath: dir, Action: pushActionFailed, Error: msg})
continue
}
if _, ok := plannedFolders[dir]; ok {
continue
}
@@ -308,6 +323,11 @@ func printDrivePushDryRun(ifExists string, remoteFiles map[string]*remoteFile, r
}
for _, lf := range localFiles {
size := lf.Size
if msg := pushTypeConflictError(lf.RelPath, false, remoteFiles, remoteFolders); msg != "" {
plan.Summary.Failed++
plan.Items = append(plan.Items, drivePushItem{RelPath: lf.RelPath, Action: pushActionFailed, SizeBytes: &size, Error: msg})
continue
}
parentRel, _ := splitRel(lf.RelPath)
if plannedFolders[parentRel] == "" {
plan.Summary.Failed++
@@ -342,6 +362,21 @@ func printDrivePushDryRun(ifExists string, remoteFiles map[string]*remoteFile, r
})
}
// pushTypeConflictError 在任何写操作或 dry-run 成功预览之前,拒绝同一路径下
// “本地目录 ↔ 远端文件”或“本地文件 ↔ 远端目录”的类型冲突。
func pushTypeConflictError(rel string, localIsFolder bool, remoteFiles map[string]*remoteFile, remoteFolders map[string]string) string {
if localIsFolder {
if _, exists := remoteFiles[rel]; exists {
return "远端同路径已存在文件,无法创建目录"
}
return ""
}
if _, exists := remoteFolders[rel]; exists {
return "远端同路径已存在目录,无法上传文件"
}
return ""
}
// walkLocalForPush 遍历本地根目录,返回所有子目录 rel_path(不含根本身,浅层在前)
// 与所有常规文件。dirs 升序排序保证父目录先于子目录被创建。
func walkLocalForPush(root string) ([]string, []localPushFile, error) {
@@ -0,0 +1,108 @@
package helpers
import (
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
)
func TestCrossPlatformCoverageDrivePushDryRun_typeConflictsFail(t *testing.T) {
var out bytes.Buffer
prevDeps := deps
deps = &Deps{Out: &Formatter{w: &out}}
t.Cleanup(func() { deps = prevDeps })
tests := []struct {
name string
remoteFiles map[string]*remoteFile
remoteFolders map[string]string
localDirs []string
localFiles []localPushFile
}{
{
name: "remote file conflicts with local folder",
remoteFiles: map[string]*remoteFile{"conflict": {RelPath: "conflict", FileID: "REMOTE_FILE"}},
remoteFolders: map[string]string{"": "ROOT"},
localDirs: []string{"conflict"},
},
{
name: "remote folder conflicts with local file",
remoteFiles: map[string]*remoteFile{},
remoteFolders: map[string]string{"": "ROOT", "conflict": "REMOTE_FOLDER"},
localFiles: []localPushFile{{RelPath: "conflict", Size: 1}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out.Reset()
if err := printDrivePushDryRun(ifExistsSkip, tt.remoteFiles, tt.remoteFolders, tt.localDirs, tt.localFiles); err != nil {
t.Fatalf("printDrivePushDryRun: %v", err)
}
var got drivePushDryRunResult
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("decode dry-run result: %v\n%s", err, out.String())
}
if got.Plan.Summary.Failed != 1 || got.Plan.Summary.Uploaded != 0 {
t.Fatalf("summary = %+v, want failed=1 uploaded=0", got.Plan.Summary)
}
if len(got.Plan.Items) != 1 || got.Plan.Items[0].Action != pushActionFailed || got.Plan.Items[0].Error == "" {
t.Fatalf("items = %+v, want one failed item with an error", got.Plan.Items)
}
})
}
}
func TestCrossPlatformCoverageDrivePush_typeConflictsFailBeforeWrites(t *testing.T) {
tests := []struct {
name string
prepare func(*testing.T, string)
remoteItem string
}{
{
name: "remote file conflicts with local folder",
prepare: func(t *testing.T, root string) {
t.Helper()
if err := os.Mkdir(filepath.Join(root, "conflict"), 0o755); err != nil {
t.Fatal(err)
}
},
remoteItem: `{"name":"conflict","type":"file","fileId":"REMOTE_FILE"}`,
},
{
name: "remote folder conflicts with local file",
prepare: func(t *testing.T, root string) {
t.Helper()
mustWrite(t, filepath.Join(root, "conflict"), "local")
},
remoteItem: `{"name":"conflict","type":"folder","fileId":"REMOTE_FOLDER"}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
root := t.TempDir()
tt.prepare(t, root)
withNoopPut(t)
caller := pushOKCaller(map[string]string{
"ROOT": `{"result":{"items":[` + tt.remoteItem + `],"nextToken":""}}`,
})
err := runDriveCmd(t, caller, "push", "--local-folder", root, "--remote-folder", "ROOT")
var failure *drivePushFailure
if !errors.As(err, &failure) || failure.failed != 1 {
t.Fatalf("error = %T %v, want drivePushFailure(failed=1)", err, err)
}
if calls := caller.callsFor("create_folder"); len(calls) != 0 {
t.Fatalf("type conflict must not create folders, got %v", calls)
}
if calls := caller.callsFor("get_upload_info"); len(calls) != 0 {
t.Fatalf("type conflict must not upload, got %v", calls)
}
})
}
}