diff --git a/shortcuts/drive/drive_duplicate_remote_test.go b/shortcuts/drive/drive_duplicate_remote_test.go new file mode 100644 index 000000000..33d1676b5 --- /dev/null +++ b/shortcuts/drive/drive_duplicate_remote_test.go @@ -0,0 +1,865 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" +) + +const ( + duplicateRemoteFileIDFirst = "example-file-token-first" + duplicateRemoteFileIDSecond = "example-file-token-second" + duplicateRemoteFileIDThird = "example-file-token-third" + duplicateRemoteFolderID = "example-folder-token" +) + +func TestDriveStatusFailsOnDuplicateRemoteFiles(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + registerDuplicateRemoteFiles(reg) + + err := mountAndRunDrive(t, DriveStatus, []string{ + "+status", + "--local-dir", "local", + "--folder-token", "folder_root", + "--as", "bot", + }, f, stdout) + assertDuplicateRemotePathError(t, err, "dup.txt", duplicateRemoteFileIDFirst, duplicateRemoteFileIDSecond) + if stdout.String() != "" { + t.Fatalf("stdout should be empty on duplicate_remote_path, got: %s", stdout.String()) + } + + reg.Verify(t) +} + +func TestDrivePullFailsOnDuplicateRemoteFilesBeforeWriting(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + registerDuplicateRemoteFiles(reg) + + err := mountAndRunDrive(t, DrivePull, []string{ + "+pull", + "--local-dir", "local", + "--folder-token", "folder_root", + "--as", "bot", + }, f, stdout) + assertDuplicateRemotePathError(t, err, "dup.txt", duplicateRemoteFileIDFirst, duplicateRemoteFileIDSecond) + if _, statErr := os.Stat(filepath.Join("local", "dup.txt")); !os.IsNotExist(statErr) { + t.Fatalf("duplicate default failure must not write local dup.txt; stat err=%v", statErr) + } + if stdout.String() != "" { + t.Fatalf("stdout should be empty on duplicate_remote_path, got: %s", stdout.String()) + } + + reg.Verify(t) +} + +func TestDrivePullRenameDownloadsDuplicateRemoteFilesWithStableHashSuffix(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + registerDuplicateRemoteFiles(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDFirst + "/download", + Status: 200, + Body: []byte("FIRST"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDSecond + "/download", + Status: 200, + Body: []byte("SECOND"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + err := mountAndRunDrive(t, DrivePull, []string{ + "+pull", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-duplicate-remote", "rename", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + renamedRelPath := expectedRenamedRelPath("dup.txt", duplicateRemoteFileIDSecond, 12, 0) + mustReadFile(t, filepath.Join("local", "dup.txt"), "FIRST") + mustReadFile(t, filepath.Join("local", renamedRelPath), "SECOND") + if strings.Contains(renamedRelPath, duplicateRemoteFileIDSecond) { + t.Fatalf("renamed rel_path should not expose raw file token: %s", renamedRelPath) + } + payload := decodeDrivePullStdout(t, stdout.Bytes()) + if got := payload.Data.Summary.Downloaded; got != 2 { + t.Fatalf("summary.downloaded = %d, want 2", got) + } + if item := findPullItem(payload.Data.Items, renamedRelPath); item.SourceID == "" || item.FileToken != "" { + t.Fatalf("rename item should emit source_id without file_token, got: %#v", item) + } + assertPullItemAction(t, stdout.Bytes(), renamedRelPath, "downloaded") + + reg.Verify(t) +} + +func TestDrivePullRenameStrengthensSuffixWhenShortHashTargetAlreadyExists(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + shortHashRelPath := expectedRenamedRelPath("dup.txt", duplicateRemoteFileIDSecond, 12, 0) + registerRemoteListing(reg, "folder_root", []map[string]interface{}{ + {"token": duplicateRemoteFileIDFirst, "name": "dup.txt", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"}, + {"token": duplicateRemoteFileIDSecond, "name": "dup.txt", "type": "file", "size": 6, "created_time": "2", "modified_time": "2"}, + {"token": duplicateRemoteFileIDThird, "name": shortHashRelPath, "type": "file", "size": 7, "created_time": "3", "modified_time": "3"}, + }) + registerDownload(reg, duplicateRemoteFileIDFirst, "FIRST") + registerDownload(reg, duplicateRemoteFileIDSecond, "SECOND") + registerDownload(reg, duplicateRemoteFileIDThird, "THIRD") + + err := mountAndRunDrive(t, DrivePull, []string{ + "+pull", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-duplicate-remote", "rename", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + occupied := occupiedRemotePaths([]driveRemoteEntry{ + {RelPath: "dup.txt"}, + {RelPath: "dup.txt"}, + {RelPath: shortHashRelPath}, + }) + strongerRelPath, err := relPathWithUniqueFileTokenSuffix("dup.txt", duplicateRemoteFileIDSecond, occupied) + if err != nil { + t.Fatalf("relPathWithUniqueFileTokenSuffix: %v", err) + } + if strongerRelPath == shortHashRelPath { + t.Fatalf("expected stronger unique suffix when %q is already occupied", shortHashRelPath) + } + mustReadFile(t, filepath.Join("local", shortHashRelPath), "THIRD") + mustReadFile(t, filepath.Join("local", strongerRelPath), "SECOND") + payload := decodeDrivePullStdout(t, stdout.Bytes()) + if got := payload.Data.Summary.Downloaded; got != 3 { + t.Fatalf("summary.downloaded = %d, want 3", got) + } + if item := findPullItem(payload.Data.Items, strongerRelPath); item.SourceID == "" || item.FileToken != "" { + t.Fatalf("rename item should emit source_id without file_token, got: %#v", item) + } + assertPullItemAction(t, stdout.Bytes(), strongerRelPath, "downloaded") + + reg.Verify(t) +} + +func TestDrivePullRenameAppendsSequenceWhenAllHashSuffixTargetsAreOccupied(t *testing.T) { + fileToken := duplicateRemoteFileIDSecond + tokenHash := stableTokenHash(fileToken) + occupied := map[string]struct{}{ + "dup.txt": {}, + relPathWithSuffix("dup.txt", "__lark_"+tokenHash[:12]): {}, + relPathWithSuffix("dup.txt", "__lark_"+tokenHash[:24]): {}, + relPathWithSuffix("dup.txt", "__lark_"+tokenHash): {}, + relPathWithSuffix("dup.txt", "__lark_"+tokenHash+"_2"): {}, + } + + got, err := relPathWithUniqueFileTokenSuffix("dup.txt", fileToken, occupied) + if err != nil { + t.Fatalf("relPathWithUniqueFileTokenSuffix: %v", err) + } + want := relPathWithSuffix("dup.txt", "__lark_"+tokenHash+"_3") + if got != want { + t.Fatalf("unique rel_path = %q, want %q", got, want) + } +} + +func TestRelPathWithUniqueFileTokenSuffixReturnsErrorAfterMaxAttempts(t *testing.T) { + fileToken := duplicateRemoteFileIDSecond + tokenHash := stableTokenHash(fileToken) + occupied := map[string]struct{}{ + "dup.txt": {}, + } + for _, suffix := range []string{ + "__lark_" + tokenHash[:12], + "__lark_" + tokenHash[:24], + "__lark_" + tokenHash, + } { + occupied[relPathWithSuffix("dup.txt", suffix)] = struct{}{} + } + for attempt := 2; attempt <= driveUniqueSuffixMaxSeq; attempt++ { + occupied[relPathWithSuffix("dup.txt", "__lark_"+tokenHash+"_"+strconv.Itoa(attempt))] = struct{}{} + } + + _, err := relPathWithUniqueFileTokenSuffix("dup.txt", fileToken, occupied) + if err == nil { + t.Fatal("expected relPathWithUniqueFileTokenSuffix to fail after exhausting all suffix attempts") + } +} + +func TestDrivePullNewestChoosesMostRecentDuplicateRemoteFile(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + registerDuplicateRemoteFiles(reg) + registerDownload(reg, duplicateRemoteFileIDSecond, "SECOND") + + err := mountAndRunDrive(t, DrivePull, []string{ + "+pull", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-duplicate-remote", "newest", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + mustReadFile(t, filepath.Join("local", "dup.txt"), "SECOND") + assertPullItemAction(t, stdout.Bytes(), "dup.txt", "downloaded") + payload := decodeDrivePullStdout(t, stdout.Bytes()) + if got := payload.Data.Summary.Downloaded; got != 1 { + t.Fatalf("summary.downloaded = %d, want 1", got) + } + if item := findPullItem(payload.Data.Items, "dup.txt"); item.FileToken != duplicateRemoteFileIDSecond { + t.Fatalf("stdout should surface the chosen newest file token, got: %#v", item) + } + + reg.Verify(t) +} + +func TestDrivePullOldestChoosesOldestDuplicateRemoteFile(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + registerDuplicateRemoteFiles(reg) + registerDownload(reg, duplicateRemoteFileIDFirst, "FIRST") + + err := mountAndRunDrive(t, DrivePull, []string{ + "+pull", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-duplicate-remote", "oldest", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + mustReadFile(t, filepath.Join("local", "dup.txt"), "FIRST") + assertPullItemAction(t, stdout.Bytes(), "dup.txt", "downloaded") + payload := decodeDrivePullStdout(t, stdout.Bytes()) + if got := payload.Data.Summary.Downloaded; got != 1 { + t.Fatalf("summary.downloaded = %d, want 1", got) + } + if item := findPullItem(payload.Data.Items, "dup.txt"); item.FileToken != duplicateRemoteFileIDFirst { + t.Fatalf("stdout should surface the chosen oldest file token, got: %#v", item) + } + + reg.Verify(t) +} + +func TestDrivePullRenameHandlesNestedDuplicateRemoteFilesEndToEnd(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + registerRemoteListing(reg, "folder_root", []map[string]interface{}{ + {"token": duplicateRemoteFolderID, "name": "sub", "type": "folder", "created_time": "1", "modified_time": "1"}, + }) + registerRemoteListing(reg, duplicateRemoteFolderID, []map[string]interface{}{ + {"token": duplicateRemoteFileIDFirst, "name": "dup.txt", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"}, + {"token": duplicateRemoteFileIDSecond, "name": "dup.txt", "type": "file", "size": 6, "created_time": "2", "modified_time": "2"}, + }) + registerDownload(reg, duplicateRemoteFileIDFirst, "FIRST") + registerDownload(reg, duplicateRemoteFileIDSecond, "SECOND") + + err := mountAndRunDrive(t, DrivePull, []string{ + "+pull", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-duplicate-remote", "rename", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + renamedRelPath := expectedRenamedRelPath("sub/dup.txt", duplicateRemoteFileIDSecond, 12, 0) + mustReadFile(t, filepath.Join("local", "sub", "dup.txt"), "FIRST") + mustReadFile(t, filepath.Join("local", filepath.FromSlash(renamedRelPath)), "SECOND") + assertPullItemAction(t, stdout.Bytes(), "sub/dup.txt", "downloaded") + assertPullItemAction(t, stdout.Bytes(), renamedRelPath, "downloaded") + + reg.Verify(t) +} + +func TestDrivePushFailsOnDuplicateRemoteFilesBeforeUpload(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join("local", "dup.txt"), []byte("LOCAL"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + registerDuplicateRemoteFiles(reg) + + err := mountAndRunDrive(t, DrivePush, []string{ + "+push", + "--local-dir", "local", + "--folder-token", "folder_root", + "--if-exists", "overwrite", + "--delete-remote", + "--yes", + "--as", "bot", + }, f, stdout) + assertDuplicateRemotePathError(t, err, "dup.txt", duplicateRemoteFileIDFirst, duplicateRemoteFileIDSecond) + if stdout.String() != "" { + t.Fatalf("stdout should be empty on duplicate_remote_path, got: %s", stdout.String()) + } + + reg.Verify(t) +} + +func TestDrivePullFailsOnRemoteFileFolderConflictEvenWithRename(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + registerRemoteListing(reg, "folder_root", []map[string]interface{}{ + {"token": duplicateRemoteFileIDFirst, "name": "dup", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"}, + {"token": duplicateRemoteFolderID, "name": "dup", "type": "folder", "created_time": "2", "modified_time": "2"}, + }) + registerRemoteListing(reg, duplicateRemoteFolderID, nil) + + err := mountAndRunDrive(t, DrivePull, []string{ + "+pull", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-duplicate-remote", "rename", + "--as", "bot", + }, f, stdout) + assertDuplicateRemotePathError(t, err, "dup", duplicateRemoteFileIDFirst, duplicateRemoteFolderID) + if stdout.String() != "" { + t.Fatalf("stdout should be empty on duplicate_remote_path, got: %s", stdout.String()) + } + + reg.Verify(t) +} + +func TestDrivePushFailsOnRemoteFileFolderConflictEvenWithNewest(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join("local", "dup"), []byte("LOCAL"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + registerRemoteListing(reg, "folder_root", []map[string]interface{}{ + {"token": duplicateRemoteFileIDFirst, "name": "dup", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"}, + {"token": duplicateRemoteFolderID, "name": "dup", "type": "folder", "created_time": "2", "modified_time": "2"}, + }) + registerRemoteListing(reg, duplicateRemoteFolderID, nil) + + err := mountAndRunDrive(t, DrivePush, []string{ + "+push", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-duplicate-remote", "newest", + "--if-exists", "skip", + "--as", "bot", + }, f, stdout) + assertDuplicateRemotePathError(t, err, "dup", duplicateRemoteFileIDFirst, duplicateRemoteFolderID) + if stdout.String() != "" { + t.Fatalf("stdout should be empty on duplicate_remote_path, got: %s", stdout.String()) + } + + reg.Verify(t) +} + +func TestDrivePushDeleteRemoteDeletesUnchosenDuplicateSibling(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join("local", "dup.txt"), []byte("LOCAL"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + registerDuplicateRemoteFiles(reg) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDFirst, + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{}, + }, + }) + + err := mountAndRunDrive(t, DrivePush, []string{ + "+push", + "--local-dir", "local", + "--folder-token", "folder_root", + "--if-exists", "skip", + "--on-duplicate-remote", "newest", + "--delete-remote", + "--yes", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + assertPushItemAction(t, stdout.Bytes(), "dup.txt", "deleted_remote", duplicateRemoteFileIDFirst) + + reg.Verify(t) +} + +func TestDrivePushOldestOverwritesChosenDuplicateAndDeletesSibling(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join("local", "dup.txt"), []byte("LOCAL"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + registerDuplicateRemoteFiles(reg) + uploadStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "file_token": "dup-oldest-new-token", + "version": "v11", + }, + }, + } + reg.Register(uploadStub) + deleteStub := &httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDSecond, + Body: map[string]interface{}{"code": 0, "msg": "ok"}, + } + reg.Register(deleteStub) + + err := mountAndRunDrive(t, DrivePush, []string{ + "+push", + "--local-dir", "local", + "--folder-token", "folder_root", + "--if-exists", "overwrite", + "--on-duplicate-remote", "oldest", + "--delete-remote", + "--yes", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + body := decodeDriveMultipartBody(t, uploadStub) + if got := body.Fields["file_token"]; got != duplicateRemoteFileIDFirst { + t.Fatalf("upload_all form file_token = %q, want %q", got, duplicateRemoteFileIDFirst) + } + assertPushItemAction(t, stdout.Bytes(), "dup.txt", "deleted_remote", duplicateRemoteFileIDSecond) + if deleteStub.CapturedHeaders == nil { + t.Fatal("DELETE for the newer duplicate sibling was never issued") + } + + reg.Verify(t) +} + +func TestDrivePushNewestResolvesNestedDuplicateRemoteFilesEndToEnd(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join("local", "sub", "dup.txt"), []byte("LOCAL"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + registerRemoteListing(reg, "folder_root", []map[string]interface{}{ + {"token": duplicateRemoteFolderID, "name": "sub", "type": "folder", "created_time": "1", "modified_time": "1"}, + }) + registerRemoteListing(reg, duplicateRemoteFolderID, []map[string]interface{}{ + {"token": duplicateRemoteFileIDFirst, "name": "dup.txt", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"}, + {"token": duplicateRemoteFileIDSecond, "name": "dup.txt", "type": "file", "size": 6, "created_time": "2", "modified_time": "2"}, + }) + uploadStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "file_token": "nested-dup-new-token", + "version": "v7", + }, + }, + } + reg.Register(uploadStub) + deleteStub := &httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDFirst, + Body: map[string]interface{}{"code": 0, "msg": "ok"}, + } + reg.Register(deleteStub) + + err := mountAndRunDrive(t, DrivePush, []string{ + "+push", + "--local-dir", "local", + "--folder-token", "folder_root", + "--if-exists", "overwrite", + "--on-duplicate-remote", "newest", + "--delete-remote", + "--yes", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + body := decodeDriveMultipartBody(t, uploadStub) + if got := body.Fields["file_token"]; got != duplicateRemoteFileIDSecond { + t.Fatalf("upload_all form file_token = %q, want %q", got, duplicateRemoteFileIDSecond) + } + assertPushItemAction(t, stdout.Bytes(), "sub/dup.txt", "deleted_remote", duplicateRemoteFileIDFirst) + if deleteStub.CapturedHeaders == nil { + t.Fatal("DELETE for nested duplicate sibling was never issued") + } + + reg.Verify(t) +} + +func TestChooseRemoteFileSortsByParsedTimes(t *testing.T) { + files := []driveRemoteEntry{ + {FileToken: "token_b", CreatedTime: "9", ModifiedTime: "9"}, + {FileToken: "token_a", CreatedTime: "10", ModifiedTime: "10"}, + } + gotNewest, err := chooseRemoteFile(files, driveDuplicateRemoteNewest) + if err != nil { + t.Fatalf("chooseRemoteFile newest: %v", err) + } + if gotNewest.FileToken != "token_a" { + t.Fatalf("newest token = %q, want token_a", gotNewest.FileToken) + } + gotOldest, err := chooseRemoteFile(files, driveDuplicateRemoteOldest) + if err != nil { + t.Fatalf("chooseRemoteFile oldest: %v", err) + } + if gotOldest.FileToken != "token_b" { + t.Fatalf("oldest token = %q, want token_b", gotOldest.FileToken) + } +} + +func TestChooseRemoteFileFallsBackToFileTokenOnTimeParseFailure(t *testing.T) { + files := []driveRemoteEntry{ + {FileToken: "token_a", CreatedTime: "bad", ModifiedTime: "bad"}, + {FileToken: "token_b", CreatedTime: "10", ModifiedTime: "10"}, + } + got, err := chooseRemoteFile(files, driveDuplicateRemoteNewest) + if err != nil { + t.Fatalf("chooseRemoteFile: %v", err) + } + if got.FileToken != "token_a" { + t.Fatalf("fallback token = %q, want token_a", got.FileToken) + } +} + +func TestChooseRemoteFileRejectsEmptyCandidates(t *testing.T) { + _, err := chooseRemoteFile(nil, driveDuplicateRemoteNewest) + if err == nil { + t.Fatal("expected chooseRemoteFile to reject empty candidates") + } +} + +func TestDrivePullRemoteViewsRejectsUnknownStrategy(t *testing.T) { + _, _, err := drivePullRemoteViews([]driveRemoteEntry{ + {RelPath: "dup.txt", Type: driveTypeFile, FileToken: duplicateRemoteFileIDFirst}, + {RelPath: "dup.txt", Type: driveTypeFile, FileToken: duplicateRemoteFileIDSecond}, + }, "mystery") + if err == nil { + t.Fatal("expected drivePullRemoteViews to reject an unknown duplicate strategy") + } +} + +func registerDuplicateRemoteFiles(reg *httpmock.Registry) { + registerRemoteListing(reg, "folder_root", []map[string]interface{}{ + {"token": duplicateRemoteFileIDFirst, "name": "dup.txt", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"}, + {"token": duplicateRemoteFileIDSecond, "name": "dup.txt", "type": "file", "size": 6, "created_time": "2", "modified_time": "2"}, + }) +} + +func registerRemoteListing(reg *httpmock.Registry, folderToken string, files []map[string]interface{}) { + items := make([]interface{}, 0, len(files)) + for _, file := range files { + items = append(items, file) + } + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=" + folderToken, + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": items, + "has_more": false, + }, + }, + }) +} + +func registerDownload(reg *httpmock.Registry, fileToken, body string) { + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/" + fileToken + "/download", + Status: 200, + Body: []byte(body), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) +} + +func assertDuplicateRemotePathError(t *testing.T, err error, relPath string, tokens ...string) { + t.Helper() + if err == nil { + t.Fatal("expected duplicate_remote_path error, got nil") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T: %v", err, err) + } + if exitErr.Code != output.ExitAPI { + t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAPI) + } + if exitErr.Detail == nil || exitErr.Detail.Type != "duplicate_remote_path" { + t.Fatalf("error detail = %#v, want duplicate_remote_path", exitErr.Detail) + } + detailMap, ok := exitErr.Detail.Detail.(map[string]interface{}) + if !ok { + t.Fatalf("duplicate detail type = %T, want map[string]interface{}", exitErr.Detail.Detail) + } + duplicates, ok := detailMap["duplicates_remote"].([]driveDuplicateRemotePath) + if !ok { + t.Fatalf("duplicate detail duplicates_remote type = %T, want []driveDuplicateRemotePath", detailMap["duplicates_remote"]) + } + if len(duplicates) == 0 { + t.Fatal("duplicate detail should include at least one rel_path group") + } + if _, hasLegacyFilesKey := detailMap["files"]; hasLegacyFilesKey { + t.Fatalf("duplicate detail should not expose legacy files key: %#v", detailMap) + } + var matched bool + for _, duplicate := range duplicates { + if duplicate.RelPath != relPath { + continue + } + matched = true + if len(duplicate.Entries) != len(tokens) { + t.Fatalf("duplicate entry count = %d, want %d for rel_path %q", len(duplicate.Entries), len(tokens), relPath) + } + for i, token := range tokens { + if duplicate.Entries[i].FileToken != token { + t.Fatalf("duplicate entry %d file_token = %q, want %q", i, duplicate.Entries[i].FileToken, token) + } + if duplicate.Entries[i].Type == "" { + t.Fatalf("duplicate entry %d missing type for rel_path %q", i, relPath) + } + } + } + if !matched { + t.Fatalf("duplicate detail missing rel_path group %q: %#v", relPath, duplicates) + } + raw, marshalErr := json.Marshal(exitErr.Detail.Detail) + if marshalErr != nil { + t.Fatalf("marshal detail: %v", marshalErr) + } + text := string(raw) + if !strings.Contains(text, relPath) { + t.Fatalf("duplicate detail missing rel_path %q: %s", relPath, text) + } + for _, token := range tokens { + if !strings.Contains(text, token) { + t.Fatalf("duplicate detail missing token %q: %s", token, text) + } + } +} + +type drivePullStdoutPayload struct { + Data struct { + Summary struct { + Downloaded int `json:"downloaded"` + Skipped int `json:"skipped"` + Failed int `json:"failed"` + } `json:"summary"` + Items []struct { + RelPath string `json:"rel_path"` + FileToken string `json:"file_token,omitempty"` + SourceID string `json:"source_id,omitempty"` + Action string `json:"action"` + } `json:"items"` + } `json:"data"` +} + +func decodeDrivePullStdout(t *testing.T, raw []byte) drivePullStdoutPayload { + t.Helper() + var payload drivePullStdoutPayload + if err := json.Unmarshal(raw, &payload); err != nil { + t.Fatalf("decode pull stdout: %v\n%s", err, string(raw)) + } + return payload +} + +func findPullItem(items []struct { + RelPath string `json:"rel_path"` + FileToken string `json:"file_token,omitempty"` + SourceID string `json:"source_id,omitempty"` + Action string `json:"action"` +}, relPath string) struct { + RelPath string `json:"rel_path"` + FileToken string `json:"file_token,omitempty"` + SourceID string `json:"source_id,omitempty"` + Action string `json:"action"` +} { + for _, item := range items { + if item.RelPath == relPath { + return item + } + } + return struct { + RelPath string `json:"rel_path"` + FileToken string `json:"file_token,omitempty"` + SourceID string `json:"source_id,omitempty"` + Action string `json:"action"` + }{} +} + +func expectedRenamedRelPath(relPath, fileToken string, hashLen, attempt int) string { + sum := sha256.Sum256([]byte(fileToken)) + hash := hex.EncodeToString(sum[:]) + suffix := "__lark_" + hash[:hashLen] + if attempt > 0 { + suffix = "__lark_" + hash + "_" + strconv.Itoa(attempt) + } + dir, base := path.Split(relPath) + ext := path.Ext(base) + if ext == base { + return dir + base + suffix + } + stem := base[:len(base)-len(ext)] + return dir + stem + suffix + ext +} + +func assertPullItemAction(t *testing.T, raw []byte, relPath, action string) { + t.Helper() + var payload struct { + Data struct { + Items []struct { + RelPath string `json:"rel_path"` + Action string `json:"action"` + } `json:"items"` + } `json:"data"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + t.Fatalf("decode pull stdout: %v\n%s", err, string(raw)) + } + for _, item := range payload.Data.Items { + if item.RelPath == relPath && item.Action == action { + return + } + } + t.Fatalf("missing pull item %q/%q in stdout: %s", relPath, action, string(raw)) +} + +func assertPushItemAction(t *testing.T, raw []byte, relPath, action, fileToken string) { + t.Helper() + var payload struct { + Data struct { + Items []struct { + RelPath string `json:"rel_path"` + Action string `json:"action"` + FileToken string `json:"file_token"` + } `json:"items"` + } `json:"data"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + t.Fatalf("decode push stdout: %v\n%s", err, string(raw)) + } + for _, item := range payload.Data.Items { + if item.RelPath == relPath && item.Action == action && item.FileToken == fileToken { + return + } + } + t.Fatalf("missing push item %q/%q/%q in stdout: %s", relPath, action, fileToken, string(raw)) +} diff --git a/shortcuts/drive/drive_pull.go b/shortcuts/drive/drive_pull.go index 86e3cc7fd..21292430b 100644 --- a/shortcuts/drive/drive_pull.go +++ b/shortcuts/drive/drive_pull.go @@ -28,10 +28,17 @@ const ( type drivePullItem struct { RelPath string `json:"rel_path"` FileToken string `json:"file_token,omitempty"` + SourceID string `json:"source_id,omitempty"` Action string `json:"action"` Error string `json:"error,omitempty"` } +type drivePullTarget struct { + DownloadToken string + ItemFileToken string + ItemSourceID string +} + // DrivePull performs a one-way file-level mirror from a Drive folder onto // a local directory: recursively lists --folder-token, downloads each // type=file entry under --local-dir, and optionally deletes local files @@ -54,12 +61,14 @@ var DrivePull = common.Shortcut{ {Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true}, {Name: "folder-token", Desc: "source Drive folder token", Required: true}, {Name: "if-exists", Desc: "policy when a local file already exists", Default: drivePullIfExistsOverwrite, Enum: []string{drivePullIfExistsOverwrite, drivePullIfExistsSkip}}, + {Name: "on-duplicate-remote", Desc: "policy when multiple remote Drive entries map to the same rel_path", Default: driveDuplicateRemoteFail, Enum: []string{driveDuplicateRemoteFail, driveDuplicateRemoteRename, driveDuplicateRemoteNewest, driveDuplicateRemoteOldest}}, {Name: "delete-local", Type: "bool", Desc: "delete local regular files absent from Drive (file-level mirror; empty directories are NOT pruned); requires --yes"}, {Name: "yes", Type: "bool", Desc: "confirm --delete-local before deleting local files"}, }, Tips: []string{ "Only entries with type=file are downloaded; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.", "Subfolders recurse and are reproduced as local directories under --local-dir; missing parents are created automatically.", + "Duplicate remote rel_path conflicts fail by default. Use --on-duplicate-remote=rename to download duplicate files with stable hashed suffixes.", "--delete-local requires --yes; without --yes the command is rejected upfront so a stray flag never deletes anything.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { @@ -102,6 +111,10 @@ var DrivePull = common.Shortcut{ if ifExists == "" { ifExists = drivePullIfExistsOverwrite } + duplicateRemote := strings.TrimSpace(runtime.Str("on-duplicate-remote")) + if duplicateRemote == "" { + duplicateRemote = driveDuplicateRemoteFail + } deleteLocal := runtime.Bool("delete-local") // Resolve --local-dir to its canonical absolute path before we @@ -132,10 +145,13 @@ var DrivePull = common.Shortcut{ } fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken)) - entries, err := listRemoteFolder(ctx, runtime, folderToken, "") + entries, err := listRemoteFolderEntries(ctx, runtime, folderToken, "") if err != nil { return err } + if duplicates := blockingRemotePathConflicts(entries, duplicateRemote); len(duplicates) > 0 { + return duplicateRemotePathError(duplicates) + } // Two views over the same listing: // - remoteFiles drives the download/skip loop (only type=file // has hashable bytes the local mirror can write back). @@ -143,13 +159,9 @@ var DrivePull = common.Shortcut{ // rel_path Drive owns regardless of type, so a local file // shadowed by a remote folder / online doc / shortcut is NOT // treated as orphaned. - remoteFiles := make(map[string]string, len(entries)) - remotePaths := make(map[string]struct{}, len(entries)) - for rel, entry := range entries { - remotePaths[rel] = struct{}{} - if entry.Type == driveTypeFile { - remoteFiles[rel] = entry.FileToken - } + remoteFiles, remotePaths, err := drivePullRemoteViews(entries, duplicateRemote) + if err != nil { + return output.Errorf(output.ExitInternal, "internal", "%s", err) } var downloaded, skipped, failed, deletedLocal int @@ -164,7 +176,10 @@ var DrivePull = common.Shortcut{ sort.Strings(downloadablePaths) for _, rel := range downloadablePaths { - token := remoteFiles[rel] + targetFile := remoteFiles[rel] + downloadToken := targetFile.DownloadToken + itemFileToken := targetFile.ItemFileToken + itemSourceID := targetFile.ItemSourceID target := filepath.Join(rootRelToCwd, rel) if info, statErr := runtime.FileIO().Stat(target); statErr == nil { @@ -178,7 +193,8 @@ var DrivePull = common.Shortcut{ if info.IsDir() { items = append(items, drivePullItem{ RelPath: rel, - FileToken: token, + FileToken: itemFileToken, + SourceID: itemSourceID, Action: "failed", Error: fmt.Sprintf("local path is a directory, remote is a regular file: %s", target), }) @@ -187,19 +203,19 @@ var DrivePull = common.Shortcut{ continue } if ifExists == drivePullIfExistsSkip { - items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "skipped"}) + items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "skipped"}) skipped++ continue } } - if err := drivePullDownload(ctx, runtime, token, target); err != nil { - items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "failed", Error: err.Error()}) + if err := drivePullDownload(ctx, runtime, downloadToken, target); err != nil { + items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "failed", Error: err.Error()}) failed++ downloadFailed++ continue } - items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "downloaded"}) + items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "downloaded"}) downloaded++ } @@ -307,6 +323,66 @@ func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, file return nil } +func drivePullRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]drivePullTarget, map[string]struct{}, error) { + remoteFiles := make(map[string]drivePullTarget, len(entries)) + remotePaths := make(map[string]struct{}, len(entries)) + fileGroups := make(map[string][]driveRemoteEntry) + occupied := occupiedRemotePaths(entries) + + for _, entry := range entries { + if entry.Type == driveTypeFile { + fileGroups[entry.RelPath] = append(fileGroups[entry.RelPath], entry) + continue + } + remotePaths[entry.RelPath] = struct{}{} + } + + relPaths := make([]string, 0, len(fileGroups)) + for rel := range fileGroups { + relPaths = append(relPaths, rel) + } + sort.Strings(relPaths) + + for _, rel := range relPaths { + files := fileGroups[rel] + if len(files) == 1 { + remoteFiles[rel] = drivePullTarget{DownloadToken: files[0].FileToken, ItemFileToken: files[0].FileToken} + remotePaths[rel] = struct{}{} + continue + } + switch duplicateRemote { + case driveDuplicateRemoteRename: + candidates := append([]driveRemoteEntry(nil), files...) + sortRemoteFiles(candidates, driveDuplicateRemoteOldest) + for idx, file := range candidates { + targetRel := rel + if idx > 0 { + var err error + targetRel, err = relPathWithUniqueFileTokenSuffix(rel, file.FileToken, occupied) + if err != nil { + return nil, nil, err + } + } + remoteFiles[targetRel] = drivePullTarget{ + DownloadToken: file.FileToken, + ItemSourceID: stableTokenIdentifier(file.FileToken), + } + remotePaths[targetRel] = struct{}{} + } + case driveDuplicateRemoteNewest, driveDuplicateRemoteOldest: + chosen, err := chooseRemoteFile(files, duplicateRemote) + if err != nil { + return nil, nil, err + } + remoteFiles[rel] = drivePullTarget{DownloadToken: chosen.FileToken, ItemFileToken: chosen.FileToken} + remotePaths[rel] = struct{}{} + default: + return nil, nil, fmt.Errorf("unsupported duplicate remote strategy %q", duplicateRemote) + } + } + return remoteFiles, remotePaths, nil +} + // drivePullWalkLocal walks the canonical absolute root and returns the // absolute paths of every regular file underneath it. The caller deletes // some of these paths, so it is critical that they are produced by diff --git a/shortcuts/drive/drive_pull_test.go b/shortcuts/drive/drive_pull_test.go index 9b5d661ac..8919a0af4 100644 --- a/shortcuts/drive/drive_pull_test.go +++ b/shortcuts/drive/drive_pull_test.go @@ -293,6 +293,49 @@ func TestDrivePullPaginationHandlesPageTokenField(t *testing.T) { reg.Verify(t) } +func TestDrivePullRenameSummarizesDuplicateDownloadsAndAvoidsRawTokenInRelPath(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + registerDuplicateRemoteFiles(reg) + registerDownload(reg, duplicateRemoteFileIDFirst, "FIRST") + registerDownload(reg, duplicateRemoteFileIDSecond, "SECOND") + + err := mountAndRunDrive(t, DrivePull, []string{ + "+pull", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-duplicate-remote", "rename", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + renamedRelPath := expectedRenamedRelPath("dup.txt", duplicateRemoteFileIDSecond, 12, 0) + payload := decodeDrivePullStdout(t, stdout.Bytes()) + if got := payload.Data.Summary.Downloaded; got != 2 { + t.Fatalf("summary.downloaded = %d, want 2", got) + } + if out := stdout.String(); strings.Contains(out, duplicateRemoteFileIDSecond) { + t.Fatalf("stdout should not expose the raw duplicate file token in rename mode, got: %s", out) + } + if item := findPullItem(payload.Data.Items, renamedRelPath); item.SourceID == "" || item.FileToken != "" { + t.Fatalf("rename item should emit source_id without file_token, got: %#v", item) + } + mustReadFile(t, filepath.Join("local", "dup.txt"), "FIRST") + mustReadFile(t, filepath.Join("local", renamedRelPath), "SECOND") + assertPullItemAction(t, stdout.Bytes(), "dup.txt", "downloaded") + assertPullItemAction(t, stdout.Bytes(), renamedRelPath, "downloaded") + + reg.Verify(t) +} + // TestDrivePullDeleteLocalRequiresYes verifies the upfront safety guard: // --delete-local without --yes must be rejected before any API call. func TestDrivePullDeleteLocalRequiresYes(t *testing.T) { diff --git a/shortcuts/drive/drive_push.go b/shortcuts/drive/drive_push.go index 05350fbf4..6f49a7596 100644 --- a/shortcuts/drive/drive_push.go +++ b/shortcuts/drive/drive_push.go @@ -92,12 +92,14 @@ var DrivePush = common.Shortcut{ {Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true}, {Name: "folder-token", Desc: "target Drive folder token", Required: true}, {Name: "if-exists", Desc: "policy when a Drive file already exists at the same rel_path (default: skip — safe; opt into overwrite explicitly while the backend version field is rolling out)", Default: drivePushIfExistsSkip, Enum: []string{drivePushIfExistsOverwrite, drivePushIfExistsSkip}}, + {Name: "on-duplicate-remote", Desc: "policy when multiple remote Drive entries map to the same rel_path", Default: driveDuplicateRemoteFail, Enum: []string{driveDuplicateRemoteFail, driveDuplicateRemoteNewest, driveDuplicateRemoteOldest}}, {Name: "delete-remote", Type: "bool", Desc: "delete Drive files absent locally (file-level mirror; remote-only directories are not removed); requires --yes"}, {Name: "yes", Type: "bool", Desc: "confirm --delete-remote before deleting Drive files"}, }, Tips: []string{ "This is a file-level mirror: only type=file entries are uploaded, overwritten or deleted. Online docs (docx, sheet, bitable, mindnote, slides), shortcuts, and remote-only directories are never touched.", "Local directory structure (including empty directories) is mirrored to Drive via create_folder; existing remote folders are reused.", + "Duplicate remote rel_path conflicts fail by default before upload, overwrite, or delete. Use --on-duplicate-remote=newest|oldest only when the conflict is duplicate files and you explicitly want to target one.", "Default --if-exists=skip is the safe choice while the upload_all overwrite-version field is rolling out. Pass --if-exists=overwrite to replace remote bytes; on tenants without the field it surfaces a structured api_error and the run exits non-zero.", "--delete-remote requires --yes; without --yes the command is rejected upfront so a stray flag never deletes anything.", "--delete-remote --yes also requires the space:document:delete scope. Validate runs a dynamic pre-flight check when the flag is on, so a missing grant fails the run before any upload — preventing a half-synced state where files were uploaded but the cleanup pass cannot delete.", @@ -164,6 +166,10 @@ var DrivePush = common.Shortcut{ // rolling-out upload_all `file_token`/`version` protocol field. ifExists = drivePushIfExistsSkip } + duplicateRemote := strings.TrimSpace(runtime.Str("on-duplicate-remote")) + if duplicateRemote == "" { + duplicateRemote = driveDuplicateRemoteFail + } deleteRemote := runtime.Bool("delete-remote") // Resolve --local-dir to its canonical absolute path before walking. @@ -190,10 +196,13 @@ var DrivePush = common.Shortcut{ } fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken)) - entries, err := listRemoteFolder(ctx, runtime, folderToken, "") + entries, err := listRemoteFolderEntries(ctx, runtime, folderToken, "") if err != nil { return err } + if duplicates := blockingRemotePathConflicts(entries, duplicateRemote); len(duplicates) > 0 { + return duplicateRemotePathError(duplicates) + } // Two views over the same listing: // - remoteFiles drives upload / overwrite / orphan-delete // decisions (only type=file entries are upload candidates; @@ -203,15 +212,9 @@ var DrivePush = common.Shortcut{ // path skip create_folder when an intermediate folder already // exists, and keeps directory recreation idempotent across // reruns. - remoteFiles := make(map[string]driveRemoteEntry, len(entries)) - remoteFolders := make(map[string]driveRemoteEntry, len(entries)) - for rel, entry := range entries { - switch entry.Type { - case driveTypeFile: - remoteFiles[rel] = entry - case driveTypeFolder: - remoteFolders[rel] = entry - } + remoteFiles, remoteFolders, remoteFileGroups, err := drivePushRemoteViews(entries, duplicateRemote) + if err != nil { + return output.Errorf(output.ExitInternal, "internal", "%s", err) } var uploaded, skipped, failed, deletedRemote int @@ -333,24 +336,31 @@ var DrivePush = common.Shortcut{ } if deleteRemote && !uploadFailed { // Stable iteration order so failures (and tests) are deterministic. - remoteRelPaths := make([]string, 0, len(remoteFiles)) - for p := range remoteFiles { + remoteRelPaths := make([]string, 0, len(remoteFileGroups)) + for p := range remoteFileGroups { remoteRelPaths = append(remoteRelPaths, p) } sort.Strings(remoteRelPaths) for _, rel := range remoteRelPaths { + keepToken := "" if _, ok := localFiles[rel]; ok { - continue + if chosen, ok := remoteFiles[rel]; ok { + keepToken = chosen.FileToken + } } - entry := remoteFiles[rel] - if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil { - items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "delete_failed", Error: err.Error()}) - failed++ - continue + for _, entry := range remoteFileGroups[rel] { + if entry.FileToken == keepToken { + continue + } + if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil { + items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "delete_failed", Error: err.Error()}) + failed++ + continue + } + items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "deleted_remote"}) + deletedRemote++ } - items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "deleted_remote"}) - deletedRemote++ } } @@ -463,6 +473,46 @@ func drivePushWalkLocal(root, cwdCanonical string) (map[string]drivePushLocalFil return files, dirs, nil } +func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]driveRemoteEntry, map[string]driveRemoteEntry, map[string][]driveRemoteEntry, error) { + remoteFiles := make(map[string]driveRemoteEntry, len(entries)) + remoteFolders := make(map[string]driveRemoteEntry, len(entries)) + fileGroups := make(map[string][]driveRemoteEntry) + + for _, entry := range entries { + switch entry.Type { + case driveTypeFile: + fileGroups[entry.RelPath] = append(fileGroups[entry.RelPath], entry) + case driveTypeFolder: + remoteFolders[entry.RelPath] = entry + } + } + + relPaths := make([]string, 0, len(fileGroups)) + for rel := range fileGroups { + relPaths = append(relPaths, rel) + } + sort.Strings(relPaths) + + for _, rel := range relPaths { + files := fileGroups[rel] + if len(files) == 1 { + remoteFiles[rel] = files[0] + continue + } + switch duplicateRemote { + case driveDuplicateRemoteNewest, driveDuplicateRemoteOldest: + chosen, err := chooseRemoteFile(files, duplicateRemote) + if err != nil { + return nil, nil, nil, err + } + remoteFiles[rel] = chosen + default: + return nil, nil, nil, fmt.Errorf("unsupported duplicate remote strategy %q", duplicateRemote) + } + } + return remoteFiles, remoteFolders, fileGroups, nil +} + // drivePushEnsureFolder ensures a folder chain (rel_dir relative to the root // folder identified by rootFolderToken) exists on Drive, creating any // missing segments via /open-apis/drive/v1/files/create_folder. Returns the diff --git a/shortcuts/drive/drive_push_test.go b/shortcuts/drive/drive_push_test.go index 61adb8b58..55eaeafd5 100644 --- a/shortcuts/drive/drive_push_test.go +++ b/shortcuts/drive/drive_push_test.go @@ -454,6 +454,124 @@ func TestDrivePushDeleteRemoteSkipsOnlineDocs(t *testing.T) { } } +func TestDrivePushNewestOverwritesChosenDuplicateAndDeletesSibling(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join("local", "dup.txt"), []byte("LOCAL"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + registerDuplicateRemoteFiles(reg) + uploadStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "file_token": "dup-new-token", + "version": "v99", + }, + }, + } + reg.Register(uploadStub) + deleteStub := &httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDFirst, + Body: map[string]interface{}{"code": 0, "msg": "ok"}, + } + reg.Register(deleteStub) + + err := mountAndRunDrive(t, DrivePush, []string{ + "+push", + "--local-dir", "local", + "--folder-token", "folder_root", + "--if-exists", "overwrite", + "--on-duplicate-remote", "newest", + "--delete-remote", + "--yes", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + body := decodeDriveMultipartBody(t, uploadStub) + if got := body.Fields["file_token"]; got != duplicateRemoteFileIDSecond { + t.Fatalf("upload_all form file_token = %q, want %q", got, duplicateRemoteFileIDSecond) + } + out := stdout.String() + if !strings.Contains(out, `"uploaded": 1`) { + t.Fatalf("expected uploaded=1, got: %s", out) + } + if !strings.Contains(out, `"deleted_remote": 1`) { + t.Fatalf("expected deleted_remote=1, got: %s", out) + } + assertPushItemAction(t, stdout.Bytes(), "dup.txt", "deleted_remote", duplicateRemoteFileIDFirst) + if deleteStub.CapturedHeaders == nil { + t.Fatal("DELETE for the unchosen duplicate sibling was never issued") + } + + reg.Verify(t) +} + +func TestDrivePushDeleteRemoteDeletesEntireDuplicateGroupWithoutLocalCounterpart(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + registerDuplicateRemoteFiles(reg) + deleteFirst := &httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDFirst, + Body: map[string]interface{}{"code": 0, "msg": "ok"}, + } + deleteSecond := &httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDSecond, + Body: map[string]interface{}{"code": 0, "msg": "ok"}, + } + reg.Register(deleteFirst) + reg.Register(deleteSecond) + + err := mountAndRunDrive(t, DrivePush, []string{ + "+push", + "--local-dir", "local", + "--folder-token", "folder_root", + "--if-exists", "skip", + "--on-duplicate-remote", "newest", + "--delete-remote", + "--yes", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + out := stdout.String() + if !strings.Contains(out, `"uploaded": 0`) { + t.Fatalf("expected uploaded=0, got: %s", out) + } + if !strings.Contains(out, `"deleted_remote": 2`) { + t.Fatalf("expected deleted_remote=2, got: %s", out) + } + assertPushItemAction(t, stdout.Bytes(), "dup.txt", "deleted_remote", duplicateRemoteFileIDFirst) + assertPushItemAction(t, stdout.Bytes(), "dup.txt", "deleted_remote", duplicateRemoteFileIDSecond) + if deleteFirst.CapturedHeaders == nil || deleteSecond.CapturedHeaders == nil { + t.Fatal("expected both duplicate remote DELETE requests to be issued") + } + + reg.Verify(t) +} + // TestDrivePushRejectsAbsoluteLocalDir confirms SafeLocalFlagPath surfaces // the proper flag name in the error message. func TestDrivePushRejectsAbsoluteLocalDir(t *testing.T) { diff --git a/shortcuts/drive/drive_status.go b/shortcuts/drive/drive_status.go index 5456bd45a..de811a3f5 100644 --- a/shortcuts/drive/drive_status.go +++ b/shortcuts/drive/drive_status.go @@ -118,19 +118,22 @@ var DriveStatus = common.Shortcut{ } fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken)) - entries, err := listRemoteFolder(ctx, runtime, folderToken, "") + entries, err := listRemoteFolderEntries(ctx, runtime, folderToken, "") if err != nil { return err } + if duplicates := duplicateRemoteFilePaths(entries); len(duplicates) > 0 { + return duplicateRemotePathError(duplicates) + } // +status only diffs binary content, so collapse the unified // listing to type=file. Online docs / shortcuts have no // hashable bytes and are intentionally absent from the diff // view (a docx living next to a same-named local file is a // known no-op). remoteFiles := make(map[string]string, len(entries)) - for rel, entry := range entries { + for _, entry := range entries { if entry.Type == driveTypeFile { - remoteFiles[rel] = entry.FileToken + remoteFiles[entry.RelPath] = entry.FileToken } } diff --git a/shortcuts/drive/drive_status_test.go b/shortcuts/drive/drive_status_test.go index 5467a2ff4..9f20e6fbf 100644 --- a/shortcuts/drive/drive_status_test.go +++ b/shortcuts/drive/drive_status_test.go @@ -213,6 +213,37 @@ func TestDriveStatusPaginatesRemoteListing(t *testing.T) { reg.Verify(t) } +func TestDriveStatusFailsOnRemoteFileFolderConflict(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + registerRemoteListing(reg, "folder_root", []map[string]interface{}{ + {"token": duplicateRemoteFileIDFirst, "name": "dup", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"}, + {"token": duplicateRemoteFolderID, "name": "dup", "type": "folder", "created_time": "2", "modified_time": "2"}, + }) + registerRemoteListing(reg, duplicateRemoteFolderID, []map[string]interface{}{ + {"token": "nested-file-token", "name": "child.txt", "type": "file", "size": 1, "created_time": "3", "modified_time": "3"}, + }) + + err := mountAndRunDrive(t, DriveStatus, []string{ + "+status", + "--local-dir", "local", + "--folder-token", "folder_root", + "--as", "bot", + }, f, stdout) + assertDuplicateRemotePathError(t, err, "dup", duplicateRemoteFileIDFirst, duplicateRemoteFolderID) + if stdout.Len() != 0 { + t.Fatalf("stdout should be empty on duplicate_remote_path, got: %s", stdout.String()) + } + + reg.Verify(t) +} + func TestDriveStatusRejectsMissingLocalDir(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig()) diff --git a/shortcuts/drive/list_remote.go b/shortcuts/drive/list_remote.go index a0a583b62..0616bcfaa 100644 --- a/shortcuts/drive/list_remote.go +++ b/shortcuts/drive/list_remote.go @@ -5,8 +5,14 @@ package drive import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" + "path" + "sort" + "strconv" + "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) @@ -14,52 +20,63 @@ const ( driveListRemotePageSize = 200 driveTypeFile = "file" driveTypeFolder = "folder" + driveUniqueSuffixMaxSeq = 1024 ) -// driveRemoteEntry is one Drive entry returned by listRemoteFolder. It +// driveRemoteEntry is one Drive entry returned by listRemoteFolderEntries. It // carries enough metadata for every shortcut that consumes the listing // to build its own per-shortcut view by filtering on Type. type driveRemoteEntry struct { // FileToken is the Drive token for this entry. For type=folder this // is the folder_token; for everything else it is the file_token. FileToken string + Name string + Size int64 // Type is the Drive entry kind verbatim from the API: // "file" | "folder" | "docx" | "doc" | "sheet" | "bitable" | // "mindnote" | "slides" | "shortcut" | … - Type string + Type string + CreatedTime string + ModifiedTime string // RelPath is the entry's path relative to the listing root. Encoded // with "/" separators on every platform so it matches the rel_paths // produced by the shortcuts' local walkers. RelPath string } -// listRemoteFolder recursively lists folderToken under relBase and -// returns one entry per Drive item, keyed by rel_path. Subfolders are -// descended into and the folder's own entry is also recorded — callers -// can reason about "this rel_path is occupied by a folder" without -// re-listing. -// -// This is the shared backbone for the three sync-disk shortcuts. None -// of them need every field at every call site, so each one filters -// on Type: +type driveDuplicateRemoteEntry struct { + FileToken string `json:"file_token"` + Name string `json:"name"` + Type string `json:"type"` + Size int64 `json:"size,omitempty"` + CreatedTime string `json:"created_time,omitempty"` + ModifiedTime string `json:"modified_time,omitempty"` +} + +type driveDuplicateRemotePath struct { + RelPath string `json:"rel_path"` + Entries []driveDuplicateRemoteEntry `json:"entries"` +} + +// listRemoteFolderEntries recursively lists folderToken under relBase and +// returns one entry per Drive item. Subfolders are descended into and the +// folder's own entry is also recorded, allowing callers to detect multiple +// remote files that map to the same rel_path. // -// - +status (drive_status.go) keeps Type=="file" and uses FileToken -// to drive content-hash diffs against the local tree. -// - +pull (drive_pull.go) keeps Type=="file" + FileToken for the -// download set, and the full key set (every rel_path) as the -// guard for --delete-local. -// - +push (drive_push.go) keeps Type=="file" + FileToken for upload / -// overwrite / orphan-delete decisions, and Type=="folder" + FileToken -// for the create_folder cache. +// The helper deliberately stores every Drive object kind. Online docs and +// shortcuts are skipped by sync shortcuts later, but preserving their rel_path +// here prevents destructive mirror modes from treating a local same-named +// regular file as an orphan when Drive already owns that path. // -// Pagination uses common.PaginationMeta, which accepts both -// page_token and next_page_token — the Drive list endpoint has -// historically returned the latter, but the helper future-proofs -// against a backend rename. -func listRemoteFolder(ctx context.Context, runtime *common.RuntimeContext, folderToken, relBase string) (map[string]driveRemoteEntry, error) { - out := make(map[string]driveRemoteEntry) +// Pagination uses common.PaginationMeta, which accepts both page_token and +// next_page_token. +func listRemoteFolderEntries(ctx context.Context, runtime *common.RuntimeContext, folderToken, relBase string) ([]driveRemoteEntry, error) { + var out []driveRemoteEntry pageToken := "" for { + if err := ctx.Err(); err != nil { + return nil, err + } params := map[string]interface{}{ "folder_token": folderToken, "page_size": fmt.Sprint(driveListRemotePageSize), @@ -84,15 +101,24 @@ func listRemoteFolder(ctx context.Context, runtime *common.RuntimeContext, folde continue } rel := joinRelDrive(relBase, fName) - out[rel] = driveRemoteEntry{FileToken: fToken, Type: fType, RelPath: rel} + out = append(out, driveRemoteEntry{ + FileToken: fToken, + Name: fName, + Size: int64(common.GetFloat(f, "size")), + Type: fType, + CreatedTime: common.GetString(f, "created_time"), + ModifiedTime: common.GetString(f, "modified_time"), + RelPath: rel, + }) if fType == driveTypeFolder { - sub, err := listRemoteFolder(ctx, runtime, fToken, rel) - if err != nil { + if err := ctx.Err(); err != nil { return nil, err } - for k, v := range sub { - out[k] = v + sub, err := listRemoteFolderEntries(ctx, runtime, fToken, rel) + if err != nil { + return nil, err } + out = append(out, sub...) } } hasMore, nextToken := common.PaginationMeta(result) @@ -104,6 +130,208 @@ func listRemoteFolder(ctx context.Context, runtime *common.RuntimeContext, folde return out, nil } +func duplicateRemoteFilePaths(entries []driveRemoteEntry) []driveDuplicateRemotePath { + groups := make(map[string][]driveRemoteEntry) + for _, entry := range entries { + groups[entry.RelPath] = append(groups[entry.RelPath], entry) + } + + relPaths := make([]string, 0, len(groups)) + for relPath, grouped := range groups { + if len(grouped) > 1 { + relPaths = append(relPaths, relPath) + } + } + sort.Strings(relPaths) + + duplicates := make([]driveDuplicateRemotePath, 0, len(relPaths)) + for _, relPath := range relPaths { + grouped := append([]driveRemoteEntry(nil), groups[relPath]...) + sort.SliceStable(grouped, func(i, j int) bool { + if grouped[i].Type != grouped[j].Type { + return grouped[i].Type < grouped[j].Type + } + if cmp, ok := compareDriveTimes(grouped[i].CreatedTime, grouped[j].CreatedTime); ok && cmp != 0 { + return cmp < 0 + } + if cmp, ok := compareDriveTimes(grouped[i].ModifiedTime, grouped[j].ModifiedTime); ok && cmp != 0 { + return cmp < 0 + } + return grouped[i].FileToken < grouped[j].FileToken + }) + dupEntries := make([]driveDuplicateRemoteEntry, 0, len(grouped)) + for _, entry := range grouped { + dupEntries = append(dupEntries, driveDuplicateRemoteEntry{ + FileToken: entry.FileToken, + Name: entry.Name, + Type: entry.Type, + Size: entry.Size, + CreatedTime: entry.CreatedTime, + ModifiedTime: entry.ModifiedTime, + }) + } + duplicates = append(duplicates, driveDuplicateRemotePath{RelPath: relPath, Entries: dupEntries}) + } + return duplicates +} + +func duplicateRemotePathError(duplicates []driveDuplicateRemotePath) *output.ExitError { + return &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{ + Type: "duplicate_remote_path", + Message: "multiple Drive entries map to the same rel_path", + Detail: map[string]interface{}{ + "duplicates_remote": duplicates, + }, + }, + } +} + +const ( + driveDuplicateRemoteFail = "fail" + driveDuplicateRemoteRename = "rename" + driveDuplicateRemoteNewest = "newest" + driveDuplicateRemoteOldest = "oldest" +) + +func sortRemoteFiles(files []driveRemoteEntry, strategy string) { + sort.SliceStable(files, func(i, j int) bool { + a, b := files[i], files[j] + switch strategy { + case driveDuplicateRemoteNewest: + if cmp, ok := compareDriveTimes(a.ModifiedTime, b.ModifiedTime); ok && cmp != 0 { + return cmp > 0 + } else if !ok { + return a.FileToken < b.FileToken + } + if cmp, ok := compareDriveTimes(a.CreatedTime, b.CreatedTime); ok && cmp != 0 { + return cmp > 0 + } else if !ok { + return a.FileToken < b.FileToken + } + default: + if cmp, ok := compareDriveTimes(a.CreatedTime, b.CreatedTime); ok && cmp != 0 { + return cmp < 0 + } else if !ok { + return a.FileToken < b.FileToken + } + if cmp, ok := compareDriveTimes(a.ModifiedTime, b.ModifiedTime); ok && cmp != 0 { + return cmp < 0 + } else if !ok { + return a.FileToken < b.FileToken + } + } + return a.FileToken < b.FileToken + }) +} + +func compareDriveTimes(a, b string) (int, bool) { + av, aErr := strconv.ParseInt(a, 10, 64) + bv, bErr := strconv.ParseInt(b, 10, 64) + if aErr != nil || bErr != nil { + return 0, false + } + switch { + case av < bv: + return -1, true + case av > bv: + return 1, true + default: + return 0, true + } +} + +func chooseRemoteFile(files []driveRemoteEntry, strategy string) (driveRemoteEntry, error) { + if len(files) == 0 { + return driveRemoteEntry{}, fmt.Errorf("no Drive entries available for strategy %q", strategy) + } + candidates := append([]driveRemoteEntry(nil), files...) + sortRemoteFiles(candidates, strategy) + return candidates[0], nil +} + +func isFileOnlyDuplicatePath(duplicate driveDuplicateRemotePath) bool { + if len(duplicate.Entries) < 2 { + return false + } + for _, entry := range duplicate.Entries { + if entry.Type != driveTypeFile { + return false + } + } + return true +} + +func blockingRemotePathConflicts(entries []driveRemoteEntry, duplicateRemote string) []driveDuplicateRemotePath { + duplicates := duplicateRemoteFilePaths(entries) + if duplicateRemote == driveDuplicateRemoteFail { + return duplicates + } + blocking := make([]driveDuplicateRemotePath, 0, len(duplicates)) + for _, duplicate := range duplicates { + if !isFileOnlyDuplicatePath(duplicate) { + blocking = append(blocking, duplicate) + } + } + return blocking +} + +func occupiedRemotePaths(entries []driveRemoteEntry) map[string]struct{} { + occupied := make(map[string]struct{}, len(entries)) + for _, entry := range entries { + occupied[entry.RelPath] = struct{}{} + } + return occupied +} + +func stableTokenHash(fileToken string) string { + sum := sha256.Sum256([]byte(fileToken)) + return hex.EncodeToString(sum[:]) +} + +func stableTokenIdentifier(fileToken string) string { + hash := stableTokenHash(fileToken) + if len(hash) > 12 { + hash = hash[:12] + } + return "hash_" + hash +} + +func relPathWithSuffix(relPath, suffix string) string { + dir, base := path.Split(relPath) + ext := path.Ext(base) + if ext == base { + return dir + base + suffix + } + stem := base[:len(base)-len(ext)] + return dir + stem + suffix + ext +} + +func relPathWithUniqueFileTokenSuffix(relPath, fileToken string, occupied map[string]struct{}) (string, error) { + tokenHash := stableTokenHash(fileToken) + suffixes := []string{ + "__lark_" + tokenHash[:12], + "__lark_" + tokenHash[:24], + "__lark_" + tokenHash, + } + for _, suffix := range suffixes { + candidate := relPathWithSuffix(relPath, suffix) + if _, exists := occupied[candidate]; !exists { + occupied[candidate] = struct{}{} + return candidate, nil + } + } + for attempt := 2; attempt <= driveUniqueSuffixMaxSeq; attempt++ { + candidate := relPathWithSuffix(relPath, "__lark_"+tokenHash+"_"+strconv.Itoa(attempt)) + if _, exists := occupied[candidate]; !exists { + occupied[candidate] = struct{}{} + return candidate, nil + } + } + return "", fmt.Errorf("could not generate a unique rel_path for %q after %d attempts", relPath, driveUniqueSuffixMaxSeq) +} + // joinRelDrive joins a rel_path base with an entry name using "/". // Empty base means the entry sits at the listing root. Mirrors the // behavior the per-shortcut helpers used to ship and keeps rel_paths diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md index a23e0972e..09b667844 100644 --- a/skills/lark-drive/SKILL.md +++ b/skills/lark-drive/SKILL.md @@ -229,8 +229,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive + [flags]`) | [`+upload`](references/lark-drive-upload.md) | Upload a local file to a Drive folder or wiki node | | [`+create-folder`](references/lark-drive-create-folder.md) | Create a Drive folder, optionally under a parent folder, with bot auto-grant support | | [`+download`](references/lark-drive-download.md) | Download a file from Drive to local | -| [`+status`](references/lark-drive-status.md) | Compare a local directory with a Drive folder by SHA-256 content hash; reports `new_local` / `new_remote` / `modified` / `unchanged` (read-only diff primitive for sync workflows). `--local-dir` 必须是 cwd 内的相对路径,越界路径 CLI 会直接拒绝;目标在 cwd 外时引导用户切换 agent 工作目录,不要私自 `cd` 绕过。 | -| [`+pull`](references/lark-drive-pull.md) | One-way **file-level** mirror of a Drive folder onto a local directory (Drive → local). Supports `--if-exists` (overwrite/skip) and `--delete-local` for orphan cleanup; the destructive `--delete-local` requires `--yes` and only unlinks regular files — empty local directories left behind by remote folder deletes are NOT pruned. Item-level failures exit non-zero (`error.type=partial_failure`) and skip the `--delete-local` pass to avoid half-synced state. `--local-dir` is bounded to cwd by CLI path validation; tell the user to switch the agent's working directory if the target is outside cwd. | +| [`+status`](references/lark-drive-status.md) | Compare a local directory with a Drive folder by SHA-256 content hash; reports `new_local` / `new_remote` / `modified` / `unchanged` (read-only diff primitive for sync workflows). Duplicate remote `rel_path` conflicts fail fast with `error.type=duplicate_remote_path` and list every conflicting entry; do not proceed as if one was chosen. `--local-dir` 必须是 cwd 内的相对路径,越界路径 CLI 会直接拒绝;目标在 cwd 外时引导用户切换 agent 工作目录,不要私自 `cd` 绕过。 | +| [`+pull`](references/lark-drive-pull.md) | One-way **file-level** mirror of a Drive folder onto a local directory (Drive → local). Duplicate remote `rel_path` conflicts fail by default before writing; for duplicate files only, `--on-duplicate-remote rename` downloads all copies with stable hashed suffixes, while `newest` / `oldest` explicitly choose one. Supports `--if-exists` (overwrite/skip) and `--delete-local` for orphan cleanup; the destructive `--delete-local` requires `--yes` and only unlinks regular files — empty local directories left behind by remote folder deletes are NOT pruned. Item-level failures exit non-zero (`error.type=partial_failure`) and skip the `--delete-local` pass to avoid half-synced state. `--local-dir` is bounded to cwd by CLI path validation; tell the user to switch the agent's working directory if the target is outside cwd. | | [`+create-shortcut`](references/lark-drive-create-shortcut.md) | Create a shortcut to an existing Drive file in another folder | | [`+add-comment`](references/lark-drive-add-comment.md) | Add a comment to doc/docx/sheet/slides, also supports wiki URL resolving to doc/docx/sheet/slides | | [`+export`](references/lark-drive-export.md) | Export a doc/docx/sheet/bitable to a local file with limited polling; supports `--file-name` for local naming | @@ -238,7 +238,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive + [flags]`) | [`+import`](references/lark-drive-import.md) | Import a local file to Drive as a cloud document (docx, sheet, bitable) | | [`+move`](references/lark-drive-move.md) | Move a file or folder to another location in Drive | | [`+delete`](references/lark-drive-delete.md) | Delete a Drive file or folder with limited polling for folder deletes | -| [`+push`](references/lark-drive-push.md) | Mirror a local directory onto a Drive folder (local → Drive). Supports `--if-exists` (overwrite/skip) and `--delete-remote` for one-way mirror sync; the destructive `--delete-remote` requires `--yes`. `--local-dir` is bounded to cwd by CLI path validation; tell the user to switch the agent's working directory if the source is outside cwd. | +| [`+push`](references/lark-drive-push.md) | Mirror a local directory onto a Drive folder (local → Drive). Duplicate remote `rel_path` conflicts fail by default before upload / overwrite / delete; use `--on-duplicate-remote newest\|oldest` only when the conflict is duplicate files and you explicitly want to target one existing remote file. Supports `--if-exists` (overwrite/skip) and `--delete-remote` for one-way mirror sync; the destructive `--delete-remote` requires `--yes`. `--local-dir` is bounded to cwd by CLI path validation; tell the user to switch the agent's working directory if the source is outside cwd. | | [`+task_result`](references/lark-drive-task-result.md) | Poll async task result for import, export, move, or delete operations | | [`+apply-permission`](references/lark-drive-apply-permission.md) | Apply to the document owner for view/edit access (user-only; 5/day per document) | diff --git a/skills/lark-drive/references/lark-drive-pull.md b/skills/lark-drive/references/lark-drive-pull.md index 5ef290fbb..d9affdf72 100644 --- a/skills/lark-drive/references/lark-drive-pull.md +++ b/skills/lark-drive/references/lark-drive-pull.md @@ -15,10 +15,23 @@ | `summary.skipped` | 按 `--if-exists=skip` 跳过的文件数 | | `summary.failed` | 下载或写盘失败的文件数 | | `summary.deleted_local` | 启用 `--delete-local --yes` 时删除的本地文件数 | -| `items[]` | 每个文件的明细(`rel_path` / `file_token` / `action` / 失败时的 `error`) | +| `items[]` | 每个文件的明细(`rel_path` / `file_token` / `source_id` / `action` / 失败时的 `error`) | `summary.failed > 0` 时命令以 **非零状态码**(`exit=1`,`error.type=partial_failure`)退出,且同一份 `summary + items` 会在 `error.detail` 里返回;脚本/agent 直接通过 exit code 判断成败即可,不需要再去解 `summary.failed`。 +## 远端同名文件冲突 + +如果 Drive 中多个条目映射到同一个 `rel_path`,默认直接失败(`error.type=duplicate_remote_path`),且不会下载、覆盖或删除任何本地文件。只有“多个 `type=file` 同名”的场景支持显式策略;`file-folder` 这类异构冲突始终直接失败。 + +| 策略 | 行为 | +|------|------| +| `fail` | 默认。返回所有冲突条目的完整信息,不写盘 | +| `rename` | 仅适用于 duplicate file。下载全部重复文件;第一个保留原名,后续文件使用稳定 hash 后缀生成唯一文件名;若短后缀目标已被占用,会自动升级到更强后缀 | +| `newest` | 只下载 `modified_time` 最新的远端文件 | +| `oldest` | 只下载 `created_time` 最早的远端文件 | + +`rename` 命名规则稳定且可追溯:`report.pdf` 的后续重复项会落盘为 `report__lark_.pdf`,例如 `report__lark_3a2f4c5d6e7f.pdf`。如果这个短 hash 目标名已经被同目录下的其他远端对象占用,CLI 会自动改用更长的稳定 hash,必要时再追加序号后缀,直到目标名唯一。此模式下 `items[]` 不再返回可直接复用的 Drive `file_token`;CLI 会在 `source_id` 中返回稳定 hash 标识符,供日志、比对和人工排查使用。 + ## 命令 ```bash @@ -29,6 +42,10 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \ --if-exists skip +# 云端有多个同名二进制文件时,显式下载全部并用稳定 hash 后缀改名 +lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \ + --on-duplicate-remote rename + # 文件级镜像:下载新文件 + 删除云端没有的本地文件(不删空目录) # (--delete-local 必须搭配 --yes,否则会被 Validate 直接拒绝) lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \ @@ -42,6 +59,7 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \ | `--local-dir` | 是 | path | 本地根目录(**必须是 cwd 的相对路径**;绝对路径或逃出 cwd 的相对路径会被 CLI 直接拒绝) | | `--folder-token` | 是 | string | 源 Drive 文件夹 token | | `--if-exists` | 否 | enum | 本地文件已存在时的策略:`overwrite`(默认)/ `skip` | +| `--on-duplicate-remote` | 否 | enum | 云端多个条目映射到同一个 `rel_path` 时的策略:`fail`(默认);如果冲突全是 `type=file`,还可选 `rename` / `newest` / `oldest` | | `--delete-local` | 否 | bool | 删除本地"云端没有的常规文件"(**不删空目录**,因此是 file-level mirror);**必须配合 `--yes`** | | `--yes` | 否 | bool | 确认 `--delete-local`;不传时该破坏性操作在 Validate 阶段被拒绝 | @@ -50,6 +68,7 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \ - **只下载 Drive `type=file` 的二进制文件**。在线文档(`docx` / `sheet` / `bitable` / `mindnote` / `slides`)和快捷方式(`shortcut`)会被跳过 —— 它们没有等价的本地二进制可写盘,否则会变成产生噪声的"假"下载。 - 子文件夹会递归遍历;rel_path 形如 `sub1/sub2/file.txt`,本地缺失的父目录会被自动创建。 - 已存在的本地文件按 `--if-exists` 决定 `overwrite` 还是 `skip`,没有第三种选择 —— 想做 `keep-both` 这类的请自己改名再 pull。 +- 云端同名冲突默认失败;只有“冲突全是 `type=file`”且传了 `--on-duplicate-remote rename|newest|oldest` 时才会继续。 ## --delete-local 的安全行为 @@ -58,6 +77,7 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \ - `--delete-local`(无 `--yes`)→ Validate 直接报错:`--delete-local requires --yes`,没有任何下载、列表请求或删除发生。 - `--delete-local --yes`,**且下载阶段全部成功** → 扫一遍 `--local-dir` 下所有常规文件,把不在云端清单里的逐个 `os.Remove`。**只删常规文件,不删目录**:远端文件夹被删除后,对应本地目录会保留空壳。 - `--delete-local --yes`,**但下载阶段有任何条目失败** → **跳过整个删除阶段**,命令以 `partial_failure` 非零退出。设计意图:避免出现"前面下载失败、后面继续删本地文件"的半同步状态;操作者修好下载错误后再重跑即可。 +- 远端同名文件冲突且使用默认 `fail` → 在下载阶段前失败,删除阶段不会运行。 - 不传 `--delete-local` → `summary.deleted_local` 永远是 0;命令对本地"多余"文件视而不见。 第 6 章里把 `+pull --delete-local` 标了 `high-risk-write`,CLI 这边的实现等价于"未传 `--yes` 时拒绝执行",符合该约束的精神。 @@ -74,15 +94,15 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \ }, "items": [ {"rel_path": "...", "file_token": "...", "action": "downloaded"}, - {"rel_path": "...", "file_token": "...", "action": "skipped"}, - {"rel_path": "...", "file_token": "...", "action": "failed", "error": "..."}, + {"rel_path": "...", "source_id": "hash_3a2f4c5d6e7f", "action": "downloaded"}, + {"rel_path": "...", "source_id": "hash_3a2f4c5d6e7f", "action": "failed", "error": "..."}, {"rel_path": "...", "action": "deleted_local"}, {"rel_path": "...", "action": "delete_failed", "error": "..."} ] } ``` -`rel_path` 始终用 `/` 作为分隔符(跨平台一致)。删除条目(`deleted_local` / `delete_failed`)没有 `file_token`,因为该文件本来就只在本地。 +`rel_path` 始终用 `/` 作为分隔符(跨平台一致)。删除条目(`deleted_local` / `delete_failed`)没有 `file_token`。`rename` 模式下,duplicate 文件条目会返回 `source_id` 而不是可调用 API 的真实 `file_token`;其余模式仍返回真实 `file_token`。 ## 性能注意 diff --git a/skills/lark-drive/references/lark-drive-push.md b/skills/lark-drive/references/lark-drive-push.md index 31fbb57e8..665633187 100644 --- a/skills/lark-drive/references/lark-drive-push.md +++ b/skills/lark-drive/references/lark-drive-push.md @@ -21,6 +21,18 @@ > 本地目录(包括空目录)会被镜像到 Drive;新建的子目录会以 `action: "folder_created"` 出现在 `items[]` 里,但**不计入** `summary.uploaded`(该字段只数文件)。已存在的远端目录复用其 token,不会重复 `create_folder`,也不会出现在 `items[]` 里。 +## 远端同名文件冲突 + +如果 Drive 中多个条目映射到同一个 `rel_path`,默认直接失败(`error.type=duplicate_remote_path`),且不会上传、覆盖或进入 `--delete-remote` 删除阶段。只有“多个 `type=file` 同名”的场景支持显式策略;`file-folder` 这类异构冲突始终直接失败。 + +| 策略 | 行为 | +|------|------| +| `fail` | 默认。返回所有冲突条目的完整信息,不写远端 | +| `newest` | 只把本地文件与 `modified_time` 最新的远端文件对齐 | +| `oldest` | 只把本地文件与 `created_time` 最早的远端文件对齐 | + +`+push` 不提供 `rename`:本地一个文件无法表达要覆盖多个远端对象。若用户想保留多个云端副本,应先显式整理云端文件,再重新 push。 + ## 命令 ```bash @@ -32,6 +44,10 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \ --if-exists overwrite +# 云端已有多个同名二进制文件时,显式选择一个远端目标再覆盖 +lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \ + --if-exists overwrite --on-duplicate-remote newest + # 文件级镜像同步:上传 / 覆盖 + 删除本地不存在的远端文件 # (--delete-remote 必须搭配 --yes,否则会被 Validate 直接拒绝; # 且 Validate 阶段会动态检查 space:document:delete scope,缺权限会立刻失败, @@ -47,6 +63,7 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \ | `--local-dir` | 是 | path | 本地根目录(**必须是 cwd 的相对路径**;绝对路径或逃出 cwd 的相对路径会被 CLI 直接拒绝) | | `--folder-token` | 是 | string | 目标 Drive 文件夹 token | | `--if-exists` | 否 | enum | 远端文件已存在时的策略:`skip`(**默认**,安全)/ `overwrite`(依赖灰度后端协议,详见"覆盖语义") | +| `--on-duplicate-remote` | 否 | enum | 云端多个条目映射到同一个 `rel_path` 时的策略:`fail`(默认);如果冲突全是 `type=file`,还可选 `newest` / `oldest` | | `--delete-remote` | 否 | bool | 删除云端本地不存在的文件(文件级镜像;**不会**清理远端只有的目录);**必须配合 `--yes`**,且 Validate 阶段会动态检查 `space:document:delete` scope | | `--yes` | 否 | bool | 确认 `--delete-remote`;不传时该破坏性操作在 Validate 阶段被拒绝 | @@ -55,6 +72,7 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \ - **只上传 / 覆盖 / 删除 Drive `type=file`**。在线文档(`docx` / `sheet` / `bitable` / `mindnote` / `slides`)和快捷方式(`shortcut`)即使在同一 rel_path 下出现,也不会被覆盖或删除 —— 它们没有等价的本地二进制。 - **本地目录结构整体被镜像**:所有子目录(含**空目录**)会按需在 Drive 上 `create_folder`;同名远端目录复用其 token,不重建。空目录不计入 `summary.uploaded`,但会在 `items[]` 里以 `folder_created` 形式留痕。 - 已存在的远端文件按 `--if-exists` 决定 `overwrite` 还是 `skip`,没有第三种选择 —— 想做 `keep-both` 这类的请自行改名再 push。 +- 云端同名冲突默认失败;只有“冲突全是 `type=file`”且传了 `--on-duplicate-remote newest|oldest` 时才会选择一个远端文件继续。启用 `--delete-remote` 时,未被选中的 duplicate sibling 也会被删除,最终远端只保留一个被选中的文件副本;只有在 `--if-exists=overwrite` 成功时,才能保证该副本内容与本地对齐。 ## 覆盖语义 @@ -71,6 +89,7 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \ - `--delete-remote`(无 `--yes`)→ Validate 直接报错:`--delete-remote requires --yes`,不会发起任何列表 / 上传 / 删除请求。 - `--delete-remote --yes` → Validate 阶段还会**动态做一次** `space:document:delete` 的 scope 预检:缺这条 scope 时整次运行立刻失败、不发任何上传请求,避免出现"上传都成功了,但删除阶段才报 missing_scope"的半同步状态。 - `--delete-remote --yes`(且 scope 已授权)→ 正常执行:先把本地文件 push 上去,再扫一遍远端 `type=file` 列表,把不在本地清单里的逐个删除。**任何上传 / 覆盖 / 建目录失败时,整段 `--delete-remote` 阶段会被跳过**(stderr 上有提示),命令以非零状态退出,远端不会被破坏。 +- 远端同名冲突且使用默认 `fail`,或冲突里混有 folder / 其他非 `type=file` 对象 → 在上传阶段前失败,删除阶段不会运行。 - 不传 `--delete-remote` → `summary.deleted_remote` 永远是 0;命令对远端"多余"文件视而不见。 - 在线文档(docx / sheet / bitable / ...)和快捷方式即使本地完全没有同名文件,也**不会**进入删除候选,因为它们从来不进 `summary.uploaded` 的对齐域。 - **远端只有的空目录、本地已删除的目录**也不会被清理 —— 这是"文件级镜像"的语义边界,命令不会对目录结构做主动收敛。 diff --git a/skills/lark-drive/references/lark-drive-status.md b/skills/lark-drive/references/lark-drive-status.md index a87244232..f9ad2ae05 100644 --- a/skills/lark-drive/references/lark-drive-status.md +++ b/skills/lark-drive/references/lark-drive-status.md @@ -14,6 +14,10 @@ 只读命令:流式 hash,不下载落盘;但双端都有的文件会从云端拉一份字节流过来在内存里算 hash,大目录 / 大文件会有可观的网络流量。 +## 远端同名文件冲突 + +如果 Drive 中多个条目映射到同一个 `rel_path`,`+status` 会在下载/hash 前直接失败,返回 `error.type=duplicate_remote_path`,并在 `error.detail.duplicates_remote[]` 中列出该路径下所有冲突条目的 `file_token`、`type`、名称、大小和时间字段;其中 `created_time`、`modified_time` 缺失时会省略,`size` 在缺失或为 `0` 时都可能被省略。不要把这种情况当成普通 `modified`;它表示同步域本身有歧义,需要先整理云端结构,或在 `+pull` / `+push` 中仅对“duplicate file”场景显式选择冲突策略。 + ## 命令 ```bash @@ -38,6 +42,8 @@ lark-cli drive +status \ ## 输出 schema +成功时: + ```json { "new_local": [{"rel_path": "..."}], @@ -49,10 +55,34 @@ lark-cli drive +status \ `rel_path` 始终用 `/` 作为分隔符(跨平台一致),相对于 `--local-dir` 或 `--folder-token` 的根。仅本地存在时没有 `file_token` 字段。 +远端同名文件冲突时: + +```json +{ + "ok": false, + "error": { + "type": "duplicate_remote_path", + "message": "multiple Drive entries map to the same rel_path", + "detail": { + "duplicates_remote": [ + { + "rel_path": "dup.txt", + "entries": [ + {"file_token": "", "type": "file", "name": "dup.txt", "size": 5, "created_time": "1730000000", "modified_time": "1730000000"}, + {"file_token": "", "type": "folder", "name": "dup.txt", "created_time": "1730000060", "modified_time": "1730000060"} + ] + } + ] + } + } +} +``` + ## 比较范围 - **只比对 Drive `type=file` 的二进制文件**。在线文档(`docx` / `sheet` / `bitable` / `mindnote` / `slides`)和快捷方式(`shortcut`)都被跳过 —— 它们没有等价的本地二进制可对齐,否则会在 `new_remote` 里产生大量误报。 - 子文件夹会递归遍历;rel_path 形如 `sub1/sub2/file.txt`。 +- 多个远端条目映射到同一个 rel_path 时不做隐式选择,默认失败。 - 本地侧只比对常规文件(regular file);符号链接、设备文件等被忽略。 ## 范围限制 diff --git a/tests/cli_e2e/drive/coverage.md b/tests/cli_e2e/drive/coverage.md index 6b259b6bb..c37c8e8e2 100644 --- a/tests/cli_e2e/drive/coverage.md +++ b/tests/cli_e2e/drive/coverage.md @@ -2,17 +2,20 @@ ## Metrics - Denominator: 29 leaf commands -- Covered: 2 -- Coverage: 6.9% +- Covered: 7 +- Coverage: 24.1% ## Summary - TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`. - TestDrive_StatusWorkflow: proves `drive +status` against a real Drive folder. Seeds the remote side via `drive +upload` (`unchanged.txt`, `modified.txt`, `remote-only.txt`), seeds local files with the matching/diverging contents, and asserts every output bucket (`unchanged`, `modified`, `new_local`, `new_remote`) holds exactly the expected `rel_path` and `file_token`. Cleans up uploaded files and the parent folder via best-effort cleanup hooks. +- TestDrive_DuplicateRemoteWorkflow: proves the duplicate-remote workflows against the real backend. One subtest uploads two same-name files into the same Drive folder and asserts `drive +status` and default `drive +pull` both fail with `duplicate_remote_path`, while `drive +pull --on-duplicate-remote=rename` succeeds, downloads both files, and writes a hashed renamed sibling locally. The other subtest uploads duplicate remote files, runs `drive +push --on-duplicate-remote=newest --if-exists=overwrite --delete-remote --yes`, and then re-runs `drive +status` to prove the mirror converged to a single unchanged `dup.txt`. - TestDrive_ApplyPermissionDryRun / TestDrive_ApplyPermissionDryRunRejectsFullAccess: dry-run coverage for `drive +apply-permission`; asserts URL→type inference for docx/sheet/slides, explicit `--type` overriding URL inference when both a recognized URL and `--type` are supplied, bare-token + explicit `--type` path, request method/URL/type-query/perm/remark body shape, optional `remark` omission when unset, and client-side rejection of `--perm full_access`. Runs without hitting the live API. - TestDriveExportDryRun_FileNameMetadata: dry-run coverage for `drive +export`; asserts export task request shape and local `--file-name` / `--output-dir` metadata without calling live APIs. +- TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary. +- TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary. - Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered. -- Blocked area: live upload, live export, comment, permission, subscription, and reply flows still need deterministic remote fixtures and filesystem setup. -- Dry-run note: `drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget` covers the wiki-target request shape for `drive +upload`, but there is still no live upload workflow coverage. +- Blocked area: live export, comment, permission, subscription, and reply flows still need deterministic remote fixtures and filesystem setup. +- Dry-run note: `drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget` covers the wiki-target request shape for `drive +upload`; live duplicate/status workflows also use real `+upload` to seed remote fixtures. ## Command Table @@ -26,9 +29,11 @@ | ✕ | drive +export-download | shortcut | | none | no export-download workflow yet | | ✕ | drive +import | shortcut | | none | no import workflow yet | | ✕ | drive +move | shortcut | | none | no move workflow yet | -| ✓ | drive +status | shortcut | drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_status_dryrun_test.go::TestDrive_StatusDryRun | `--local-dir`; `--folder-token`; bucketed `new_local` / `new_remote` / `modified` / `unchanged` outputs | dry-run pins request shape; live workflow seeds via `+upload` and asserts all four buckets | +| ✓ | drive +pull | shortcut | drive_pull_dryrun_test.go::TestDrive_PullDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--on-duplicate-remote=rename\|newest\|oldest`; `--delete-local --yes` guard | dry-run locks flag/validate shape; live workflow proves duplicate fail-fast and rename recovery | +| ✓ | drive +push | shortcut | drive_push_dryrun_test.go::TestDrive_PushDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--if-exists`; `--on-duplicate-remote=newest\|oldest`; `--delete-remote --yes` | dry-run locks flag/validate shape; live workflow proves overwrite + duplicate cleanup converges status | +| ✓ | drive +status | shortcut | drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_status_dryrun_test.go::TestDrive_StatusDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; bucketed `new_local` / `new_remote` / `modified` / `unchanged` outputs | dry-run pins request shape; live workflows cover both normal hashing buckets and duplicate-remote failure | | ✕ | drive +task_result | shortcut | | none | no async task-result workflow yet | -| ✕ | drive +upload | shortcut | drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget (dry-run only) | `--wiki-token`; `parent_type=wiki`; `parent_node` | no live upload workflow yet | +| ✓ | drive +upload | shortcut | drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget + drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--wiki-token`; `parent_type=wiki`; `parent_node`; named uploads into Drive folders | dry-run covers wiki-target shape; live workflows assert returned file tokens and consume the uploaded fixtures | | ✕ | drive file.comment.replys create | api | | none | no reply workflow yet | | ✕ | drive file.comment.replys delete | api | | none | no reply workflow yet | | ✕ | drive file.comment.replys list | api | | none | no reply workflow yet | diff --git a/tests/cli_e2e/drive/drive_duplicate_sync_workflow_test.go b/tests/cli_e2e/drive/drive_duplicate_sync_workflow_test.go new file mode 100644 index 000000000..0bbb2e2ac --- /dev/null +++ b/tests/cli_e2e/drive/drive_duplicate_sync_workflow_test.go @@ -0,0 +1,208 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestDrive_DuplicateRemoteWorkflow(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) + t.Cleanup(cancel) + + uploadNamedFile := func(t *testing.T, workDir, folderToken, stageName, remoteName, content string) string { + t.Helper() + stagePath := filepath.Join(workDir, stageName) + if err := os.WriteFile(stagePath, []byte(content), 0o644); err != nil { + t.Fatalf("write stage file %s: %v", stageName, err) + } + t.Cleanup(func() { _ = os.Remove(stagePath) }) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+upload", + "--file", stageName, + "--folder-token", folderToken, + "--name", remoteName, + }, + WorkDir: workDir, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + fileToken := gjson.Get(result.Stdout, "data.file_token").String() + require.NotEmpty(t, fileToken, "uploaded file should have a token, stdout:\n%s", result.Stdout) + return fileToken + } + + t.Run("status and pull handle duplicate remote files", func(t *testing.T) { + suffix := clie2e.GenerateSuffix() + folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-dup-pull-"+suffix, "") + + workDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil { + t.Fatalf("mkdir local: %v", err) + } + + firstToken := uploadNamedFile(t, workDir, folderToken, "_dup_first.txt", "dup.txt", "first") + secondToken := uploadNamedFile(t, workDir, folderToken, "_dup_second.txt", "dup.txt", "second") + + statusResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+status", + "--local-dir", "local", + "--folder-token", folderToken, + }, + WorkDir: workDir, + DefaultAs: "bot", + }) + require.NoError(t, err) + if statusResult.ExitCode == 0 { + t.Fatalf("+status should fail on duplicate remote rel_path\nstdout:\n%s\nstderr:\n%s", statusResult.Stdout, statusResult.Stderr) + } + if !strings.Contains(statusResult.Stderr, `"type": "duplicate_remote_path"`) { + t.Fatalf("+status stderr should contain duplicate_remote_path\nstdout:\n%s\nstderr:\n%s", statusResult.Stdout, statusResult.Stderr) + } + + pullFailResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+pull", + "--local-dir", "local", + "--folder-token", folderToken, + }, + WorkDir: workDir, + DefaultAs: "bot", + }) + require.NoError(t, err) + if pullFailResult.ExitCode == 0 { + t.Fatalf("+pull should fail on duplicate remote rel_path by default\nstdout:\n%s\nstderr:\n%s", pullFailResult.Stdout, pullFailResult.Stderr) + } + if !strings.Contains(pullFailResult.Stderr, `"type": "duplicate_remote_path"`) { + t.Fatalf("+pull stderr should contain duplicate_remote_path\nstdout:\n%s\nstderr:\n%s", pullFailResult.Stdout, pullFailResult.Stderr) + } + if _, statErr := os.Stat(filepath.Join(workDir, "local", "dup.txt")); !os.IsNotExist(statErr) { + t.Fatalf("default duplicate failure must not write dup.txt; stat err=%v", statErr) + } + + pullRenameResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+pull", + "--local-dir", "local", + "--folder-token", folderToken, + "--on-duplicate-remote", "rename", + }, + WorkDir: workDir, + DefaultAs: "bot", + }) + require.NoError(t, err) + pullRenameResult.AssertExitCode(t, 0) + pullRenameResult.AssertStdoutStatus(t, true) + + items := gjson.Get(pullRenameResult.Stdout, "data.items") + if items.Array() == nil || len(items.Array()) != 2 { + t.Fatalf("+pull rename should produce two items, stdout:\n%s", pullRenameResult.Stdout) + } + if got := gjson.Get(pullRenameResult.Stdout, "data.summary.downloaded").Int(); got != 2 { + t.Fatalf("+pull rename downloaded=%d, want 2\nstdout:\n%s", got, pullRenameResult.Stdout) + } + relPaths := []string{ + gjson.Get(pullRenameResult.Stdout, "data.items.0.rel_path").String(), + gjson.Get(pullRenameResult.Stdout, "data.items.1.rel_path").String(), + } + var renamedRel string + for _, rel := range relPaths { + if rel != "dup.txt" { + renamedRel = rel + } + } + if renamedRel == "" || !strings.HasPrefix(renamedRel, "dup__lark_") || !strings.HasSuffix(renamedRel, ".txt") { + t.Fatalf("renamed rel_path = %q, want dup__lark_.txt\nstdout:\n%s", renamedRel, pullRenameResult.Stdout) + } + if !strings.Contains(pullRenameResult.Stdout, `"source_id":"hash_`) && + !strings.Contains(pullRenameResult.Stdout, `"source_id": "hash_`) { + t.Fatalf("+pull rename stdout should contain source_id for duplicate items\nstdout:\n%s", pullRenameResult.Stdout) + } + if strings.Contains(pullRenameResult.Stdout, firstToken) || strings.Contains(pullRenameResult.Stdout, secondToken) { + t.Fatalf("+pull rename stdout should not expose raw duplicate file tokens\nstdout:\n%s", pullRenameResult.Stdout) + } + require.FileExists(t, filepath.Join(workDir, "local", "dup.txt")) + require.FileExists(t, filepath.Join(workDir, "local", filepath.FromSlash(renamedRel))) + }) + + t.Run("push resolves duplicate remote files and converges status", func(t *testing.T) { + suffix := clie2e.GenerateSuffix() + folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-dup-push-"+suffix, "") + + workDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil { + t.Fatalf("mkdir local: %v", err) + } + if err := os.WriteFile(filepath.Join(workDir, "local", "dup.txt"), []byte("local-overwrite"), 0o644); err != nil { + t.Fatalf("write local dup.txt: %v", err) + } + + _ = uploadNamedFile(t, workDir, folderToken, "_push_dup_first.txt", "dup.txt", "remote-first") + time.Sleep(1200 * time.Millisecond) + _ = uploadNamedFile(t, workDir, folderToken, "_push_dup_second.txt", "dup.txt", "remote-second") + + pushResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+push", + "--local-dir", "local", + "--folder-token", folderToken, + "--if-exists", "overwrite", + "--on-duplicate-remote", "newest", + "--delete-remote", + "--yes", + }, + WorkDir: workDir, + DefaultAs: "bot", + }) + require.NoError(t, err) + pushResult.AssertExitCode(t, 0) + pushResult.AssertStdoutStatus(t, true) + if got := gjson.Get(pushResult.Stdout, "data.summary.uploaded").Int(); got != 1 { + t.Fatalf("+push uploaded=%d, want 1\nstdout:\n%s", got, pushResult.Stdout) + } + if got := gjson.Get(pushResult.Stdout, "data.summary.deleted_remote").Int(); got != 1 { + t.Fatalf("+push deleted_remote=%d, want 1\nstdout:\n%s", got, pushResult.Stdout) + } + + statusResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+status", + "--local-dir", "local", + "--folder-token", folderToken, + }, + WorkDir: workDir, + DefaultAs: "bot", + }) + require.NoError(t, err) + statusResult.AssertExitCode(t, 0) + statusResult.AssertStdoutStatus(t, true) + if got := gjson.Get(statusResult.Stdout, "data.unchanged.#").Int(); got != 1 { + t.Fatalf("+status unchanged count=%d, want 1\nstdout:\n%s", got, statusResult.Stdout) + } + if got := gjson.Get(statusResult.Stdout, "data.unchanged.0.rel_path").String(); got != "dup.txt" { + t.Fatalf("+status unchanged rel_path=%q, want dup.txt\nstdout:\n%s", got, statusResult.Stdout) + } + if got := gjson.Get(statusResult.Stdout, "data.modified.#").Int(); got != 0 || + gjson.Get(statusResult.Stdout, "data.new_local.#").Int() != 0 || + gjson.Get(statusResult.Stdout, "data.new_remote.#").Int() != 0 { + t.Fatalf("+status should converge to a clean unchanged mirror\nstdout:\n%s", statusResult.Stdout) + } + }) +} diff --git a/tests/cli_e2e/drive/drive_pull_dryrun_test.go b/tests/cli_e2e/drive/drive_pull_dryrun_test.go index 25c515daf..13827a267 100644 --- a/tests/cli_e2e/drive/drive_pull_dryrun_test.go +++ b/tests/cli_e2e/drive/drive_pull_dryrun_test.go @@ -171,3 +171,44 @@ func TestDrive_PullDryRunRejectsMissingFolderToken(t *testing.T) { t.Fatalf("expected folder-token in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) } } + +func TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + for _, strategy := range []string{"rename", "newest", "oldest"} { + t.Run(strategy, func(t *testing.T) { + workDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+pull", + "--local-dir", "local", + "--folder-token", "fldcnE2E001", + "--on-duplicate-remote", strategy, + "--dry-run", + }, + WorkDir: workDir, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + if got := gjson.Get(out, "api.0.method").String(); got != "GET" { + t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" { + t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out) + } + }) + } +} diff --git a/tests/cli_e2e/drive/drive_push_dryrun_test.go b/tests/cli_e2e/drive/drive_push_dryrun_test.go index af8072015..ba28a9076 100644 --- a/tests/cli_e2e/drive/drive_push_dryrun_test.go +++ b/tests/cli_e2e/drive/drive_push_dryrun_test.go @@ -241,3 +241,44 @@ func TestDrive_PushDryRunRejectsMissingFolderToken(t *testing.T) { t.Fatalf("expected folder-token in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) } } + +func TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + for _, strategy := range []string{"newest", "oldest"} { + t.Run(strategy, func(t *testing.T) { + workDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+push", + "--local-dir", "local", + "--folder-token", "fldcnE2E001", + "--on-duplicate-remote", strategy, + "--dry-run", + }, + WorkDir: workDir, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + if got := gjson.Get(out, "api.0.method").String(); got != "GET" { + t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" { + t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out) + } + }) + } +}