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:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user