From cb9167159b85d122b121386704a0690984d47a27 Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Thu, 12 Nov 2026 09:48:32 -0800 Subject: [PATCH] Support recursive shared-link subfolder downloads --- cmd/help_manifest.go | 5 +- cmd/share_link_download.go | 126 +++-- cmd/share_link_download_path_test.go | 576 ++++++++++++++++++++ cmd/share_link_download_test.go | 19 +- cmd/share_link_ls_test.go | 19 +- docs/commands/dbxcli_share-link_download.md | 10 +- docs/sharing.md | 29 +- 7 files changed, 722 insertions(+), 62 deletions(-) create mode 100644 cmd/share_link_download_path_test.go diff --git a/cmd/help_manifest.go b/cmd/help_manifest.go index 0dec748..31eb1bb 100644 --- a/cmd/help_manifest.go +++ b/cmd/help_manifest.go @@ -272,7 +272,10 @@ var commandManifestRegistry = map[string]jsonCommandManifestMetadata{ commandArg("url", true, false, "url", "Shared link URL"), streamCommandArg("target", false, false, "local_path", "Local destination path, or - for stdout"), }, - Examples: []jsonCommandExample{{Description: "Download a shared link", Command: "dbxcli share-link download https://www.dropbox.com/s/example/file.txt"}}, + Examples: []jsonCommandExample{ + {Description: "Download a shared link", Command: "dbxcli share-link download https://www.dropbox.com/s/example/file.txt"}, + {Description: "Download a selected subfolder", Command: "dbxcli share-link download https://www.dropbox.com/s/example/folder ./out --path /2027/Logoset --recursive"}, + }, Flags: mergeCommandFlagMetadata(sharedLinkPasswordFlagMetadata, map[string]jsonCommandFlagMetadata{ "path": {ValueKind: "dropbox_path"}, "recursive": {ValueKind: "boolean"}, diff --git a/cmd/share_link_download.go b/cmd/share_link_download.go index 3c73e39..fefb97e 100644 --- a/cmd/share_link_download.go +++ b/cmd/share_link_download.go @@ -50,7 +50,7 @@ type shareLinkDownloadResult struct { Link shareLinkJSONMetadata `json:"link"` } -func shareLinkDownload(cmd *cobra.Command, args []string) error { +func shareLinkDownload(cmd *cobra.Command, args []string) (retErr error) { if len(args) == 0 || len(args) > 2 { return invalidArgumentsErrorWithDetails("`share-link download` requires a `url` and optional `target` argument", argumentsErrorDetails("url", "target")) } @@ -71,8 +71,18 @@ func shareLinkDownload(cmd *cobra.Command, args []string) error { arg := sharing.NewGetSharedLinkMetadataArg(url) opts, err := parseShareLinkDownloadOptions(cmd) if err != nil { - return err + return withJSONErrorDetails(err, urlErrorDetails(url), operationErrorDetails("share_link_download")) } + defer func() { + retErr = withJSONErrorDetails(retErr, urlErrorDetails(url), operationErrorDetails("share_link_download")) + if opts.path != "" { + flags := []string{"path"} + if opts.recursive { + flags = append(flags, "recursive") + } + retErr = withJSONErrorDetails(retErr, pathErrorDetails(opts.path), flagsErrorDetails(flags...)) + } + }() if opts.password.set { arg.LinkPassword = opts.password.password } @@ -83,18 +93,17 @@ func shareLinkDownload(cmd *cobra.Command, args []string) error { dbx := newSharedLinkClient(config) if opts.path != "" { arg.Path = opts.path - if err := downloadSharedLinkPath(cmd, dbx, arg, target, opts); err != nil { - return withJSONErrorDetails(err, urlErrorDetails(url), operationErrorDetails("share_link_download")) - } - return nil } + if err := currentContext().Err(); err != nil { + return err + } link, err := dbx.GetSharedLinkMetadataContext(currentContext(), arg) if err != nil { - return withJSONErrorDetails(err, urlErrorDetails(url), operationErrorDetails("share_link_download")) + return err } - if folder, ok := link.(*sharing.FolderLinkMetadata); ok { + if folder, ok := link.(*sharing.FolderLinkMetadata); ok && folder != nil { if !opts.recursive { return invalidArgumentsErrorWithDetails("shared link is a folder (use --recursive to download folders)", mergeJSONErrorDetails(operationErrorDetails("share_link_download"), flagErrorDetails("recursive"), urlErrorDetails(url))) } @@ -104,13 +113,19 @@ func shareLinkDownload(cmd *cobra.Command, args []string) error { dst, err := sharedLinkFolderDownloadTarget(target, folder) if err != nil { - return withJSONErrorDetails(err, urlErrorDetails(url), operationErrorDetails("share_link_download")) + return err } if err := downloadSharedLinkFolder(filesNewFunc(config), dbx, arg, dst, cmd.ErrOrStderr()); err != nil { - return withJSONErrorDetails(err, urlErrorDetails(url), operationErrorDetails("share_link_download")) + return err } commandVerboseStatus(cmd, "Downloaded shared link folder to %s", dst) - return withJSONErrorDetails(renderShareLinkDownloadOutput(cmd, newShareLinkDownloadInput(url, target, opts), dst, folder), urlErrorDetails(url), operationErrorDetails("share_link_download")) + return renderShareLinkDownloadOutput(cmd, newShareLinkDownloadInput(url, target, opts), dst, folder) + } + + if opts.path != "" { + if file, ok := link.(*sharing.FileLinkMetadata); !ok || file == nil { + return errors.New("shared link path is not a downloadable file or folder") + } } if target == "-" { @@ -118,7 +133,7 @@ func shareLinkDownload(cmd *cobra.Command, args []string) error { return invalidArgumentsErrorWithDetails("`share-link download -` cannot be used with --recursive", mergeJSONErrorDetails(operationErrorDetails("share_link_download"), flagErrorDetails("recursive"), urlErrorDetails(url))) } if err := downloadSharedLinkToStdout(dbx, arg, cmd.OutOrStdout()); err != nil { - return withJSONErrorDetails(err, urlErrorDetails(url), operationErrorDetails("share_link_download")) + return err } commandVerboseStatus(cmd, "Downloaded shared link to stdout") return nil @@ -126,10 +141,10 @@ func shareLinkDownload(cmd *cobra.Command, args []string) error { dst, downloaded, err := downloadSharedLinkToFile(dbx, arg, target, cmd.ErrOrStderr()) if err != nil { - return withJSONErrorDetails(err, urlErrorDetails(url), operationErrorDetails("share_link_download")) + return err } commandVerboseStatus(cmd, "Downloaded shared link to %s", dst) - return withJSONErrorDetails(renderShareLinkDownloadOutput(cmd, newShareLinkDownloadInput(url, target, opts), dst, downloaded), urlErrorDetails(url), operationErrorDetails("share_link_download")) + return renderShareLinkDownloadOutput(cmd, newShareLinkDownloadInput(url, target, opts), dst, downloaded) } func parseShareLinkDownloadOptions(cmd *cobra.Command) (shareLinkDownloadOptions, error) { @@ -155,6 +170,15 @@ func parseShareLinkDownloadOptions(cmd *cobra.Command) (shareLinkDownloadOptions if pathArg == "" { return opts, invalidArgumentsErrorWithDetails("`--path` requires a non-empty path", flagErrorDetails("path")) } + // Reject traversal before validatePath can clean it away, as share-link ls does. + for _, name := range strings.Split(pathArg, "/") { + if name == "" || name == "." { + continue + } + if _, err := sharedLinkEntryRelativePath("", name); err != nil { + return opts, invalidArgumentsErrorWithDetails(err.Error(), mergeJSONErrorDetails(flagErrorDetails("path"), pathErrorDetails(pathArg))) + } + } dropboxPath, err := validatePath(pathArg) if err != nil { return opts, err @@ -165,35 +189,16 @@ func parseShareLinkDownloadOptions(cmd *cobra.Command) (shareLinkDownloadOptions opts.path = dropboxPath } - if opts.path != "" && opts.recursive { - return opts, invalidArgumentsErrorWithDetails("`--path` cannot be used with --recursive", mergeJSONErrorDetails(operationErrorDetails("share_link_download"), flagsErrorDetails("path", "recursive"), pathErrorDetails(opts.path))) - } - return opts, nil } -func downloadSharedLinkPath(cmd *cobra.Command, dbx sharedLinkClient, arg *sharing.GetSharedLinkMetadataArg, target string, opts shareLinkDownloadOptions) error { - if target == "-" { - if err := downloadSharedLinkToStdout(dbx, arg, cmd.OutOrStdout()); err != nil { - return err - } - commandVerboseStatus(cmd, "Downloaded shared link path %s to stdout", arg.Path) - return nil - } - - dst, downloaded, err := downloadSharedLinkToFile(dbx, arg, target, cmd.ErrOrStderr()) - if err != nil { - return err - } - commandVerboseStatus(cmd, "Downloaded shared link path %s to %s", arg.Path, dst) - return renderShareLinkDownloadOutput(cmd, newShareLinkDownloadInput(arg.Url, target, opts), dst, downloaded) -} - func sharedLinkFolderDownloadTarget(target string, link *sharing.FolderLinkMetadata) (string, error) { name := link.Name - name = filepath.Base(filepath.FromSlash(name)) - if name == "" || name == "." || name == ".." || name == string(filepath.Separator) { - return "", errors.New("shared link folder metadata did not include a name") + if _, err := sharedLinkEntryRelativePath("", name); err != nil { + return "", fmt.Errorf("invalid shared link folder name: %w", err) + } + if _, err := sharedLinkLocalPath(".", name); err != nil { + return "", err } if target == "" { @@ -214,14 +219,24 @@ func downloadSharedLinkFolder(filesDbx filesClient, dbx sharedLinkClient, arg *s errOut = io.Discard } + // Queue and local paths are relative to the selected folder. API paths + // remain relative to the original shared-link root, even for unmounted links. + start := strings.TrimPrefix(arg.Path, "/") var downloadErrors []error queue := []string{""} for len(queue) > 0 { + if err := currentContext().Err(); err != nil { + return err + } relFolder := queue[0] queue = queue[1:] - entries, err := listSharedLinkFolderEntries(filesDbx, arg, relFolder) + apiFolder := relFolder + if start != "" { + apiFolder = path.Join(start, relFolder) + } + entries, err := listSharedLinkFolderEntries(filesDbx, arg, apiFolder) if err != nil { if relFolder == "" { return err @@ -236,8 +251,15 @@ func downloadSharedLinkFolder(filesDbx filesClient, dbx sharedLinkClient, arg *s } for _, entry := range entries { + if err := currentContext().Err(); err != nil { + return err + } switch f := entry.(type) { case *files.FolderMetadata: + if f == nil { + downloadErrors = append(downloadErrors, errors.New("shared link listing returned nil folder metadata")) + continue + } relPath, err := sharedLinkEntryRelativePath(relFolder, f.Name) if err != nil { downloadErrors = append(downloadErrors, err) @@ -254,6 +276,10 @@ func downloadSharedLinkFolder(filesDbx filesClient, dbx sharedLinkClient, arg *s } queue = append(queue, relPath) case *files.FileMetadata: + if f == nil { + downloadErrors = append(downloadErrors, errors.New("shared link listing returned nil file metadata")) + continue + } relPath, err := sharedLinkEntryRelativePath(relFolder, f.Name) if err != nil { downloadErrors = append(downloadErrors, err) @@ -269,9 +295,15 @@ func downloadSharedLinkFolder(filesDbx filesClient, dbx sharedLinkClient, arg *s continue } fmt.Fprintf(errOut, "Downloading %s -> %s\n", relPath, localPath) - if err := downloadSharedLinkRelativeFile(dbx, arg, relPath, localPath, errOut); err != nil { + apiPath := relPath + if start != "" { + apiPath = path.Join(start, relPath) + } + if err := downloadSharedLinkRelativeFile(dbx, arg, apiPath, localPath, errOut); err != nil { downloadErrors = append(downloadErrors, fmt.Errorf("%s: %w", relPath, err)) } + default: + downloadErrors = append(downloadErrors, fmt.Errorf("unknown shared link entry type in %q", sharedLinkAPIPath(apiFolder))) } } } @@ -280,10 +312,10 @@ func downloadSharedLinkFolder(filesDbx filesClient, dbx sharedLinkClient, arg *s for _, e := range downloadErrors { fmt.Fprintf(errOut, "Error: %v\n", e) } - return fmt.Errorf("share-link download: %d error(s)", len(downloadErrors)) + return fmt.Errorf("share-link download: %d error(s): %w", len(downloadErrors), errors.Join(downloadErrors...)) } - return nil + return currentContext().Err() } func listSharedLinkFolderEntries(dbx filesClient, arg *sharing.GetSharedLinkMetadataArg, relFolder string) ([]files.IsMetadata, error) { @@ -541,21 +573,25 @@ var shareLinkDownloadCmd = &cobra.Command{ Short: "Download shared link content", Long: `Download content from a Dropbox shared link. - If target is omitted, the local filename comes from shared-link metadata. - - Use --path to download a file inside a folder shared link. + - Use --path to select a file or folder inside a folder shared link. + Selected folders require --recursive; files download with or without it. - Use - as target to write file bytes to stdout. Stdout is byte-clean: all progress and errors go to stderr. - - Use --recursive (-r) to download folder shared links. + - Use --recursive (-r) to download folder shared links or selected subfolders. + Existing target directories receive a subdirectory named after the selected folder. + Otherwise, target is used directly as the destination directory. `, Example: ` dbxcli share-link download https://www.dropbox.com/s/example/file.txt dbxcli share-link download https://www.dropbox.com/s/example/file.txt ./local-file.txt dbxcli share-link download https://www.dropbox.com/s/example/folder --path /nested/file.txt + dbxcli share-link download https://www.dropbox.com/s/example/folder ./out --path /2027/Logoset --recursive dbxcli share-link download https://www.dropbox.com/s/example/file.txt - | tar tz`, RunE: shareLinkDownload, } func init() { addSharedLinkPasswordFlags(shareLinkDownloadCmd) - shareLinkDownloadCmd.Flags().String("path", "", "Download a file path inside a folder shared link") + shareLinkDownloadCmd.Flags().String("path", "", "Select a file or folder path inside a folder shared link") shareLinkDownloadCmd.Flags().BoolP("recursive", "r", false, "Recursively download a folder shared link") shareLinkCmd.AddCommand(shareLinkDownloadCmd) enableStructuredOutput(shareLinkDownloadCmd) diff --git a/cmd/share_link_download_path_test.go b/cmd/share_link_download_path_test.go new file mode 100644 index 0000000..d7b2ff9 --- /dev/null +++ b/cmd/share_link_download_path_test.go @@ -0,0 +1,576 @@ +// Copyright © 2026 Dropbox, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/sharing" +) + +func TestShareLinkDownloadSelectedFolder(t *testing.T) { + schema := compileJSONSchemaFile(t, "../docs/json-schema/v1/commands.schema.json") + for _, mounted := range []bool{false, true} { + for _, existing := range []bool{false, true} { + t.Run(fmt.Sprintf("mounted=%t/existing=%t", mounted, existing), func(t *testing.T) { + const selected = "/2027/Logoset" + target := filepath.Join(t.TempDir(), "out") + dst := target + if existing { + if err := os.Mkdir(target, 0755); err != nil { + t.Fatal(err) + } + dst = filepath.Join(target, "Logoset") + } + folder := sharedLinkFolder("/Logoset", listingURL) + folder.PathLower = "" + if mounted { + folder.PathLower = "/mounted/2027/logoset" + } + var listed, continued, downloaded []string + stubSharedLinkClient(t, &mockSharedLinkClient{ + getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { + if arg.Url != listingURL || arg.Path != selected || arg.LinkPassword != "secret" { + t.Fatalf("metadata arg = %#v", arg) + } + return folder, nil + }, + getSharedLinkFileFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, io.ReadCloser, error) { + if arg.Url != listingURL || arg.LinkPassword != "secret" { + t.Fatalf("download arg = %#v", arg) + } + downloaded = append(downloaded, arg.Path) + return downloadableSharedLinkFile(path.Base(arg.Path), "", arg.Url, uint64(len(arg.Path))), io.NopCloser(strings.NewReader(arg.Path)), nil + }, + }) + metadata := func(name, parent string) files.Metadata { + m := files.Metadata{Name: name} + if mounted { + m.PathDisplay = "/mounted" + parent + "/" + name + m.PathLower = strings.ToLower(m.PathDisplay) + } + return m + } + stubFilesClient(t, &mockFilesClient{ + listFolderFn: func(arg *files.ListFolderArg) (*files.ListFolderResult, error) { + if arg.SharedLink == nil || arg.SharedLink.Url != listingURL || arg.SharedLink.Password != "secret" || arg.Recursive { + t.Fatalf("listing arg = %#v", arg) + } + listed = append(listed, arg.Path) + switch arg.Path { + case selected: + return &files.ListFolderResult{Entries: []files.IsMetadata{&files.FileMetadata{Metadata: metadata("logo.png", arg.Path)}}, HasMore: true, Cursor: "selected-next"}, nil + case selected + "/icons": + return &files.ListFolderResult{Entries: []files.IsMetadata{&files.FileMetadata{Metadata: metadata("é-資料.svg", arg.Path)}}, HasMore: true, Cursor: "icons-next"}, nil + case selected + "/icons/deep", selected + "/empty": + return &files.ListFolderResult{}, nil + default: + t.Fatalf("listed outside selected folder: %q", arg.Path) + return nil, nil + } + }, + listFolderContinueFn: func(arg *files.ListFolderContinueArg) (*files.ListFolderResult, error) { + continued = append(continued, arg.Cursor) + switch arg.Cursor { + case "selected-next": + return &files.ListFolderResult{Entries: []files.IsMetadata{listingFolder("icons"), listingFolder("empty")}}, nil + case "icons-next": + return &files.ListFolderResult{Entries: []files.IsMetadata{listingFolder("deep"), listingFile("e\u0301.txt", 1)}}, nil + default: + t.Fatalf("unexpected cursor %q", arg.Cursor) + return nil, nil + } + }, + }) + var out, stderr bytes.Buffer + cmd := newShareLinkDownloadTestCommand(&out, &stderr) + setShareLinkOutputJSON(t, cmd) + if err := cmd.ParseFlags([]string{"--path", "//2027//./Logoset/", "-r", "--password", "secret"}); err != nil { + t.Fatal(err) + } + if err := shareLinkDownload(cmd, []string{listingURL, target}); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(listed, []string{selected, selected + "/icons", selected + "/empty", selected + "/icons/deep"}) { + t.Fatalf("listed = %v", listed) + } + if !reflect.DeepEqual(continued, []string{"selected-next", "icons-next"}) { + t.Fatalf("continued = %v", continued) + } + wantFiles := []string{selected + "/logo.png", selected + "/icons/é-資料.svg", selected + "/icons/e\u0301.txt"} + if !reflect.DeepEqual(downloaded, wantFiles) { + t.Fatalf("downloaded = %v", downloaded) + } + for _, file := range wantFiles { + assertFileContent(t, filepath.Join(dst, filepath.FromSlash(strings.TrimPrefix(file, selected+"/"))), file) + } + for _, dir := range []string{"empty", "icons/deep"} { + info, err := os.Stat(filepath.Join(dst, filepath.FromSlash(dir))) + if err != nil || !info.IsDir() { + t.Fatalf("missing directory %s: %v", dir, err) + } + } + got := decodeShareLinkOperationOutput[shareLinkDownloadInput, shareLinkDownloadResult](t, out.Bytes()) + if got.Input.URL != listingURL || got.Input.Path != selected || got.Input.Target != target || !got.Input.Recursive || !got.Input.Password { + t.Fatalf("input = %#v", got.Input) + } + if len(got.Results) != 1 || got.Results[0].Status != "downloaded" || got.Results[0].Kind != "folder" || got.Results[0].Result.Target != dst || got.Results[0].Result.Link.Name != "Logoset" { + t.Fatalf("results = %#v", got.Results) + } + if strings.Contains(out.String(), "secret") { + t.Fatal("password leaked") + } + if err := schema.Validate(decodeJSONValueForSchema(t, out.Bytes())); err != nil { + t.Fatal(err) + } + }) + } + } +} + +func TestShareLinkDownloadSelectedEmptyFolderTargets(t *testing.T) { + for _, targetKind := range []string{"omitted", "new", "existing"} { + t.Run(targetKind, func(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + dst := "Empty" + args := []string{listingURL} + switch targetKind { + case "new": + args = append(args, "out") + dst = "out" + case "existing": + args = append(args, root) + dst = filepath.Join(root, "Empty") + } + stubSharedLinkClient(t, &mockSharedLinkClient{getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { + folder := sharedLinkFolder("/Empty", arg.Url) + folder.PathLower = "" + return folder, nil + }}) + stubFilesClient(t, &mockFilesClient{listFolderFn: func(arg *files.ListFolderArg) (*files.ListFolderResult, error) { + if arg.Path != "/nested/Empty" { + t.Fatalf("listing = %q", arg.Path) + } + return &files.ListFolderResult{}, nil + }}) + cmd := newShareLinkDownloadTestCommand(nil, nil) + if err := cmd.ParseFlags([]string{"--path", "/nested/Empty", "-r"}); err != nil { + t.Fatal(err) + } + if err := shareLinkDownload(cmd, args); err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(dst) + if err != nil || len(entries) != 0 { + t.Fatalf("empty folder = %v, %v", entries, err) + } + }) + } +} + +func TestShareLinkDownloadSelectedFileModes(t *testing.T) { + schema := compileJSONSchemaFile(t, "../docs/json-schema/v1/commands.schema.json") + for _, recursive := range []bool{false, true} { + for _, stdout := range []bool{false, true} { + t.Run(fmt.Sprintf("recursive=%t/stdout=%t", recursive, stdout), func(t *testing.T) { + calls := 0 + stubSharedLinkClient(t, &mockSharedLinkClient{ + getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { + if arg.Path != "/nested/file.txt" { + t.Fatalf("metadata arg = %#v", arg) + } + return downloadableSharedLinkFile("file.txt", "", arg.Url, 4), nil + }, + getSharedLinkFileFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, io.ReadCloser, error) { + calls++ + if arg.Path != "/nested/file.txt" { + t.Fatalf("download arg = %#v", arg) + } + return downloadableSharedLinkFile("file.txt", "", arg.Url, 4), io.NopCloser(strings.NewReader("data")), nil + }, + }) + target := filepath.Join(t.TempDir(), "file.txt") + if stdout { + target = "-" + } + var out, stderr bytes.Buffer + cmd := newShareLinkDownloadTestCommand(&out, &stderr) + if !stdout { + setShareLinkOutputJSON(t, cmd) + } + if err := cmd.ParseFlags([]string{"--path", "/nested/file.txt", fmt.Sprintf("--recursive=%t", recursive)}); err != nil { + t.Fatal(err) + } + err := shareLinkDownload(cmd, []string{listingURL, target}) + if stdout && recursive { + if err == nil || !strings.Contains(err.Error(), "--recursive") || calls != 0 || out.Len() != 0 { + t.Fatalf("err=%v calls=%d out=%s", err, calls, &out) + } + return + } + if err != nil { + t.Fatal(err) + } + if calls != 1 { + t.Fatalf("download calls=%d", calls) + } + if stdout { + if out.String() != "data" || stderr.Len() != 0 { + t.Fatalf("out=%s stderr=%s", &out, &stderr) + } + return + } + assertFileContent(t, target, "data") + got := decodeShareLinkOperationOutput[shareLinkDownloadInput, shareLinkDownloadResult](t, out.Bytes()) + if got.Input.Path != "/nested/file.txt" || got.Input.Recursive != recursive || got.Results[0].Kind != "file" { + t.Fatalf("output=%#v", got) + } + if err := schema.Validate(decodeJSONValueForSchema(t, out.Bytes())); err != nil { + t.Fatal(err) + } + }) + } + } +} + +func TestShareLinkDownloadSelectedFolderRestrictions(t *testing.T) { + schema := compileJSONSchemaFile(t, "../docs/json-schema/v1/error.schema.json") + for _, mode := range []string{"recursive required", "stdout", "JSON stdout", "unknown metadata"} { + t.Run(mode, func(t *testing.T) { + called := false + stubSharedLinkClient(t, &mockSharedLinkClient{ + getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { + if mode == "unknown metadata" { + return &sharing.SharedLinkMetadata{}, nil + } + return sharedLinkFolder("/folder", arg.Url), nil + }, + getSharedLinkFileFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, io.ReadCloser, error) { + called = true + return nil, nil, nil + }, + }) + stubFilesClient(t, &mockFilesClient{listFolderFn: func(arg *files.ListFolderArg) (*files.ListFolderResult, error) { called = true; return nil, nil }}) + var out bytes.Buffer + cmd := newShareLinkDownloadTestCommand(&out, nil) + flags := []string{"--path", "/nested/folder"} + target := filepath.Join(t.TempDir(), "out") + want := "--recursive" + if mode != "recursive required" { + flags = append(flags, "-r") + } + if strings.Contains(mode, "stdout") { + target = "-" + want = "stdout" + } + if mode == "JSON stdout" { + setShareLinkOutputJSON(t, cmd) + want = "--output=json" + } + if mode == "unknown metadata" { + want = "not a downloadable file or folder" + } + if err := cmd.ParseFlags(flags); err != nil { + t.Fatal(err) + } + err := shareLinkDownload(cmd, []string{listingURL, target}) + if err == nil || !strings.Contains(err.Error(), want) || called || out.Len() != 0 { + t.Fatalf("err=%v called=%t out=%s", err, called, &out) + } + if err := schema.Validate(normalizeJSONValueForSchema(t, newJSONErrorResponse(cmd, err))); err != nil { + t.Fatalf("download error violates JSON v1 schema: %v", err) + } + details := jsonErrorDetails(err) + if details["url"] != listingURL || details["path"] != "/nested/folder" || details["operation"] != "share_link_download" { + t.Fatalf("details=%#v", details) + } + }) + } +} + +func TestShareLinkDownloadSelectedFolderFailures(t *testing.T) { + schema := compileJSONSchemaFile(t, "../docs/json-schema/v1/error.schema.json") + for _, stage := range []string{"metadata", "password required", "incorrect password", "expired", "not found", "list", "nested list", "continue", "nested continue", "missing cursor", "unknown entry", "nil file", "nil folder", "download", "local", "canceled", "cancel listing", "cancel pagination", "cancel download"} { + t.Run(stage, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + old := commandContext + commandContext = ctx + t.Cleanup(func() { commandContext = old }) + want := errors.New(stage) + if stage == "canceled" || strings.HasPrefix(stage, "cancel ") { + want = context.Canceled + } + if stage == "canceled" { + cancel() + } + target := filepath.Join(t.TempDir(), "out") + if stage == "local" { + if err := os.WriteFile(target, []byte("existing"), 0600); err != nil { + t.Fatal(err) + } + } + stubSharedLinkClient(t, &mockSharedLinkClient{ + getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { + switch stage { + case "metadata", "password required", "incorrect password", "expired", "not found": + return nil, want + } + return sharedLinkFolder("/folder", arg.Url), nil + }, + getSharedLinkFileFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, io.ReadCloser, error) { + if strings.HasSuffix(arg.Path, "/bad") { + return nil, nil, want + } + if stage == "cancel download" { + cancel() + } + return downloadableSharedLinkFile("good", "", arg.Url, 4), io.NopCloser(strings.NewReader("data")), nil + }, + }) + stubFilesClient(t, &mockFilesClient{ + listFolderFn: func(arg *files.ListFolderArg) (*files.ListFolderResult, error) { + switch stage { + case "unknown entry": + return &files.ListFolderResult{Entries: []files.IsMetadata{nil}}, nil + case "nil file": + return &files.ListFolderResult{Entries: []files.IsMetadata{(*files.FileMetadata)(nil)}}, nil + case "nil folder": + return &files.ListFolderResult{Entries: []files.IsMetadata{(*files.FolderMetadata)(nil)}}, nil + } + if stage == "list" || (stage == "nested list" && arg.Path == "/nested/folder/child") { + return nil, want + } + if stage == "cancel listing" { + cancel() + return &files.ListFolderResult{}, nil + } + if stage == "continue" || (stage == "nested continue" && strings.HasSuffix(arg.Path, "/child")) || stage == "missing cursor" || stage == "cancel pagination" { + cursor := "next" + if stage == "missing cursor" { + cursor = "" + } + if stage == "cancel pagination" { + cancel() + } + return &files.ListFolderResult{HasMore: true, Cursor: cursor}, nil + } + if strings.HasSuffix(arg.Path, "/child") { + return &files.ListFolderResult{}, nil + } + entries := []files.IsMetadata{listingFile("good", 4), listingFolder("child")} + if stage == "download" { + entries = append(entries, listingFile("bad", 0)) + } + return &files.ListFolderResult{Entries: entries}, nil + }, + listFolderContinueFn: func(arg *files.ListFolderContinueArg) (*files.ListFolderResult, error) { return nil, want }, + }) + var out, stderr bytes.Buffer + cmd := newShareLinkDownloadTestCommand(&out, &stderr) + setShareLinkOutputJSON(t, cmd) + if err := cmd.ParseFlags([]string{"--path", "/nested/folder", "-r"}); err != nil { + t.Fatal(err) + } + err := shareLinkDownload(cmd, []string{listingURL, target}) + if err == nil || out.Len() != 0 { + t.Fatalf("err=%v out=%s", err, &out) + } + if stage != "local" && stage != "missing cursor" && stage != "unknown entry" && stage != "nil file" && stage != "nil folder" && !errors.Is(err, want) { + t.Fatalf("error=%v, want wrapped %v", err, want) + } + if err := schema.Validate(normalizeJSONValueForSchema(t, newJSONErrorResponse(cmd, err))); err != nil { + t.Fatalf("download error violates JSON v1 schema: %v", err) + } + details := jsonErrorDetails(err) + if details["url"] != listingURL || details["path"] != "/nested/folder" || details["operation"] != "share_link_download" || !reflect.DeepEqual(details["flags"], []string{"path", "recursive"}) { + t.Fatalf("details=%#v", details) + } + if stage == "download" { + assertFileContent(t, filepath.Join(target, "good"), "data") + entries, readErr := os.ReadDir(target) + if readErr != nil { + t.Fatal(readErr) + } + for _, entry := range entries { + if strings.Contains(entry.Name(), ".tmp-") || entry.Name() == "bad" { + t.Fatalf("failed file left behind: %s", entry.Name()) + } + } + } + }) + } +} + +func TestShareLinkDownloadSelectedFolderUnsafeNames(t *testing.T) { + names := []string{"", ".", "..", "../escape", "/absolute", `a\b`, `C:\escape`, "a/b", "a\x00b"} + if runtime.GOOS == "windows" { + names = append(names, "CON", "NUL", "C:notes") + } + for _, name := range names { + for _, source := range []string{"metadata", "entry"} { + t.Run(source+"/"+name, func(t *testing.T) { + target := filepath.Join(t.TempDir(), "out") + stubSharedLinkClient(t, &mockSharedLinkClient{ + getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { + folder := sharedLinkFolder("/safe", arg.Url) + if source == "metadata" { + folder.Name = name + } + return folder, nil + }, + getSharedLinkFileFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, io.ReadCloser, error) { + t.Fatal("unsafe file downloaded") + return nil, nil, nil + }, + }) + stubFilesClient(t, &mockFilesClient{listFolderFn: func(arg *files.ListFolderArg) (*files.ListFolderResult, error) { + if arg.Path != "/nested/folder" { + t.Fatalf("unsafe folder listed: %q", arg.Path) + } + return &files.ListFolderResult{Entries: []files.IsMetadata{listingFolder(name), listingFile(name, 0)}}, nil + }}) + cmd := newShareLinkDownloadTestCommand(nil, nil) + if err := cmd.ParseFlags([]string{"--path", "/nested/folder", "-r"}); err != nil { + t.Fatal(err) + } + if err := shareLinkDownload(cmd, []string{listingURL, target}); err == nil { + t.Fatal("unsafe name accepted") + } + }) + } + } +} + +func TestShareLinkDownloadPathsFromLS(t *testing.T) { + const selected = "/2027/Logoset" + filesDbx := &mockFilesClient{listFolderFn: func(arg *files.ListFolderArg) (*files.ListFolderResult, error) { + switch arg.Path { + case "/2027": + return &files.ListFolderResult{Entries: []files.IsMetadata{listingFolder("Logoset")}}, nil + case selected: + return &files.ListFolderResult{Entries: []files.IsMetadata{listingFile("logo.png", 4)}}, nil + default: + t.Fatalf("unexpected listing %q", arg.Path) + return nil, nil + } + }} + arg := sharing.NewGetSharedLinkMetadataArg(listingURL) + results, err := listSharedLinkContents(filesDbx, arg, "2027", true) + if err != nil { + t.Fatal(err) + } + stubFilesClient(t, filesDbx) + stubSharedLinkClient(t, &mockSharedLinkClient{ + getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { + if arg.Path == selected { + return sharedLinkFolder("/Logoset", arg.Url), nil + } + return downloadableSharedLinkFile("logo.png", "", arg.Url, 4), nil + }, + getSharedLinkFileFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, io.ReadCloser, error) { + if arg.Path != selected+"/logo.png" { + t.Fatalf("download path=%q", arg.Path) + } + return downloadableSharedLinkFile("logo.png", "", arg.Url, 4), io.NopCloser(strings.NewReader("data")), nil + }, + }) + for _, result := range results { + target := filepath.Join(t.TempDir(), "out") + cmd := newShareLinkDownloadTestCommand(nil, nil) + if err := cmd.ParseFlags([]string{"--path", result.Result.(shareLinkLSEntry).Path, "-r"}); err != nil { + t.Fatal(err) + } + if err := shareLinkDownload(cmd, []string{listingURL, target}); err != nil { + t.Fatal(err) + } + if result.Kind == "folder" { + target = filepath.Join(target, "logo.png") + } + assertFileContent(t, target, "data") + } +} + +func TestShareLinkDownloadSelectedFolderRetriesAndCleansPartialFiles(t *testing.T) { + for _, recover := range []bool{false, true} { + t.Run(fmt.Sprintf("recover=%t", recover), func(t *testing.T) { + delays := stubRetrySleep(t) + root := t.TempDir() + dst := filepath.Join(root, "Selected") + if err := os.Mkdir(dst, 0755); err != nil { + t.Fatal(err) + } + file := filepath.Join(dst, "file.txt") + if err := os.WriteFile(file, []byte("original"), 0600); err != nil { + t.Fatal(err) + } + calls := 0 + stubSharedLinkClient(t, &mockSharedLinkClient{ + getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { + return sharedLinkFolder("/Selected", arg.Url), nil + }, + getSharedLinkFileFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, io.ReadCloser, error) { + calls++ + if arg.Path != "/nested/Selected/file.txt" || arg.LinkPassword != "secret" { + t.Fatalf("download arg=%#v", arg) + } + link := downloadableSharedLinkFile("file.txt", "", arg.Url, 7) + if recover && calls > 1 { + return link, io.NopCloser(strings.NewReader("complete")), nil + } + return link, &failingReadCloser{data: []byte("partial")}, nil + }, + }) + stubFilesClient(t, &mockFilesClient{listFolderFn: func(arg *files.ListFolderArg) (*files.ListFolderResult, error) { + return &files.ListFolderResult{Entries: []files.IsMetadata{listingFile("file.txt", 7)}}, nil + }}) + var out, stderr bytes.Buffer + cmd := newShareLinkDownloadTestCommand(&out, &stderr) + setShareLinkOutputJSON(t, cmd) + if err := cmd.ParseFlags([]string{"--path", "/nested/Selected", "-r", "--password", "secret"}); err != nil { + t.Fatal(err) + } + err := shareLinkDownload(cmd, []string{listingURL, root}) + if recover { + if err != nil || calls != 2 || len(*delays) != 1 { + t.Fatalf("error=%v calls=%d delays=%v", err, calls, *delays) + } + assertFileContent(t, file, "complete") + } else { + if !errors.Is(err, io.ErrUnexpectedEOF) || out.Len() != 0 || calls != maxRetries+1 { + t.Fatalf("error=%v calls=%d output=%s", err, calls, &out) + } + assertFileContent(t, file, "original") + } + entries, err := os.ReadDir(dst) + if err != nil || len(entries) != 1 || entries[0].Name() != "file.txt" { + t.Fatalf("leftover files=%v error=%v", entries, err) + } + }) + } +} diff --git a/cmd/share_link_download_test.go b/cmd/share_link_download_test.go index 8feca70..763b1c4 100644 --- a/cmd/share_link_download_test.go +++ b/cmd/share_link_download_test.go @@ -287,8 +287,10 @@ func TestShareLinkDownloadPathDownloadsNestedFile(t *testing.T) { var requested *sharing.GetSharedLinkMetadataArg stubSharedLinkClient(t, &mockSharedLinkClient{ getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { - t.Fatal("GetSharedLinkMetadata should not be called for --path file downloads") - return nil, nil + if arg.Path != "/sub/nested.txt" || arg.LinkPassword != "secret" { + t.Fatalf("metadata arg = %#v", arg) + } + return downloadableSharedLinkFile("nested.txt", "", arg.Url, uint64(len(content))), nil }, getSharedLinkFileFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, io.ReadCloser, error) { requested = arg @@ -328,6 +330,9 @@ func TestShareLinkDownloadPathToStdoutIsByteClean(t *testing.T) { content := "nested stdout content" var requested *sharing.GetSharedLinkMetadataArg stubSharedLinkClient(t, &mockSharedLinkClient{ + getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { + return downloadableSharedLinkFile("nested.txt", "", arg.Url, uint64(len(content))), nil + }, getSharedLinkFileFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, io.ReadCloser, error) { requested = arg return downloadableSharedLinkFile("nested.txt", "/docs/sub/nested.txt", "https://example.com/folder", uint64(len(content))), @@ -369,7 +374,9 @@ func TestShareLinkDownloadPathRejectsInvalidCombinations(t *testing.T) { }{ {name: "empty path", path: "", want: "`--path` requires a non-empty path"}, {name: "root path", path: "/", want: "cannot download shared-link root with `--path`", wantPath: "/"}, - {name: "recursive path", path: "/sub/nested.txt", recursive: true, want: "`--path` cannot be used with --recursive", wantPath: "/sub/nested.txt"}, + {name: "traversal", path: "/sub/../secret", want: "invalid shared link entry name", wantPath: "/sub/../secret"}, + {name: "backslash", path: `/sub\secret`, want: "invalid shared link entry name", wantPath: `/sub\secret`}, + {name: "NUL", path: "/sub/\x00secret", want: "invalid shared link entry name", wantPath: "/sub/\x00secret"}, } for _, tt := range tests { @@ -919,7 +926,11 @@ func TestShareLinkDownloadCommandIsRegistered(t *testing.T) { } func newShareLinkDownloadTestCommand(stdout, stderr *bytes.Buffer) *cobra.Command { - cmd := &cobra.Command{} + cmd := &cobra.Command{Use: "download"} + root := &cobra.Command{Use: "dbxcli"} + parent := &cobra.Command{Use: "share-link"} + root.AddCommand(parent) + parent.AddCommand(cmd) addSharedLinkPasswordFlags(cmd) cmd.Flags().String("path", "", "") cmd.Flags().BoolP("recursive", "r", false, "") diff --git a/cmd/share_link_ls_test.go b/cmd/share_link_ls_test.go index fbc7de0..98a98ae 100644 --- a/cmd/share_link_ls_test.go +++ b/cmd/share_link_ls_test.go @@ -585,12 +585,19 @@ func TestShareLinkLSPathsCanBeDownloaded(t *testing.T) { t.Fatalf("results=%v", got.Results) } returnedPath := got.Results[0].Result.Path - stubSharedLinkClient(t, &mockSharedLinkClient{getSharedLinkFileFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, io.ReadCloser, error) { - if arg.Path != "/documents/照片.txt" || arg.Url != listingURL { - t.Fatalf("download arg=%+v", arg) - } - return downloadableSharedLinkFile("照片.txt", "", arg.Url, 7), io.NopCloser(strings.NewReader("content")), nil - }}) + stubSharedLinkClient(t, &mockSharedLinkClient{ + getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { + if arg.Path != returnedPath { + t.Fatalf("metadata arg=%+v", arg) + } + return downloadableSharedLinkFile("照片.txt", "", arg.Url, 7), nil + }, + getSharedLinkFileFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, io.ReadCloser, error) { + if arg.Path != "/documents/照片.txt" || arg.Url != listingURL { + t.Fatalf("download arg=%+v", arg) + } + return downloadableSharedLinkFile("照片.txt", "", arg.Url, 7), io.NopCloser(strings.NewReader("content")), nil + }}) var downloaded bytes.Buffer download := newShareLinkDownloadTestCommand(&downloaded, nil) _ = download.Flags().Set("path", returnedPath) diff --git a/docs/commands/dbxcli_share-link_download.md b/docs/commands/dbxcli_share-link_download.md index 8a6da6d..e92347e 100644 --- a/docs/commands/dbxcli_share-link_download.md +++ b/docs/commands/dbxcli_share-link_download.md @@ -8,10 +8,13 @@ Download shared link content Download content from a Dropbox shared link. - If target is omitted, the local filename comes from shared-link metadata. - - Use --path to download a file inside a folder shared link. + - Use --path to select a file or folder inside a folder shared link. + Selected folders require --recursive; files download with or without it. - Use - as target to write file bytes to stdout. Stdout is byte-clean: all progress and errors go to stderr. - - Use --recursive (-r) to download folder shared links. + - Use --recursive (-r) to download folder shared links or selected subfolders. + Existing target directories receive a subdirectory named after the selected folder. + Otherwise, target is used directly as the destination directory. ``` @@ -24,6 +27,7 @@ dbxcli share-link download [target] [flags] dbxcli share-link download https://www.dropbox.com/s/example/file.txt dbxcli share-link download https://www.dropbox.com/s/example/file.txt ./local-file.txt dbxcli share-link download https://www.dropbox.com/s/example/folder --path /nested/file.txt + dbxcli share-link download https://www.dropbox.com/s/example/folder ./out --path /2027/Logoset --recursive dbxcli share-link download https://www.dropbox.com/s/example/file.txt - | tar tz ``` @@ -34,7 +38,7 @@ dbxcli share-link download [target] [flags] --password string Password for password-protected shared links --password-file string Read the shared link password from a file --password-prompt[=true|false] Prompt for the shared link password - --path string Download a file path inside a folder shared link + --path string Select a file or folder path inside a folder shared link -r, --recursive[=true|false] Recursively download a folder shared link ``` diff --git a/docs/sharing.md b/docs/sharing.md index 1bb292d..c7f1c6c 100644 --- a/docs/sharing.md +++ b/docs/sharing.md @@ -131,12 +131,35 @@ dbxcli share-link download [target] dbxcli share-link download --path nested/file.txt dbxcli share-link download ./local.txt --path nested/file.txt dbxcli share-link download [target] --recursive +dbxcli share-link download ./out --path /2027/Logoset --recursive ``` `share-link download` writes to the metadata filename when `target` is omitted. -Use `--path` to download a single file inside a folder shared link. Use `-` as -the target to write file bytes to stdout. Folder shared links require -`--recursive` and cannot be written to stdout. +Use `--path` to select a file or folder inside a folder shared link. Selected +files download with or without `--recursive`. Folders require `--recursive`. +Use `-` as the target to write file bytes to stdout; stdout cannot be combined +with `--recursive` or `--output=json`. Folders cannot be written to stdout. + +For a selected folder, only its descendants are downloaded, including empty +folders. If `./out` already exists as a directory, the example above writes +`./out/Logoset/logo.png` and `./out/Logoset/icons/icon.svg`. If `./out` does not +exist, it becomes the selected folder's destination directly, producing +`./out/logo.png` and `./out/icons/icon.svg`. If target is omitted, the selected +folder's metadata name is used. This also works for shared folders not mounted +in your Dropbox and with the existing password flags. + +Paths use `/` relative to the original shared-link root and can be copied from +`share-link ls`. Repeated slashes and `.` components are normalized; `..`, +backslashes, and NUL bytes are rejected before normalization. Use the command +without `--path` to download the root. Remote entry names are preserved, +including Unicode, and checked against local filesystem path rules before +writing files. + +JSON v1 output retains the URL, selected path, recursive flag, and password +presence in `input`. The `downloaded` result has kind `file` or `folder`, the +actual local `target`, and metadata for the selected item in `link`. A traversal +or download failure returns an error rather than successful partial results; +files already downloaded remain on disk. ## Password-protected links