diff --git a/internal/helpers/drive_push.go b/internal/helpers/drive_push.go index 741fa7eb..d1d10ec2 100644 --- a/internal/helpers/drive_push.go +++ b/internal/helpers/drive_push.go @@ -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) { diff --git a/internal/helpers/drive_push_type_conflict_test.go b/internal/helpers/drive_push_type_conflict_test.go new file mode 100644 index 00000000..e6e294fb --- /dev/null +++ b/internal/helpers/drive_push_type_conflict_test.go @@ -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) + } + }) + } +}