Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions cmd/dropbox_reference.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// 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 "strings"

type dropboxReferenceKind uint8

const (
dropboxPathReference dropboxReferenceKind = iota
dropboxIDReference
dropboxRevisionReference
dropboxNamespaceReference
)

// dropboxReference preserves Dropbox API references as opaque strings. Only
// ordinary paths are normalized; Dropbox remains responsible for validating
// identifier, revision, and namespace reference values.
type dropboxReference struct {
value string
kind dropboxReferenceKind
}

func newDropboxReference(value string) dropboxReference {
switch {
case strings.HasPrefix(value, "id:"):
return dropboxReference{value: value, kind: dropboxIDReference}
case strings.HasPrefix(value, "rev:"):
return dropboxReference{value: value, kind: dropboxRevisionReference}
case strings.HasPrefix(value, "ns:"):
return dropboxReference{value: value, kind: dropboxNamespaceReference}
default:
if !strings.HasPrefix(value, "/") {
value = "/" + value
}
return dropboxReference{value: cleanDropboxPath(value), kind: dropboxPathReference}
}
}

func (r dropboxReference) String() string {
return r.value
}

func (r dropboxReference) isPath() bool {
return r.kind == dropboxPathReference
}
44 changes: 44 additions & 0 deletions cmd/dropbox_reference_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// 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 "testing"

func TestNewDropboxReference(t *testing.T) {
tests := []struct {
name string
input string
want string
kind dropboxReferenceKind
}{
{name: "absolute path", input: "/folder/file.txt", want: "/folder/file.txt", kind: dropboxPathReference},
{name: "relative path", input: "folder/file.txt", want: "/folder/file.txt", kind: dropboxPathReference},
{name: "file id", input: "id:opaque-value", want: "id:opaque-value", kind: dropboxIDReference},
{name: "revision", input: "rev:opaque-value", want: "rev:opaque-value", kind: dropboxRevisionReference},
{name: "namespace path", input: "ns:123/folder/file.txt", want: "ns:123/folder/file.txt", kind: dropboxNamespaceReference},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := newDropboxReference(tt.input)
if got.String() != tt.want {
t.Fatalf("String() = %q, want %q", got.String(), tt.want)
}
if got.kind != tt.kind {
t.Fatalf("kind = %d, want %d", got.kind, tt.kind)
}
})
}
}
10 changes: 1 addition & 9 deletions cmd/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,15 +80,7 @@ func compareLess(a, b files.IsMetadata, opts listOptions) bool {
}

func entryPath(e files.IsMetadata) string {
switch f := e.(type) {
case *files.FileMetadata:
return f.PathDisplay
case *files.FolderMetadata:
return f.PathDisplay
case *files.DeletedMetadata:
return f.PathDisplay
}
return ""
return metadataPathDisplay(e)
}

func entrySize(e files.IsMetadata) uint64 {
Expand Down
73 changes: 40 additions & 33 deletions cmd/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,13 @@ func get(cmd *cobra.Command, args []string) (err error) {
return invalidArgumentsErrorWithDetails("`get` requires `src` and/or `dst` arguments", argumentsErrorDetails("src", "dst"))
}

src, err := validatePath(args[0])
if err != nil {
return
}
srcRef := newDropboxReference(args[0])
src := srcRef.String()

dst := path.Base(src)
dst := ""
if srcRef.isPath() {
dst = path.Base(src)
}
if len(args) == 2 {
dst = args[1]
}
Expand All @@ -92,11 +93,15 @@ func get(cmd *cobra.Command, args []string) (err error) {

meta, err := dbx.GetMetadataContext(currentContext(), files.NewGetMetadataArg(src))
if err != nil {
if recursive {
return withJSONErrorDetails(fmt.Errorf("get metadata for %s: %v", src, err), operationErrorDetails("download"), pathErrorDetails(src))
metadataErr := withJSONErrorDetails(fmt.Errorf("get metadata for %s: %v", src, err), operationErrorDetails("download"), pathErrorDetails(src))
if recursive || dst == "" {
return metadataErr
}
// For non-recursive, fall through to download (will fail with proper error)
if f, statErr := os.Stat(dst); statErr == nil && f.IsDir() {
if !srcRef.isPath() {
return metadataErr
}
dst = filepath.Join(dst, path.Base(src))
}
result, err := downloadFileWithResult(dbx, src, dst, opts)
Expand All @@ -111,15 +116,26 @@ func get(cmd *cobra.Command, args []string) (err error) {
}, []getResult{result})
}

sourceName := path.Base(src)
if !srcRef.isPath() {
sourceName = metadataName(meta)
if sourceName == "" {
return withJSONErrorDetails(fmt.Errorf("get metadata for %s did not include a name", src), operationErrorDetails("download"), pathErrorDetails(src))
}
if dst == "" {
dst = sourceName
}
}

if _, ok := meta.(*files.FolderMetadata); ok {
if !recursive {
return invalidArgumentsErrorfWithDetails("%s is a folder (use --recursive to download folders)", mergeJSONErrorDetails(operationErrorDetails("download"), pathErrorDetails(src)), src)
}
if f, statErr := os.Stat(dst); statErr == nil && f.IsDir() {
dst = filepath.Join(dst, path.Base(src))
dst = filepath.Join(dst, sourceName)
}
if commandOutputFormat(cmd) == output.FormatText {
return withJSONErrorDetails(getRecursive(dbx, src, dst), operationErrorDetails("download"), pathErrorDetails(src), relocationErrorDetails(src, dst))
return withJSONErrorDetails(getRecursiveWithRootMetadata(dbx, src, dst, meta), operationErrorDetails("download"), pathErrorDetails(src), relocationErrorDetails(src, dst))
}
results, err := getRecursiveWithResults(dbx, src, dst, meta, opts)
if err != nil {
Expand All @@ -134,7 +150,7 @@ func get(cmd *cobra.Command, args []string) (err error) {
}

if f, statErr := os.Stat(dst); statErr == nil && f.IsDir() {
dst = filepath.Join(dst, path.Base(src))
dst = filepath.Join(dst, sourceName)
}

result, err := downloadFileWithResult(dbx, src, dst, opts)
Expand Down Expand Up @@ -214,32 +230,16 @@ func getStdout(cmd *cobra.Command, src string, recursive bool) error {
return withJSONErrorDetails(downloadToStdout(dbx, src, cmd.OutOrStdout()), operationErrorDetails("download"), pathErrorDetails(src))
}

func getWithClient(dbx filesClient, args []string) (err error) {
if len(args) == 0 || len(args) > 2 {
return invalidArgumentsErrorWithDetails("`get` requires `src` and/or `dst` arguments", argumentsErrorDetails("src", "dst"))
}

src, err := validatePath(args[0])
if err != nil {
return
}

dst := path.Base(src)
if len(args) == 2 {
dst = args[1]
}
if f, err := os.Stat(dst); err == nil && f.IsDir() {
dst = filepath.Join(dst, path.Base(src))
}

return withJSONErrorDetails(downloadFile(dbx, src, dst), operationErrorDetails("download"), pathErrorDetails(src), relocationErrorDetails(src, dst))
}

func getRecursive(dbx filesClient, src, dst string) error {
_, err := getRecursiveInternal(dbx, src, dst, nil, getOptions{}, false)
return err
}

func getRecursiveWithRootMetadata(dbx filesClient, src, dst string, rootMeta files.IsMetadata) error {
_, err := getRecursiveInternal(dbx, src, dst, rootMeta, getOptions{}, false)
return err
}

func getRecursiveWithResults(dbx filesClient, src, dst string, rootMeta files.IsMetadata, opts getOptions) ([]getResult, error) {
return getRecursiveInternal(dbx, src, dst, rootMeta, opts, true)
}
Expand All @@ -265,6 +265,10 @@ func getRecursiveInternal(dbx filesClient, src, dst string, rootMeta files.IsMet
}

var results []getResult
rootPath := src
if metadataPath := metadataPathDisplay(rootMeta); metadataPath != "" {
rootPath = metadataPath
}

if collectResults {
result, err := ensureLocalDirectoryResult(src, dst, rootMeta)
Expand All @@ -283,7 +287,7 @@ func getRecursiveInternal(dbx filesClient, src, dst string, rootMeta files.IsMet
for _, entry := range entries {
switch f := entry.(type) {
case *files.FolderMetadata:
relPath, err := relativeTo(src, f.PathDisplay)
relPath, err := relativeTo(rootPath, f.PathDisplay)
if err != nil {
downloadErrors = append(downloadErrors, err)
continue
Expand All @@ -305,7 +309,7 @@ func getRecursiveInternal(dbx filesClient, src, dst string, rootMeta files.IsMet
}
}
case *files.FileMetadata:
relPath, err := relativeTo(src, f.PathDisplay)
relPath, err := relativeTo(rootPath, f.PathDisplay)
if err != nil {
downloadErrors = append(downloadErrors, err)
continue
Expand Down Expand Up @@ -493,11 +497,14 @@ var getCmd = &cobra.Command{
Use: "get [flags] <source> [<target>]",
Short: "Download a file or folder",
Long: `Download a file or folder from Dropbox.
- Source may be a Dropbox path, file ID (id:), revision (rev:), or
namespace-relative path (ns:).
- Use --recursive (-r) to download entire directories.
- Use - as target to write file bytes to stdout.
Stdout is byte-clean: all progress and errors go to stderr.
`,
Example: ` dbxcli get /remote/file.txt ./local-file.txt
dbxcli get rev:a1c10ce0dd78 ./historical-file.txt
dbxcli get -r /remote/folder ./local-folder
dbxcli get /backups/src.tgz - | tar tz
dbxcli get /file.txt - > local-copy.txt`,
Expand Down
Loading