From 58b2e32020168d29fe6478705af7ddbe79b1385a Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Wed, 11 Nov 2026 23:53:41 -0800 Subject: [PATCH 1/4] Add share-link ls for folder shared links --- cmd/help_json_test.go | 1 + cmd/help_manifest.go | 11 + cmd/json_contract_test.go | 9 + cmd/share_link_download.go | 12 +- cmd/share_link_json_test.go | 1 + cmd/share_link_ls.go | 203 +++++++ cmd/share_link_ls_test.go | 530 ++++++++++++++++++ .../json_contract/success_outputs.json | 1 + .../json_contract/success_schemas.json | 26 + docs/commands/dbxcli_share-link.md | 1 + docs/commands/dbxcli_share-link_ls.md | 65 +++ docs/json-schema/v1/commands.json | 26 + docs/json-schema/v1/commands.schema.json | 114 ++++ docs/sharing.md | 26 +- internal/jsonschema/definition_schemas.go | 2 + 15 files changed, 1024 insertions(+), 4 deletions(-) create mode 100644 cmd/share_link_ls.go create mode 100644 cmd/share_link_ls_test.go create mode 100644 docs/commands/dbxcli_share-link_ls.md diff --git a/cmd/help_json_test.go b/cmd/help_json_test.go index 7e4d18c..271b90e 100644 --- a/cmd/help_json_test.go +++ b/cmd/help_json_test.go @@ -173,6 +173,7 @@ func TestJSONHelpRealRootManifestIncludesPublicCommands(t *testing.T) { "share-link download", "share-link info", "share-link list", + "share-link ls", "share-link revoke", "share-link update", "team", diff --git a/cmd/help_manifest.go b/cmd/help_manifest.go index 0cf6aad..31df27a 100644 --- a/cmd/help_manifest.go +++ b/cmd/help_manifest.go @@ -288,6 +288,16 @@ var commandManifestRegistry = map[string]jsonCommandManifestMetadata{ DropboxScopes: []string{"sharing.read"}, Known: true, }, + "share-link ls": { + Args: []jsonCommandArg{commandArg("url", true, false, "url", "Folder shared link URL")}, + Examples: []jsonCommandExample{{Description: "List shared folder contents", Command: "dbxcli share-link ls https://www.dropbox.com/s/example/folder --recursive"}}, + Flags: mergeCommandFlagMetadata(sharedLinkPasswordFlagMetadata, map[string]jsonCommandFlagMetadata{ + "path": {ValueKind: "dropbox_path"}, + "recursive": {ValueKind: "boolean"}, + }), + DropboxScopes: []string{"sharing.read", "files.metadata.read"}, + Known: true, + }, "share-link list": { Args: []jsonCommandArg{commandArg("path", false, false, "dropbox_path", "Dropbox path filter")}, Examples: []jsonCommandExample{{Description: "List shared links", Command: "dbxcli share-link list /Reports/report.pdf"}}, @@ -370,6 +380,7 @@ var commandContractRegistry = map[string]jsonCommandContractMetadata{ "share list link": {Statuses: []string{"listed"}, Kinds: []string{"file", "folder", "link"}, Warnings: []string{jsonWarningCodeDeprecatedCommand}}, "share-link create": {Statuses: []string{"created", "existing", jsonStatusPlanned}, Kinds: []string{"file", "folder", "link"}}, "share-link download": {Statuses: []string{"downloaded"}, Kinds: []string{"file", "folder", "link"}}, + "share-link ls": {Statuses: []string{"listed"}, Kinds: []string{"file", "folder"}}, "share-link info": {Statuses: []string{"found"}, Kinds: []string{"file", "folder", "link"}}, "share-link list": {Statuses: []string{"listed"}, Kinds: []string{"file", "folder", "link"}}, "share-link revoke": {Statuses: []string{"revoked", jsonStatusPlanned}, Kinds: []string{"file", "folder", "link", "shared_link"}}, diff --git a/cmd/json_contract_test.go b/cmd/json_contract_test.go index 3d76b20..2cecbd8 100644 --- a/cmd/json_contract_test.go +++ b/cmd/json_contract_test.go @@ -328,6 +328,7 @@ func expectedStructuredOutputCommands() []string { "share list link", "share-link create", "share-link download", + "share-link ls", "share-link info", "share-link list", "share-link revoke", @@ -451,6 +452,7 @@ func jsonSuccessFixtureCoverage() map[string]jsonSuccessFixture { file: "share_link_json_test.go", tests: []string{"TestShareLinkDownloadJSONOutputsTargetAndMetadata"}, }, + "share-link ls": {file: "share_link_ls_test.go", tests: []string{"TestShareLinkLSJSON"}}, "share-link info": { file: "share_link_json_test.go", tests: []string{"TestShareLinkInfoJSONOutputsPermissions"}, @@ -766,6 +768,10 @@ func jsonGoldenSuccessOutputExamples() map[string]jsonOperationOutput { "share-link download": newJSONOperationOutput(shareLinkDownloadInput{URL: sharedLink.URL, Target: "old.pdf", Path: "/old.pdf", Recursive: false, Password: true}, []jsonOperationResult{ newJSONOperationResult(shareLinkJSONStatusDownloaded, sharedLink.Type, nil, shareLinkDownloadResult{Target: "old.pdf", Link: sharedLink}), }, nil), + "share-link ls": newJSONOperationOutput(shareLinkLSInput{URL: sharedLink.URL, Path: "/documents", Recursive: true, Password: true}, []jsonOperationResult{ + newJSONOperationResult(shareLinkJSONStatusListed, "folder", nil, shareLinkLSEntry{Name: "nested", Path: "/documents/nested"}), + newJSONOperationResult(shareLinkJSONStatusListed, "file", nil, shareLinkLSEntry{Name: "report.pdf", Path: "/documents/report.pdf", Size: uint64Ptr(123)}), + }, nil), "share-link info": newJSONOperationOutput(shareLinkInfoInput{URL: sharedLink.URL, Path: "/old.pdf", Password: true}, []jsonOperationResult{ shareLinkJSONOperationResult(shareLinkJSONStatusFound, sharedLink), }, nil), @@ -1066,6 +1072,8 @@ func jsonContractDefinitions() map[string][]string { "share_link_create_result_input": jsonFieldNames[shareLinkCreateResultInput](), "share_link_download_input": jsonFieldNames[shareLinkDownloadInput](), "share_link_download_result": jsonFieldNames[shareLinkDownloadResult](), + "share_link_ls_input": jsonFieldNames[shareLinkLSInput](), + "share_link_ls_entry": jsonFieldNames[shareLinkLSEntry](), "share_link_info_input": jsonFieldNames[shareLinkInfoInput](), "share_link_list_input": jsonFieldNames[shareLinkListInput](), "share_link_metadata": jsonFieldNames[shareLinkJSONMetadata](), @@ -1113,6 +1121,7 @@ func jsonCommandSchemas() map[string]jsonGoldenCommandSchema { shareLinkKinds(), nil, ), + "share-link ls": operationSchema("share_link_ls_input", schemaRef("empty"), "share_link_ls_entry", []string{shareLinkJSONStatusListed}, []string{"file", "folder"}, nil), "share-link info": operationSchema("share_link_info_input", schemaRef("empty"), "share_link_metadata", []string{shareLinkJSONStatusFound}, shareLinkKinds(), nil), "share-link list": operationSchema("share_link_list_input", schemaRef("empty"), "share_link_metadata", []string{shareLinkJSONStatusListed}, shareLinkKinds(), nil), "share-link revoke": operationSchema("share_link_revoke_input", schemaRef("share_link_revoke_result_input"), "share_link_revoke_result", []string{shareLinkJSONStatusRevoked, jsonStatusPlanned}, append(shareLinkKinds(), shareLinkJSONKindSharedLink), nil), diff --git a/cmd/share_link_download.go b/cmd/share_link_download.go index 522d1b6..0b5b84c 100644 --- a/cmd/share_link_download.go +++ b/cmd/share_link_download.go @@ -291,20 +291,26 @@ func listSharedLinkFolderEntries(dbx filesClient, arg *sharing.GetSharedLinkMeta listArg.SharedLink = files.NewSharedLink(arg.Url) listArg.SharedLink.Password = arg.LinkPassword + if err := currentContext().Err(); err != nil { + return nil, err + } res, err := dbx.ListFolderContext(currentContext(), listArg) if err != nil { - return nil, fmt.Errorf("list shared link folder %q: %v", relFolder, err) + return nil, fmt.Errorf("list shared link folder %q: %w", relFolder, err) } entries := append([]files.IsMetadata{}, res.Entries...) for res.HasMore { + if err := currentContext().Err(); err != nil { + return entries, err + } if res.Cursor == "" { return entries, errors.New("list shared link folder has more results but no cursor") } cont := files.NewListFolderContinueArg(res.Cursor) res, err = dbx.ListFolderContinueContext(currentContext(), cont) if err != nil { - return entries, fmt.Errorf("list shared link folder continue: %v", err) + return entries, fmt.Errorf("list shared link folder continue: %w", err) } entries = append(entries, res.Entries...) } @@ -334,7 +340,7 @@ func downloadSharedLinkRelativeFile(dbx sharedLinkClient, baseArg *sharing.GetSh // Shared-link listings may omit path_display and path_lower when the folder is // not mounted in the caller's Dropbox. Names are relative to the folder listed. func sharedLinkEntryRelativePath(parentRel, name string) (string, error) { - if name == "." || !filepath.IsLocal(name) || strings.ContainsAny(name, `/\`) { + if name == "." || !filepath.IsLocal(name) || strings.ContainsAny(name, "/\\\x00") { return "", fmt.Errorf("invalid shared link entry name %q", name) } return path.Join(parentRel, name), nil diff --git a/cmd/share_link_json_test.go b/cmd/share_link_json_test.go index 0663177..e8f0fba 100644 --- a/cmd/share_link_json_test.go +++ b/cmd/share_link_json_test.go @@ -529,6 +529,7 @@ func TestShareLinkCommandsSupportStructuredOutput(t *testing.T) { shareLinkListCmd, shareListLinksCmd, shareLinkInfoCmd, + shareLinkLSCmd, shareLinkUpdateCmd, shareLinkRevokeCmd, shareLinkDownloadCmd, diff --git a/cmd/share_link_ls.go b/cmd/share_link_ls.go new file mode 100644 index 0000000..90e3ab8 --- /dev/null +++ b/cmd/share_link_ls.go @@ -0,0 +1,203 @@ +// 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 ( + "fmt" + "io" + "net/url" + "sort" + "strings" + "text/tabwriter" + + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/sharing" + "github.com/dustin/go-humanize" + "github.com/spf13/cobra" +) + +type shareLinkLSInput struct { + URL string `json:"url"` + Path string `json:"path,omitempty"` + Recursive bool `json:"recursive"` + Password bool `json:"password"` +} + +type shareLinkLSEntry struct { + Name string `json:"name"` + Path string `json:"path"` + Size *uint64 `json:"size,omitempty"` +} + +func shareLinkLS(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return invalidArgumentsErrorWithDetails("`share-link ls` requires a `url` argument", argumentErrorDetails("url")) + } + return withJSONErrorDetails(listSharedLink(cmd, args[0]), urlErrorDetails(args[0]), operationErrorDetails("share_link_ls")) +} + +func listSharedLink(cmd *cobra.Command, linkURL string) error { + parsed, err := url.Parse(linkURL) + if err != nil || parsed.Hostname() == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.User != nil { + return invalidArgumentsErrorWithDetails("`share-link ls` requires a valid HTTP or HTTPS URL", argumentErrorDetails("url")) + } + input := shareLinkLSInput{URL: linkURL} + relFolder := "" + if localFlagChanged(cmd, "path") { + value, err := localStringFlag(cmd, "path") + if err != nil { + return err + } + if value == "" { + return invalidArgumentsErrorWithDetails("`--path` requires a non-empty path", flagErrorDetails("path")) + } + // Validate each component before normalization, using the same name rules as downloads. + if value != "/" { + for _, name := range strings.Split(strings.TrimPrefix(value, "/"), "/") { + relFolder, err = sharedLinkEntryRelativePath(relFolder, name) + if err != nil { + return invalidArgumentsErrorWithDetails(err.Error(), mergeJSONErrorDetails(flagErrorDetails("path"), pathErrorDetails(value))) + } + } + } + input.Path = sharedLinkAPIPath(relFolder) + if input.Path == "" { + input.Path = "/" + } + } + input.Recursive, err = localBoolFlag(cmd, "recursive") + if err != nil { + return err + } + password, err := sharedLinkPasswordFromFlags(cmd) + if err != nil { + return err + } + input.Password = password.set + arg := sharing.NewGetSharedLinkMetadataArg(linkURL) + arg.Path = sharedLinkAPIPath(relFolder) + arg.LinkPassword = password.password + if err := currentContext().Err(); err != nil { + return err + } + link, err := newSharedLinkClient(config).GetSharedLinkMetadataContext(currentContext(), arg) + if err != nil { + return withJSONErrorDetails(err, pathErrorDetails(arg.Path)) + } + if folder, ok := link.(*sharing.FolderLinkMetadata); !ok || folder == nil { + return invalidArgumentsErrorWithDetails("shared link path is not a folder", pathErrorDetails(arg.Path)) + } + results, err := listSharedLinkContents(filesNewFunc(config), arg, relFolder, input.Recursive) + if err != nil { + return err + } + return commandOutput(cmd).Render(func(out io.Writer) error { + w := tabwriter.NewWriter(out, 4, 8, 1, ' ', 0) + if _, err := fmt.Fprintln(w, "TYPE\tSIZE\tPATH"); err != nil { + return err + } + for _, result := range results { + entry := result.Result.(shareLinkLSEntry) + size := "-" + if entry.Size != nil { + size = humanize.IBytes(*entry.Size) + } + if _, err := fmt.Fprintf(w, "%s\t%s\t%s\n", result.Kind, size, entry.Path); err != nil { + return err + } + } + return w.Flush() + }, newJSONCommandOperationOutput(cmd, input, results, nil)) +} + +func listSharedLinkContents(dbx filesClient, arg *sharing.GetSharedLinkMetadataArg, start string, recursive bool) ([]jsonOperationResult, error) { + results := []jsonOperationResult{} + queue := []string{start} + for len(queue) > 0 { + if err := currentContext().Err(); err != nil { + return nil, err + } + parent := queue[0] + queue[0] = "" + queue = queue[1:] + entries, err := listSharedLinkFolderEntries(dbx, arg, parent) + if err != nil { + return nil, withJSONErrorDetails(err, pathErrorDetails(sharedLinkAPIPath(parent))) + } + children := []string{} + for _, metadata := range entries { + if err := currentContext().Err(); err != nil { + return nil, err + } + var entry shareLinkLSEntry + kind := "" + switch item := metadata.(type) { + case *files.FileMetadata: + if item != nil { + size := item.Size + entry.Name, entry.Size, kind = item.Name, &size, "file" + } + case *files.FolderMetadata: + if item != nil { + entry.Name, kind = item.Name, "folder" + } + } + if kind == "" { + return nil, fmt.Errorf("unknown shared link entry type in %q", sharedLinkAPIPath(parent)) + } + rel, err := sharedLinkEntryRelativePath(parent, entry.Name) + if err != nil { + return nil, withJSONErrorDetails(err, pathErrorDetails(sharedLinkAPIPath(parent))) + } + entry.Path = sharedLinkAPIPath(rel) + if recursive && kind == "folder" { + children = append(children, rel) + } + results = append(results, newJSONOperationResult(shareLinkJSONStatusListed, kind, nil, entry)) + } + sort.Strings(children) + queue = append(queue, children...) + } + // Keep only compact output records across folders; SDK pages are released after each folder. + sort.SliceStable(results, func(i, j int) bool { + return results[i].Result.(shareLinkLSEntry).Path < results[j].Result.(shareLinkLSEntry).Path + }) + if err := currentContext().Err(); err != nil { + return nil, err + } + return results, nil +} + +var shareLinkLSCmd = &cobra.Command{ + Use: "ls ", + Short: "List the contents of a folder shared link", + Long: `List files and folders inside a Dropbox folder shared link, including folders +not mounted in your Dropbox. Paths are relative to the shared-link root and can +be passed to share-link info --path or share-link download --path. +Use --path to start at a subfolder and --recursive (-r) to include its descendants. +Use share-link list to list shared links belonging to your Dropbox.`, + Example: ` dbxcli share-link ls https://www.dropbox.com/s/example/folder + dbxcli share-link ls https://www.dropbox.com/s/example/folder --path /documents --recursive + dbxcli share-link ls https://www.dropbox.com/s/example/folder --output json`, + RunE: shareLinkLS, +} + +func init() { + addSharedLinkPasswordFlags(shareLinkLSCmd) + shareLinkLSCmd.Flags().String("path", "", "List a subfolder inside the shared link") + shareLinkLSCmd.Flags().BoolP("recursive", "r", false, "Recursively list folder contents") + shareLinkCmd.AddCommand(shareLinkLSCmd) + enableStructuredOutput(shareLinkLSCmd) +} diff --git a/cmd/share_link_ls_test.go b/cmd/share_link_ls_test.go new file mode 100644 index 0000000..50dbf3e --- /dev/null +++ b/cmd/share_link_ls_test.go @@ -0,0 +1,530 @@ +// 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" + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox" + "io" + "os" + "reflect" + "strings" + "testing" + + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/sharing" + "github.com/spf13/cobra" +) + +const listingURL = "https://www.dropbox.com/s/example/folder" + +func newShareLinkLSTestCommand(out *bytes.Buffer) *cobra.Command { + cmd := &cobra.Command{Use: "ls"} + root := &cobra.Command{Use: "dbxcli"} + parent := &cobra.Command{Use: "share-link"} + root.AddCommand(parent) + parent.AddCommand(cmd) + cmd.Flags().String("path", "", "") + cmd.Flags().Bool("recursive", false, "") + addSharedLinkPasswordFlags(cmd) + cmd.SetOut(out) + return cmd +} + +func stubListingFolder(t *testing.T) { + t.Helper() + stubSharedLinkClient(t, &mockSharedLinkClient{getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { + return sharedLinkFolder("/mounted/elsewhere", arg.Url), nil + }}) +} + +func listingFile(name string, size uint64) *files.FileMetadata { + return &files.FileMetadata{Metadata: files.Metadata{Name: name}, Size: size} +} +func listingFolder(name string) *files.FolderMetadata { + return &files.FolderMetadata{Metadata: files.Metadata{Name: name}} +} + +func TestShareLinkLSJSON(t *testing.T) { + for _, mounted := range []bool{false, true} { + for _, recursive := range []bool{false, true} { + for _, start := range []string{"", "/documents"} { + t.Run(fmt.Sprintf("mounted=%t/recursive=%t/path=%s", mounted, recursive, start), func(t *testing.T) { + stubSharedLinkClient(t, &mockSharedLinkClient{getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { + if arg.Url != listingURL || arg.Path != start || arg.LinkPassword != "secret" { + t.Fatalf("metadata arg = %+v", arg) + } + return sharedLinkFolder("/unrelated/mount", arg.Url), nil + }}) + var paths, cursors []string + stubFilesClient(t, &mockFilesClient{ + listFolderFn: func(arg *files.ListFolderArg) (*files.ListFolderResult, error) { + paths = append(paths, arg.Path) + if arg.SharedLink == nil || arg.SharedLink.Url != listingURL || arg.SharedLink.Password != "secret" || arg.Recursive { + t.Fatalf("list arg = %+v", arg) + } + switch arg.Path { + case start: + f := listingFile("résumé.txt", 0) + if mounted { + f.PathDisplay, f.PathLower = "/wrong/mount/file", "/wrong/mount/file" + } + return &files.ListFolderResult{Entries: []files.IsMetadata{f}, HasMore: true, Cursor: "root"}, nil + case start + "/nested": + return &files.ListFolderResult{Entries: []files.IsMetadata{listingFolder("deep")}, HasMore: true, Cursor: "nested"}, nil + case start + "/nested/deep": + return &files.ListFolderResult{}, nil + default: + t.Fatalf("unexpected path %q", arg.Path) + return nil, nil + } + }, + listFolderContinueFn: func(arg *files.ListFolderContinueArg) (*files.ListFolderResult, error) { + cursors = append(cursors, arg.Cursor) + if arg.Cursor == "root" { + return &files.ListFolderResult{Entries: []files.IsMetadata{listingFolder("nested")}}, nil + } + if arg.Cursor == "nested" { + return &files.ListFolderResult{Entries: []files.IsMetadata{listingFile("照片.jpg", 2048)}}, nil + } + t.Fatalf("unexpected cursor %q", arg.Cursor) + return nil, nil + }, + }) + var out bytes.Buffer + cmd := newShareLinkLSTestCommand(&out) + setShareLinkOutputJSON(t, cmd) + if start != "" { + _ = cmd.Flags().Set("path", start) + } + _ = cmd.Flags().Set("recursive", fmt.Sprint(recursive)) + _ = cmd.Flags().Set("password", "secret") + if err := shareLinkLS(cmd, []string{listingURL}); err != nil { + t.Fatal(err) + } + got := decodeShareLinkOperationOutput[shareLinkLSInput, shareLinkLSEntry](t, out.Bytes()) + if got.Input != (shareLinkLSInput{URL: listingURL, Path: start, Recursive: recursive, Password: true}) { + t.Fatalf("input = %+v", got.Input) + } + if strings.Contains(out.String(), "secret") { + t.Fatal("password leaked") + } + wantPaths := []string{start} + wantCursors := []string{"root"} + wantEntries := []string{start + "/nested", start + "/résumé.txt"} + if recursive { + wantPaths = append(wantPaths, start+"/nested", start+"/nested/deep") + wantCursors = append(wantCursors, "nested") + wantEntries = []string{start + "/nested", start + "/nested/deep", start + "/nested/照片.jpg", start + "/résumé.txt"} + } + if !reflect.DeepEqual(paths, wantPaths) || !reflect.DeepEqual(cursors, wantCursors) { + t.Fatalf("calls = %v, %v", paths, cursors) + } + var entries []string + for _, result := range got.Results { + entries = append(entries, result.Result.Path) + if result.Status != "listed" { + t.Fatalf("status = %s", result.Status) + } + if (result.Kind == "file") != (result.Result.Size != nil) { + t.Fatalf("size/kind = %+v", result) + } + rel, err := sharedLinkEntryRelativePath(strings.TrimPrefix(start, "/"), "résumé.txt") + if err != nil { + t.Fatal(err) + } + if result.Result.Name == "résumé.txt" && result.Result.Path != sharedLinkAPIPath(rel) { + t.Fatal("download path mismatch") + } + } + if !reflect.DeepEqual(entries, wantEntries) { + t.Fatalf("entries = %v, want %v", entries, wantEntries) + } + schema := compileJSONSchemaFile(t, "../docs/json-schema/v1/commands.schema.json") + if err := schema.Validate(decodeJSONValueForSchema(t, out.Bytes())); err != nil { + t.Fatal(err) + } + }) + } + } + } +} + +func TestShareLinkLSTextAndEmpty(t *testing.T) { + for _, empty := range []bool{false, true} { + t.Run(fmt.Sprint(empty), func(t *testing.T) { + stubListingFolder(t) + stubFilesClient(t, &mockFilesClient{listFolderFn: func(arg *files.ListFolderArg) (*files.ListFolderResult, error) { + if empty { + return &files.ListFolderResult{}, nil + } + return &files.ListFolderResult{Entries: []files.IsMetadata{listingFile("zero", 0), listingFolder("docs"), listingFile("photo", 2048)}}, nil + }}) + var out bytes.Buffer + cmd := newShareLinkLSTestCommand(&out) + if err := shareLinkLS(cmd, []string{listingURL}); err != nil { + t.Fatal(err) + } + got := strings.Fields(out.String()) + want := "TYPE SIZE PATH folder - /docs file 2.0 KiB /photo file 0 B /zero" + if empty { + want = "TYPE SIZE PATH" + } + if strings.Join(got, " ") != want { + t.Fatalf("output = %q", out.String()) + } + setShareLinkOutputJSON(t, cmd) + out.Reset() + if err := shareLinkLS(cmd, []string{listingURL}); err != nil { + t.Fatal(err) + } + if empty && !strings.Contains(out.String(), `"results":[]`) { + t.Fatalf("output = %s", out.String()) + } + }) + } +} + +func TestShareLinkLSInvalidArguments(t *testing.T) { + for _, args := range [][]string{nil, {listingURL, "extra"}, {""}, {"bad url"}, {"ftp://example.com/folder"}, {"https:///folder"}, {"https://user:secret@example.com"}} { + var out bytes.Buffer + err := shareLinkLS(newShareLinkLSTestCommand(&out), args) + if err == nil || jsonErrorCode(err) != "invalid_arguments" || out.Len() != 0 { + t.Fatalf("args=%v, err=%v, out=%s", args, err, &out) + } + } + for _, value := range []string{"", "../escape", "/a/../escape", "/a/./b", "/a//b", "/a/", "/a\\b", "/nul\x00"} { + var out bytes.Buffer + cmd := newShareLinkLSTestCommand(&out) + _ = cmd.Flags().Set("path", value) + err := shareLinkLS(cmd, []string{listingURL}) + if err == nil || jsonErrorCode(err) != "invalid_arguments" { + t.Fatalf("path=%q, err=%v", value, err) + } + } +} + +func TestShareLinkLSInvalidEntries(t *testing.T) { + for _, name := range []string{"", ".", "..", "../escape", "/absolute", "a/b", "a\\b", "nul\x00"} { + t.Run(fmt.Sprintf("%q", name), func(t *testing.T) { + stubListingFolder(t) + stubFilesClient(t, &mockFilesClient{listFolderFn: func(arg *files.ListFolderArg) (*files.ListFolderResult, error) { + return &files.ListFolderResult{Entries: []files.IsMetadata{listingFile("valid", 1), listingFolder(name)}}, nil + }}) + var out bytes.Buffer + err := shareLinkLS(newShareLinkLSTestCommand(&out), []string{listingURL}) + if err == nil || !strings.Contains(err.Error(), "invalid shared link entry name") || out.Len() != 0 { + t.Fatalf("err=%v output=%s", err, &out) + } + }) + } +} + +func TestShareLinkLSFailures(t *testing.T) { + for _, stage := range []string{"metadata", "file link", "root", "nested", "continue", "no cursor", "unknown entry", "canceled", "cancel pagination"} { + t.Run(stage, func(t *testing.T) { + want := errors.New("API failed") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + old := commandContext + commandContext = ctx + t.Cleanup(func() { commandContext = old }) + if stage == "canceled" { + cancel() + want = context.Canceled + } + stubSharedLinkClient(t, &mockSharedLinkClient{getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { + if stage == "metadata" { + return nil, want + } + if stage == "file link" { + return sharedLinkFile("/file", arg.Url), nil + } + return sharedLinkFolder("/folder", arg.Url), nil + }}) + stubFilesClient(t, &mockFilesClient{ + listFolderFn: func(arg *files.ListFolderArg) (*files.ListFolderResult, error) { + if stage == "root" || (stage == "nested" && arg.Path == "/nested") { + return nil, want + } + if stage == "unknown entry" { + return &files.ListFolderResult{Entries: []files.IsMetadata{nil}}, nil + } + if stage == "continue" { + return &files.ListFolderResult{HasMore: true, Cursor: "next"}, nil + } + if stage == "cancel pagination" { + cancel() + return &files.ListFolderResult{HasMore: true, Cursor: "next"}, nil + } + if stage == "no cursor" { + return &files.ListFolderResult{HasMore: true}, nil + } + return &files.ListFolderResult{Entries: []files.IsMetadata{listingFolder("nested")}}, nil + }, + listFolderContinueFn: func(arg *files.ListFolderContinueArg) (*files.ListFolderResult, error) { return nil, want }, + }) + var out bytes.Buffer + cmd := newShareLinkLSTestCommand(&out) + setShareLinkOutputJSON(t, cmd) + _ = cmd.Flags().Set("recursive", "true") + err := shareLinkLS(cmd, []string{listingURL}) + if err == nil || out.Len() != 0 { + t.Fatalf("error=%v output=%s", err, &out) + } + if stage == "metadata" || stage == "root" || stage == "nested" || stage == "continue" || stage == "canceled" { + if !errors.Is(err, want) { + t.Fatalf("error=%v, want %v", err, want) + } + } + if stage == "cancel pagination" && !errors.Is(err, context.Canceled) { + t.Fatalf("error=%v", err) + } + details := jsonErrorDetails(err) + if details["url"] != listingURL || details["operation"] != "share_link_ls" { + t.Fatalf("details=%v", details) + } + }) + } +} + +func TestShareLinkLSHelp(t *testing.T) { + command, _, err := RootCmd.Find([]string{"share-link", "ls"}) + if err != nil || command != shareLinkLSCmd { + t.Fatalf("command=%v err=%v", command, err) + } + for _, name := range []string{"path", "recursive", "password", "password-file", "password-prompt"} { + if command.Flags().Lookup(name) == nil { + t.Fatalf("missing --%s", name) + } + } + manifest := jsonCommandManifestFor(command) + if !reflect.DeepEqual(manifest.ResultStatuses, []string{"listed"}) || !reflect.DeepEqual(manifest.ResultKinds, []string{"file", "folder"}) { + t.Fatalf("manifest=%+v", manifest) + } + if !commandSupportsStructuredOutput(command) { + t.Fatal("missing structured output") + } + list, _, err := RootCmd.Find([]string{"share-link", "list"}) + if err != nil || list != shareLinkListCmd { + t.Fatal("share-link list changed") + } +} + +func TestShareLinkLSPasswordSources(t *testing.T) { + for _, source := range []string{"none", "password-file", "password-prompt", "conflict", "empty"} { + t.Run(source, func(t *testing.T) { + var out bytes.Buffer + cmd := newShareLinkLSTestCommand(&out) + setShareLinkOutputJSON(t, cmd) + want := "secret" + switch source { + case "none": + want = "" + case "password-file": + p := t.TempDir() + "/password" + if err := os.WriteFile(p, []byte("secret\n"), 0600); err != nil { + t.Fatal(err) + } + _ = cmd.Flags().Set(source, p) + case "password-prompt": + old := readSharedLinkPassword + readSharedLinkPassword = func(_ string, _ io.Reader, _ io.Writer) (string, error) { return "secret", nil } + t.Cleanup(func() { readSharedLinkPassword = old }) + _ = cmd.Flags().Set(source, "true") + case "conflict": + _ = cmd.Flags().Set("password", "secret") + _ = cmd.Flags().Set("password-prompt", "true") + case "empty": + _ = cmd.Flags().Set("password", "") + } + stubSharedLinkClient(t, &mockSharedLinkClient{getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { + if source == "conflict" || source == "empty" { + t.Fatal("API called with invalid flags") + } + if arg.LinkPassword != want { + t.Fatalf("metadata password = %q", arg.LinkPassword) + } + return sharedLinkFolder("/folder", arg.Url), nil + }}) + stubFilesClient(t, &mockFilesClient{listFolderFn: func(arg *files.ListFolderArg) (*files.ListFolderResult, error) { + if arg.SharedLink.Password != want { + t.Fatalf("list password = %q", arg.SharedLink.Password) + } + return &files.ListFolderResult{}, nil + }}) + err := shareLinkLS(cmd, []string{listingURL}) + if source == "conflict" || source == "empty" { + if err == nil || jsonErrorCode(err) != "invalid_arguments" { + t.Fatalf("error = %v", err) + } + return + } + if err != nil { + t.Fatal(err) + } + got := decodeShareLinkOperationOutput[shareLinkLSInput, shareLinkLSEntry](t, out.Bytes()) + if got.Input.Password != (want != "") || strings.Contains(out.String(), "secret") { + t.Fatalf("output = %s", &out) + } + }) + } +} + +func TestShareLinkLSAPIErrorDetails(t *testing.T) { + for _, stage := range []string{"metadata", "listing", "pagination"} { + for _, summary := range []string{"path/not_found/", "shared_link_not_found/", "shared_link_access_denied/", "shared_link/password_required/", "shared_link/password_mismatch/", "shared_link/other/"} { + t.Run(stage+"/"+summary, func(t *testing.T) { + var want error = files.ListFolderAPIError{APIError: dropbox.APIError{ErrorSummary: summary}} + if stage == "metadata" { + want = sharing.GetSharedLinkMetadataAPIError{APIError: dropbox.APIError{ErrorSummary: summary}} + } + if stage == "pagination" { + want = files.ListFolderContinueAPIError{APIError: dropbox.APIError{ErrorSummary: summary}} + } + stubSharedLinkClient(t, &mockSharedLinkClient{getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { + if stage == "metadata" { + return nil, want + } + return sharedLinkFolder("/folder", arg.Url), nil + }}) + stubFilesClient(t, &mockFilesClient{ + listFolderFn: func(arg *files.ListFolderArg) (*files.ListFolderResult, error) { + if stage == "listing" { + return nil, want + } + return &files.ListFolderResult{Entries: []files.IsMetadata{listingFile("partial", 1)}, HasMore: true, Cursor: "next"}, nil + }, + listFolderContinueFn: func(arg *files.ListFolderContinueArg) (*files.ListFolderResult, error) { return nil, want }, + }) + var out bytes.Buffer + cmd := newShareLinkLSTestCommand(&out) + _ = cmd.Flags().Set("path", "/missing") + err := shareLinkLS(cmd, []string{listingURL}) + if !errors.Is(err, want) || out.Len() != 0 { + t.Fatalf("error=%v output=%s", err, &out) + } + response := newJSONErrorResponse(cmd, err) + if response.Error.Code != jsonErrorCode(want) || response.Error.Details["api_summary"] != summary || response.Error.Details["path"] != "/missing" { + t.Fatalf("error = %+v", response.Error) + } + schema := compileJSONSchemaFile(t, "../docs/json-schema/v1/error.schema.json") + if err := schema.Validate(normalizeJSONValueForSchema(t, response)); err != nil { + t.Fatal(err) + } + }) + } + } +} + +func TestShareLinkLSRootAndRelativePath(t *testing.T) { + for _, value := range []string{"/", "documents/sub"} { + t.Run(value, func(t *testing.T) { + want := strings.TrimPrefix(value, "/") + stubSharedLinkClient(t, &mockSharedLinkClient{getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { + if arg.Path != sharedLinkAPIPath(want) { + t.Fatalf("metadata path=%s", arg.Path) + } + return sharedLinkFolder("/elsewhere", arg.Url), nil + }}) + stubFilesClient(t, &mockFilesClient{listFolderFn: func(arg *files.ListFolderArg) (*files.ListFolderResult, error) { + if arg.Path != sharedLinkAPIPath(want) { + t.Fatalf("listing path=%s", arg.Path) + } + return &files.ListFolderResult{}, nil + }}) + var out bytes.Buffer + cmd := newShareLinkLSTestCommand(&out) + _ = cmd.Flags().Set("path", value) + if err := shareLinkLS(cmd, []string{listingURL}); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestShareLinkLSPathsCanBeDownloaded(t *testing.T) { + stubListingFolder(t) + stubFilesClient(t, &mockFilesClient{listFolderFn: func(arg *files.ListFolderArg) (*files.ListFolderResult, error) { + return &files.ListFolderResult{Entries: []files.IsMetadata{listingFile("照片.txt", 7)}}, nil + }}) + var out bytes.Buffer + cmd := newShareLinkLSTestCommand(&out) + setShareLinkOutputJSON(t, cmd) + _ = cmd.Flags().Set("path", "/documents") + if err := shareLinkLS(cmd, []string{listingURL}); err != nil { + t.Fatal(err) + } + got := decodeShareLinkOperationOutput[shareLinkLSInput, shareLinkLSEntry](t, out.Bytes()) + if len(got.Results) != 1 { + 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 + }}) + var downloaded bytes.Buffer + download := newShareLinkDownloadTestCommand(&downloaded, nil) + _ = download.Flags().Set("path", returnedPath) + target := t.TempDir() + "/download.txt" + if err := shareLinkDownload(download, []string{listingURL, target}); err != nil { + t.Fatal(err) + } + assertFileContent(t, target, "content") +} + +func TestShareLinkLSDeterministicTraversal(t *testing.T) { + for _, reverse := range []bool{false, true} { + t.Run(fmt.Sprint(reverse), func(t *testing.T) { + stubListingFolder(t) + var calls []string + stubFilesClient(t, &mockFilesClient{listFolderFn: func(arg *files.ListFolderArg) (*files.ListFolderResult, error) { + calls = append(calls, arg.Path) + if arg.Path == "" { + entries := []files.IsMetadata{listingFolder("beta"), listingFolder("alpha")} + if reverse { + entries[0], entries[1] = entries[1], entries[0] + } + return &files.ListFolderResult{Entries: entries}, nil + } + return &files.ListFolderResult{Entries: []files.IsMetadata{listingFile("child", 1)}}, nil + }}) + var out bytes.Buffer + cmd := newShareLinkLSTestCommand(&out) + setShareLinkOutputJSON(t, cmd) + _ = cmd.Flags().Set("recursive", "true") + if err := shareLinkLS(cmd, []string{listingURL}); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(calls, []string{"", "/alpha", "/beta"}) { + t.Fatalf("calls=%v", calls) + } + got := decodeShareLinkOperationOutput[shareLinkLSInput, shareLinkLSEntry](t, out.Bytes()) + var paths []string + for _, result := range got.Results { + paths = append(paths, result.Result.Path) + } + if !reflect.DeepEqual(paths, []string{"/alpha", "/alpha/child", "/beta", "/beta/child"}) { + t.Fatalf("paths=%v", paths) + } + }) + } +} diff --git a/cmd/testdata/json_contract/success_outputs.json b/cmd/testdata/json_contract/success_outputs.json index 17432f8..2fd0f4c 100644 --- a/cmd/testdata/json_contract/success_outputs.json +++ b/cmd/testdata/json_contract/success_outputs.json @@ -460,6 +460,7 @@ "share-link create": {"ok":true,"schema_version":"1","command":"share-link create","input":{"path":"/Reports/old.pdf","access":"max","audience":"public","expires":"2026-07-01T00:00:00Z","allow_download":true,"password":true},"results":[{"status":"created","kind":"file","result":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}},"input":{}}],"warnings":[]}, "share-link download": {"ok":true,"schema_version":"1","command":"share-link download","input":{"url":"https://www.dropbox.com/s/example/old.pdf","target":"old.pdf","path":"/old.pdf","password":true},"results":[{"status":"downloaded","kind":"file","result":{"target":"old.pdf","link":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}}},"input":{}}],"warnings":[]}, "share-link info": {"ok":true,"schema_version":"1","command":"share-link info","input":{"url":"https://www.dropbox.com/s/example/old.pdf","path":"/old.pdf","password":true},"results":[{"status":"found","kind":"file","result":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}},"input":{}}],"warnings":[]}, + "share-link ls": {"command":"share-link ls","input":{"password":true,"path":"/documents","recursive":true,"url":"https://www.dropbox.com/s/example/old.pdf"},"ok":true,"results":[{"input":{},"kind":"folder","result":{"name":"nested","path":"/documents/nested"},"status":"listed"},{"input":{},"kind":"file","result":{"name":"report.pdf","path":"/documents/report.pdf","size":123},"status":"listed"}],"schema_version":"1","warnings":[]}, "share-link list": {"ok":true,"schema_version":"1","command":"share-link list","input":{"path":"/Reports/old.pdf","direct_only":true},"results":[{"status":"listed","kind":"file","result":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}},"input":{}}],"warnings":[]}, "share-link revoke": {"ok":true,"schema_version":"1","command":"share-link revoke","input":{"path":"/Reports/old.pdf"},"results":[{"status":"revoked","kind":"file","result":{"url":"https://www.dropbox.com/s/example/old.pdf","link":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}}},"input":{}}],"warnings":[]}, "share-link update": {"ok":true,"schema_version":"1","command":"share-link update","input":{"url":"https://www.dropbox.com/s/example/old.pdf","audience":"public","expires":"2026-07-01T00:00:00Z","allow_download":true,"password":true},"results":[{"status":"updated","kind":"file","result":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}},"input":{}}],"warnings":[]}, diff --git a/cmd/testdata/json_contract/success_schemas.json b/cmd/testdata/json_contract/success_schemas.json index 2237983..0468117 100644 --- a/cmd/testdata/json_contract/success_schemas.json +++ b/cmd/testdata/json_contract/success_schemas.json @@ -287,6 +287,17 @@ "link", "target" ], + "share_link_ls_entry": [ + "name", + "path", + "size" + ], + "share_link_ls_input": [ + "password", + "path", + "recursive", + "url" + ], "share_link_info_input": [ "password", "path", @@ -726,6 +737,21 @@ ], "warnings": [] }, + "share-link ls": { + "input": "share_link_ls_input", + "kinds": [ + "file", + "folder" + ], + "result": "share_link_ls_entry", + "result_input": "empty", + "result_wrapper": "operation_result", + "statuses": [ + "listed" + ], + "top_level": "operation_output", + "warnings": [] + }, "share-link list": { "top_level": "operation_output", "result_wrapper": "operation_result", diff --git a/docs/commands/dbxcli_share-link.md b/docs/commands/dbxcli_share-link.md index 7b2cd4e..daff4bb 100644 --- a/docs/commands/dbxcli_share-link.md +++ b/docs/commands/dbxcli_share-link.md @@ -40,6 +40,7 @@ Create, list, inspect, download, update, and revoke Dropbox shared links. * [dbxcli share-link download](dbxcli_share-link_download.md) - Download shared link content * [dbxcli share-link info](dbxcli_share-link_info.md) - Display shared link information * [dbxcli share-link list](dbxcli_share-link_list.md) - List shared links +* [dbxcli share-link ls](dbxcli_share-link_ls.md) - List the contents of a folder shared link * [dbxcli share-link revoke](dbxcli_share-link_revoke.md) - Revoke shared links * [dbxcli share-link update](dbxcli_share-link_update.md) - Update shared link settings diff --git a/docs/commands/dbxcli_share-link_ls.md b/docs/commands/dbxcli_share-link_ls.md new file mode 100644 index 0000000..c97e210 --- /dev/null +++ b/docs/commands/dbxcli_share-link_ls.md @@ -0,0 +1,65 @@ + + +## dbxcli share-link ls + +List the contents of a folder shared link + +### Synopsis + +List files and folders inside a Dropbox folder shared link, including folders +not mounted in your Dropbox. Paths are relative to the shared-link root and can +be passed to share-link info --path or share-link download --path. +Use --path to start at a subfolder and --recursive (-r) to include its descendants. +Use share-link list to list shared links belonging to your Dropbox. + +``` +dbxcli share-link ls [flags] +``` + +### Examples + +``` + dbxcli share-link ls https://www.dropbox.com/s/example/folder + dbxcli share-link ls https://www.dropbox.com/s/example/folder --path /documents --recursive + dbxcli share-link ls https://www.dropbox.com/s/example/folder --output json +``` + +### Options + +``` + -h, --help[=true|false] help for ls + --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 List a subfolder inside the shared link + -r, --recursive[=true|false] Recursively list folder contents +``` + +### Options inherited from parent commands + +``` + --as-member string Member ID to perform action as + --output string Output format: text, json (default "text") + --timeout duration Timeout for Dropbox network operations (0 disables; examples: 30s, 2m, 1h) + -v, --verbose[=true|false] Enable verbose logging +``` + +### Command metadata + +* Structured JSON output: yes +* JSON help manifest: yes +* Manifest version: `1` +* Auth modes: `personal`, `team-access` +* Dropbox scopes: `files.metadata.read`, `sharing.read` +* Arguments: `url` (required, url) +* Flag metadata: `--output` (values: `json`, `text`), `--password` (conflicts: `password-file`, `password-prompt`; sensitive), `--password-file` (conflicts: `password`, `password-prompt`), `--password-prompt` (conflicts: `password`, `password-file`; may prompt) +* Result statuses: `listed` +* Result kinds: `file`, `folder` +* JSON contract: `docs/json-schema/v1/commands.json#/commands/share-link ls` +* JSON success schema: `docs/json-schema/v1/commands.schema.json#/$defs/command_share_2dlink_20ls` + + +### SEE ALSO + +* [dbxcli share-link](dbxcli_share-link.md) - Shared link commands + diff --git a/docs/json-schema/v1/commands.json b/docs/json-schema/v1/commands.json index 2237983..0468117 100644 --- a/docs/json-schema/v1/commands.json +++ b/docs/json-schema/v1/commands.json @@ -287,6 +287,17 @@ "link", "target" ], + "share_link_ls_entry": [ + "name", + "path", + "size" + ], + "share_link_ls_input": [ + "password", + "path", + "recursive", + "url" + ], "share_link_info_input": [ "password", "path", @@ -726,6 +737,21 @@ ], "warnings": [] }, + "share-link ls": { + "input": "share_link_ls_input", + "kinds": [ + "file", + "folder" + ], + "result": "share_link_ls_entry", + "result_input": "empty", + "result_wrapper": "operation_result", + "statuses": [ + "listed" + ], + "top_level": "operation_output", + "warnings": [] + }, "share-link list": { "top_level": "operation_output", "result_wrapper": "operation_result", diff --git a/docs/json-schema/v1/commands.schema.json b/docs/json-schema/v1/commands.schema.json index 4b89271..d32c999 100644 --- a/docs/json-schema/v1/commands.schema.json +++ b/docs/json-schema/v1/commands.schema.json @@ -1205,6 +1205,41 @@ ], "type": "object" }, + "command_share_2dlink_20ls": { + "additionalProperties": false, + "properties": { + "command": { + "const": "share-link ls" + }, + "input": { + "$ref": "#/$defs/share_link_ls_input" + }, + "ok": { + "const": true + }, + "results": { + "items": { + "$ref": "#/$defs/result_share_2dlink_20ls" + }, + "type": "array" + }, + "schema_version": { + "const": "1" + }, + "warnings": { + "$ref": "#/$defs/warnings_share_2dlink_20ls" + } + }, + "required": [ + "ok", + "schema_version", + "command", + "input", + "results", + "warnings" + ], + "type": "object" + }, "command_share_2dlink_20revoke": { "additionalProperties": false, "properties": { @@ -2537,6 +2572,35 @@ ], "type": "object" }, + "result_share_2dlink_20ls": { + "additionalProperties": false, + "properties": { + "input": { + "$ref": "#/$defs/empty" + }, + "kind": { + "enum": [ + "file", + "folder" + ] + }, + "result": { + "$ref": "#/$defs/share_link_ls_entry" + }, + "status": { + "enum": [ + "listed" + ] + } + }, + "required": [ + "status", + "kind", + "input", + "result" + ], + "type": "object" + }, "result_share_2dlink_20revoke": { "additionalProperties": false, "properties": { @@ -3043,6 +3107,49 @@ ], "type": "object" }, + "share_link_ls_entry": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "size": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "name", + "path" + ], + "type": "object" + }, + "share_link_ls_input": { + "additionalProperties": false, + "properties": { + "password": { + "type": "boolean" + }, + "path": { + "type": "string" + }, + "recursive": { + "type": "boolean" + }, + "url": { + "type": "string" + } + }, + "required": [ + "password", + "recursive", + "url" + ], + "type": "object" + }, "share_link_metadata": { "additionalProperties": false, "properties": { @@ -3620,6 +3727,10 @@ "items": false, "type": "array" }, + "warnings_share_2dlink_20ls": { + "items": false, + "type": "array" + }, "warnings_share_2dlink_20revoke": { "items": false, "type": "array" @@ -3716,6 +3827,9 @@ { "$ref": "#/$defs/command_share_2dlink_20list" }, + { + "$ref": "#/$defs/command_share_2dlink_20ls" + }, { "$ref": "#/$defs/command_share_2dlink_20revoke" }, diff --git a/docs/sharing.md b/docs/sharing.md index 3799ae3..7ca6cec 100644 --- a/docs/sharing.md +++ b/docs/sharing.md @@ -64,6 +64,30 @@ dbxcli share-link list /file.txt `share-link list /file.txt` lists direct shared links for that path, not parent-folder links. +## List folder contents + +```sh +dbxcli share-link ls +dbxcli share-link ls --path /documents +dbxcli share-link ls --recursive +dbxcli share-link ls --path /documents --recursive --output=json +``` + +`share-link ls` lists the contents of a folder shared link, including folders +not mounted in your Dropbox. `share-link list` lists shared links themselves. +Without `--recursive`, only immediate children are listed. With `--path`, all +returned paths still start at the shared-link root, so they can be passed to +`share-link download --path` or `share-link info --path`. Use `/` for the root. +Path components must be non-empty names, without `.` or `..` components or +backslashes. + +Text output shows type, human-readable file size (`-` for folders), and path. +Entries are sorted by path. JSON results use status `listed`, kind `file` or +`folder`, and `result` fields `name`, `path`, and `size` (files only, in bytes). +Input metadata includes the URL, optional path, recursive flag, and password +presence; the password is never included. A failed listing returns an error +without emitting successful partial results. + ## Update shared links ```sh @@ -106,7 +130,7 @@ the target to write file bytes to stdout. Folder shared links require ## Password-protected links -`share-link create`, `share-link update`, `share-link info`, and +`share-link create`, `share-link update`, `share-link info`, `share-link ls`, and `share-link download` support `--password `, `--password-prompt`, and `--password-file ` for password-protected links. Use `--password-prompt` for interactive use so the password is not echoed. diff --git a/internal/jsonschema/definition_schemas.go b/internal/jsonschema/definition_schemas.go index a04cf6f..b4b0ee0 100644 --- a/internal/jsonschema/definition_schemas.go +++ b/internal/jsonschema/definition_schemas.go @@ -261,6 +261,8 @@ var commandDefinitionSchemas = map[string]definitionSchemaConfig{ "link": schemaRef("share_link_metadata"), }, }, + "share_link_ls_input": {Required: []string{"url", "recursive", "password"}}, + "share_link_ls_entry": {Required: []string{"name", "path"}}, "share_link_info_input": { Required: []string{"url"}, }, From 92502fd21e507e4b98db78e46b9b7d476d5ff6e3 Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Thu, 12 Nov 2026 08:15:04 -0800 Subject: [PATCH 2/4] Match share-link ls text output to ls conventions --- cmd/help_manifest.go | 1 + cmd/json_contract_test.go | 2 +- cmd/share_link_ls.go | 28 +++++ cmd/share_link_ls_test.go | 117 +++++++++++++----- .../json_contract/success_outputs.json | 2 +- .../json_contract/success_schemas.json | 1 + docs/commands/dbxcli_share-link_ls.md | 3 + docs/json-schema/v1/commands.json | 1 + docs/json-schema/v1/commands.schema.json | 3 + docs/sharing.md | 10 +- 10 files changed, 133 insertions(+), 35 deletions(-) diff --git a/cmd/help_manifest.go b/cmd/help_manifest.go index 31df27a..0dec748 100644 --- a/cmd/help_manifest.go +++ b/cmd/help_manifest.go @@ -292,6 +292,7 @@ var commandManifestRegistry = map[string]jsonCommandManifestMetadata{ Args: []jsonCommandArg{commandArg("url", true, false, "url", "Folder shared link URL")}, Examples: []jsonCommandExample{{Description: "List shared folder contents", Command: "dbxcli share-link ls https://www.dropbox.com/s/example/folder --recursive"}}, Flags: mergeCommandFlagMetadata(sharedLinkPasswordFlagMetadata, map[string]jsonCommandFlagMetadata{ + "long": {ValueKind: "boolean"}, "path": {ValueKind: "dropbox_path"}, "recursive": {ValueKind: "boolean"}, }), diff --git a/cmd/json_contract_test.go b/cmd/json_contract_test.go index 2cecbd8..603aa77 100644 --- a/cmd/json_contract_test.go +++ b/cmd/json_contract_test.go @@ -768,7 +768,7 @@ func jsonGoldenSuccessOutputExamples() map[string]jsonOperationOutput { "share-link download": newJSONOperationOutput(shareLinkDownloadInput{URL: sharedLink.URL, Target: "old.pdf", Path: "/old.pdf", Recursive: false, Password: true}, []jsonOperationResult{ newJSONOperationResult(shareLinkJSONStatusDownloaded, sharedLink.Type, nil, shareLinkDownloadResult{Target: "old.pdf", Link: sharedLink}), }, nil), - "share-link ls": newJSONOperationOutput(shareLinkLSInput{URL: sharedLink.URL, Path: "/documents", Recursive: true, Password: true}, []jsonOperationResult{ + "share-link ls": newJSONOperationOutput(shareLinkLSInput{URL: sharedLink.URL, Path: "/documents", Recursive: true, Password: true, Long: true}, []jsonOperationResult{ newJSONOperationResult(shareLinkJSONStatusListed, "folder", nil, shareLinkLSEntry{Name: "nested", Path: "/documents/nested"}), newJSONOperationResult(shareLinkJSONStatusListed, "file", nil, shareLinkLSEntry{Name: "report.pdf", Path: "/documents/report.pdf", Size: uint64Ptr(123)}), }, nil), diff --git a/cmd/share_link_ls.go b/cmd/share_link_ls.go index 90e3ab8..402fd1e 100644 --- a/cmd/share_link_ls.go +++ b/cmd/share_link_ls.go @@ -33,6 +33,7 @@ type shareLinkLSInput struct { Path string `json:"path,omitempty"` Recursive bool `json:"recursive"` Password bool `json:"password"` + Long bool `json:"long,omitempty"` } type shareLinkLSEntry struct { @@ -81,6 +82,10 @@ func listSharedLink(cmd *cobra.Command, linkURL string) error { if err != nil { return err } + input.Long, err = localBoolFlag(cmd, "long") + if err != nil { + return err + } password, err := sharedLinkPasswordFromFlags(cmd) if err != nil { return err @@ -105,6 +110,26 @@ func listSharedLink(cmd *cobra.Command, linkURL string) error { } return commandOutput(cmd).Render(func(out io.Writer) error { w := tabwriter.NewWriter(out, 4, 8, 1, ' ', 0) + if !input.Long { + for i, result := range results { + separator := "" + if i > 0 { + separator = "\t" + if i%4 == 0 { + separator = "\n" + } + } + if _, err := fmt.Fprintf(w, "%s%s", separator, result.Result.(shareLinkLSEntry).Path); err != nil { + return err + } + } + if len(results) > 0 { + if _, err := fmt.Fprintln(w); err != nil { + return err + } + } + return w.Flush() + } if _, err := fmt.Fprintln(w, "TYPE\tSIZE\tPATH"); err != nil { return err } @@ -187,8 +212,10 @@ var shareLinkLSCmd = &cobra.Command{ not mounted in your Dropbox. Paths are relative to the shared-link root and can be passed to share-link info --path or share-link download --path. Use --path to start at a subfolder and --recursive (-r) to include its descendants. +By default, only paths are shown. Use --long (-l) for types and file sizes. Use share-link list to list shared links belonging to your Dropbox.`, Example: ` dbxcli share-link ls https://www.dropbox.com/s/example/folder + dbxcli share-link ls https://www.dropbox.com/s/example/folder -l dbxcli share-link ls https://www.dropbox.com/s/example/folder --path /documents --recursive dbxcli share-link ls https://www.dropbox.com/s/example/folder --output json`, RunE: shareLinkLS, @@ -198,6 +225,7 @@ func init() { addSharedLinkPasswordFlags(shareLinkLSCmd) shareLinkLSCmd.Flags().String("path", "", "List a subfolder inside the shared link") shareLinkLSCmd.Flags().BoolP("recursive", "r", false, "Recursively list folder contents") + shareLinkLSCmd.Flags().BoolP("long", "l", false, "Long listing with types, file sizes, and paths") shareLinkCmd.AddCommand(shareLinkLSCmd) enableStructuredOutput(shareLinkLSCmd) } diff --git a/cmd/share_link_ls_test.go b/cmd/share_link_ls_test.go index 50dbf3e..0187591 100644 --- a/cmd/share_link_ls_test.go +++ b/cmd/share_link_ls_test.go @@ -19,13 +19,13 @@ import ( "context" "errors" "fmt" - "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox" "io" "os" "reflect" "strings" "testing" + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/sharing" "github.com/spf13/cobra" @@ -41,6 +41,7 @@ func newShareLinkLSTestCommand(out *bytes.Buffer) *cobra.Command { parent.AddCommand(cmd) cmd.Flags().String("path", "", "") cmd.Flags().Bool("recursive", false, "") + cmd.Flags().BoolP("long", "l", false, "") addSharedLinkPasswordFlags(cmd) cmd.SetOut(out) return cmd @@ -167,36 +168,89 @@ func TestShareLinkLSJSON(t *testing.T) { func TestShareLinkLSTextAndEmpty(t *testing.T) { for _, empty := range []bool{false, true} { - t.Run(fmt.Sprint(empty), func(t *testing.T) { - stubListingFolder(t) - stubFilesClient(t, &mockFilesClient{listFolderFn: func(arg *files.ListFolderArg) (*files.ListFolderResult, error) { + for _, long := range []bool{false, true} { + t.Run(fmt.Sprintf("empty=%t/long=%t", empty, long), func(t *testing.T) { + stubListingFolder(t) + stubFilesClient(t, &mockFilesClient{listFolderFn: func(arg *files.ListFolderArg) (*files.ListFolderResult, error) { + if empty { + return &files.ListFolderResult{}, nil + } + return &files.ListFolderResult{Entries: []files.IsMetadata{listingFile("zero", 0), listingFolder("docs"), listingFile("photo", 2048)}}, nil + }}) + var out bytes.Buffer + cmd := newShareLinkLSTestCommand(&out) + if long { + if err := cmd.ParseFlags([]string{"-l"}); err != nil { + t.Fatal(err) + } + } + if err := shareLinkLS(cmd, []string{listingURL}); err != nil { + t.Fatal(err) + } + want := "/docs /photo /zero" if empty { - return &files.ListFolderResult{}, nil + want = "" } - return &files.ListFolderResult{Entries: []files.IsMetadata{listingFile("zero", 0), listingFolder("docs"), listingFile("photo", 2048)}}, nil - }}) - var out bytes.Buffer - cmd := newShareLinkLSTestCommand(&out) - if err := shareLinkLS(cmd, []string{listingURL}); err != nil { - t.Fatal(err) - } - got := strings.Fields(out.String()) - want := "TYPE SIZE PATH folder - /docs file 2.0 KiB /photo file 0 B /zero" - if empty { - want = "TYPE SIZE PATH" - } - if strings.Join(got, " ") != want { - t.Fatalf("output = %q", out.String()) - } - setShareLinkOutputJSON(t, cmd) - out.Reset() - if err := shareLinkLS(cmd, []string{listingURL}); err != nil { - t.Fatal(err) - } - if empty && !strings.Contains(out.String(), `"results":[]`) { - t.Fatalf("output = %s", out.String()) - } - }) + if long { + want = "TYPE SIZE PATH folder - /docs file 2.0 KiB /photo file 0 B /zero" + if empty { + want = "TYPE SIZE PATH" + } + } + if got := strings.Join(strings.Fields(out.String()), " "); got != want { + t.Fatalf("output = %q, want %q", out.String(), want) + } + setShareLinkOutputJSON(t, cmd) + out.Reset() + if err := shareLinkLS(cmd, []string{listingURL}); err != nil { + t.Fatal(err) + } + got := decodeShareLinkOperationOutput[shareLinkLSInput, shareLinkLSEntry](t, out.Bytes()) + if got.Input.Long != long { + t.Fatalf("input.long = %t, want %t", got.Input.Long, long) + } + if empty && len(got.Results) != 0 { + t.Fatalf("results = %+v", got.Results) + } + if !empty { + if len(got.Results) != 3 || got.Results[1].Kind != "file" || got.Results[1].Result.Size == nil || *got.Results[1].Result.Size != 2048 { + t.Fatalf("JSON must retain file metadata in both text formats: %+v", got.Results) + } + } + schema := compileJSONSchemaFile(t, "../docs/json-schema/v1/commands.schema.json") + if err := schema.Validate(decodeJSONValueForSchema(t, out.Bytes())); err != nil { + t.Fatal(err) + } + }) + } + } +} + +func TestShareLinkLSShortTextLayout(t *testing.T) { + stubListingFolder(t) + stubFilesClient(t, &mockFilesClient{listFolderFn: func(arg *files.ListFolderArg) (*files.ListFolderResult, error) { + return &files.ListFolderResult{Entries: []files.IsMetadata{ + listingFile("five", 1), listingFolder("four"), listingFile("one", 2), listingFile("three", 3), listingFile("two words", 4), + }}, nil + }}) + var out bytes.Buffer + cmd := newShareLinkLSTestCommand(&out) + if err := cmd.Flags().Set("path", "/nested"); err != nil { + t.Fatal(err) + } + if err := shareLinkLS(cmd, []string{listingURL}); err != nil { + t.Fatal(err) + } + want := "/nested/five /nested/four /nested/one /nested/three\n/nested/two words\n" + var normalized []string + for _, line := range strings.Split(strings.TrimSuffix(out.String(), "\n"), "\n") { + normalized = append(normalized, strings.Join(strings.Fields(line), " ")) + if strings.TrimRight(line, " \t") != line { + t.Fatalf("trailing padding: %q", line) + } + } + if got := strings.Join(normalized, "\n") + "\n"; got != want { + t.Fatalf("output = %q, want %q", got, want) } } @@ -308,11 +362,14 @@ func TestShareLinkLSHelp(t *testing.T) { if err != nil || command != shareLinkLSCmd { t.Fatalf("command=%v err=%v", command, err) } - for _, name := range []string{"path", "recursive", "password", "password-file", "password-prompt"} { + for _, name := range []string{"path", "recursive", "long", "password", "password-file", "password-prompt"} { if command.Flags().Lookup(name) == nil { t.Fatalf("missing --%s", name) } } + if command.Flags().Lookup("long").Shorthand != "l" { + t.Fatal("--long should have -l shorthand") + } manifest := jsonCommandManifestFor(command) if !reflect.DeepEqual(manifest.ResultStatuses, []string{"listed"}) || !reflect.DeepEqual(manifest.ResultKinds, []string{"file", "folder"}) { t.Fatalf("manifest=%+v", manifest) diff --git a/cmd/testdata/json_contract/success_outputs.json b/cmd/testdata/json_contract/success_outputs.json index 2fd0f4c..0de0d3b 100644 --- a/cmd/testdata/json_contract/success_outputs.json +++ b/cmd/testdata/json_contract/success_outputs.json @@ -460,7 +460,7 @@ "share-link create": {"ok":true,"schema_version":"1","command":"share-link create","input":{"path":"/Reports/old.pdf","access":"max","audience":"public","expires":"2026-07-01T00:00:00Z","allow_download":true,"password":true},"results":[{"status":"created","kind":"file","result":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}},"input":{}}],"warnings":[]}, "share-link download": {"ok":true,"schema_version":"1","command":"share-link download","input":{"url":"https://www.dropbox.com/s/example/old.pdf","target":"old.pdf","path":"/old.pdf","password":true},"results":[{"status":"downloaded","kind":"file","result":{"target":"old.pdf","link":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}}},"input":{}}],"warnings":[]}, "share-link info": {"ok":true,"schema_version":"1","command":"share-link info","input":{"url":"https://www.dropbox.com/s/example/old.pdf","path":"/old.pdf","password":true},"results":[{"status":"found","kind":"file","result":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}},"input":{}}],"warnings":[]}, - "share-link ls": {"command":"share-link ls","input":{"password":true,"path":"/documents","recursive":true,"url":"https://www.dropbox.com/s/example/old.pdf"},"ok":true,"results":[{"input":{},"kind":"folder","result":{"name":"nested","path":"/documents/nested"},"status":"listed"},{"input":{},"kind":"file","result":{"name":"report.pdf","path":"/documents/report.pdf","size":123},"status":"listed"}],"schema_version":"1","warnings":[]}, + "share-link ls": {"command":"share-link ls","input":{"long":true,"password":true,"path":"/documents","recursive":true,"url":"https://www.dropbox.com/s/example/old.pdf"},"ok":true,"results":[{"input":{},"kind":"folder","result":{"name":"nested","path":"/documents/nested"},"status":"listed"},{"input":{},"kind":"file","result":{"name":"report.pdf","path":"/documents/report.pdf","size":123},"status":"listed"}],"schema_version":"1","warnings":[]}, "share-link list": {"ok":true,"schema_version":"1","command":"share-link list","input":{"path":"/Reports/old.pdf","direct_only":true},"results":[{"status":"listed","kind":"file","result":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}},"input":{}}],"warnings":[]}, "share-link revoke": {"ok":true,"schema_version":"1","command":"share-link revoke","input":{"path":"/Reports/old.pdf"},"results":[{"status":"revoked","kind":"file","result":{"url":"https://www.dropbox.com/s/example/old.pdf","link":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}}},"input":{}}],"warnings":[]}, "share-link update": {"ok":true,"schema_version":"1","command":"share-link update","input":{"url":"https://www.dropbox.com/s/example/old.pdf","audience":"public","expires":"2026-07-01T00:00:00Z","allow_download":true,"password":true},"results":[{"status":"updated","kind":"file","result":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}},"input":{}}],"warnings":[]}, diff --git a/cmd/testdata/json_contract/success_schemas.json b/cmd/testdata/json_contract/success_schemas.json index 0468117..fbb30c2 100644 --- a/cmd/testdata/json_contract/success_schemas.json +++ b/cmd/testdata/json_contract/success_schemas.json @@ -293,6 +293,7 @@ "size" ], "share_link_ls_input": [ + "long", "password", "path", "recursive", diff --git a/docs/commands/dbxcli_share-link_ls.md b/docs/commands/dbxcli_share-link_ls.md index c97e210..f922a96 100644 --- a/docs/commands/dbxcli_share-link_ls.md +++ b/docs/commands/dbxcli_share-link_ls.md @@ -10,6 +10,7 @@ List files and folders inside a Dropbox folder shared link, including folders not mounted in your Dropbox. Paths are relative to the shared-link root and can be passed to share-link info --path or share-link download --path. Use --path to start at a subfolder and --recursive (-r) to include its descendants. +By default, only paths are shown. Use --long (-l) for types and file sizes. Use share-link list to list shared links belonging to your Dropbox. ``` @@ -20,6 +21,7 @@ dbxcli share-link ls [flags] ``` dbxcli share-link ls https://www.dropbox.com/s/example/folder + dbxcli share-link ls https://www.dropbox.com/s/example/folder -l dbxcli share-link ls https://www.dropbox.com/s/example/folder --path /documents --recursive dbxcli share-link ls https://www.dropbox.com/s/example/folder --output json ``` @@ -28,6 +30,7 @@ dbxcli share-link ls [flags] ``` -h, --help[=true|false] help for ls + -l, --long[=true|false] Long listing with types, file sizes, and paths --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 diff --git a/docs/json-schema/v1/commands.json b/docs/json-schema/v1/commands.json index 0468117..fbb30c2 100644 --- a/docs/json-schema/v1/commands.json +++ b/docs/json-schema/v1/commands.json @@ -293,6 +293,7 @@ "size" ], "share_link_ls_input": [ + "long", "password", "path", "recursive", diff --git a/docs/json-schema/v1/commands.schema.json b/docs/json-schema/v1/commands.schema.json index d32c999..fb4d362 100644 --- a/docs/json-schema/v1/commands.schema.json +++ b/docs/json-schema/v1/commands.schema.json @@ -3130,6 +3130,9 @@ "share_link_ls_input": { "additionalProperties": false, "properties": { + "long": { + "type": "boolean" + }, "password": { "type": "boolean" }, diff --git a/docs/sharing.md b/docs/sharing.md index 7ca6cec..5a09f89 100644 --- a/docs/sharing.md +++ b/docs/sharing.md @@ -68,6 +68,7 @@ parent-folder links. ```sh dbxcli share-link ls +dbxcli share-link ls -l dbxcli share-link ls --path /documents dbxcli share-link ls --recursive dbxcli share-link ls --path /documents --recursive --output=json @@ -81,12 +82,15 @@ returned paths still start at the shared-link root, so they can be passed to Path components must be non-empty names, without `.` or `..` components or backslashes. -Text output shows type, human-readable file size (`-` for folders), and path. +By default, text output shows only paths in columns, like `dbxcli ls`. +Use `--long` (`-l`) to show type, human-readable file size (`-` for folders), +and path. Empty folders produce no text in the default format. Entries are sorted by path. JSON results use status `listed`, kind `file` or `folder`, and `result` fields `name`, `path`, and `size` (files only, in bytes). Input metadata includes the URL, optional path, recursive flag, and password -presence; the password is never included. A failed listing returns an error -without emitting successful partial results. +presence; `long: true` is included when `--long` is requested. The password is +never included. JSON entries retain the same metadata in both text formats. +A failed listing returns an error without emitting successful partial results. ## Update shared links From cb3dbb3152ce57072ff7aece6925b0ae74cd8868 Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Thu, 12 Nov 2026 08:34:24 -0800 Subject: [PATCH 3/4] Normalize shared-link paths independently of host OS --- cmd/share_link_download.go | 6 ++- cmd/share_link_download_test.go | 23 +++++++++++ cmd/share_link_ls.go | 20 +++++---- cmd/share_link_ls_test.go | 73 ++++++++++++++++++++++++++++----- docs/sharing.md | 10 ++++- 5 files changed, 110 insertions(+), 22 deletions(-) diff --git a/cmd/share_link_download.go b/cmd/share_link_download.go index 0b5b84c..3c73e39 100644 --- a/cmd/share_link_download.go +++ b/cmd/share_link_download.go @@ -339,8 +339,10 @@ func downloadSharedLinkRelativeFile(dbx sharedLinkClient, baseArg *sharing.GetSh // Shared-link listings may omit path_display and path_lower when the folder is // not mounted in the caller's Dropbox. Names are relative to the folder listed. +// Validate Dropbox names independently of the local OS; Windows device names +// and colons are not filesystem paths until sharedLinkLocalPath is called. func sharedLinkEntryRelativePath(parentRel, name string) (string, error) { - if name == "." || !filepath.IsLocal(name) || strings.ContainsAny(name, "/\\\x00") { + if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\\\x00") { return "", fmt.Errorf("invalid shared link entry name %q", name) } return path.Join(parentRel, name), nil @@ -355,7 +357,7 @@ func sharedLinkAPIPath(rel string) string { func sharedLinkLocalPath(root, rel string) (string, error) { localRel := filepath.Clean(filepath.FromSlash(rel)) - if localRel == "." || localRel == ".." || strings.HasPrefix(localRel, ".."+string(filepath.Separator)) || filepath.IsAbs(localRel) { + if localRel == "." || !filepath.IsLocal(localRel) { return "", fmt.Errorf("invalid shared link relative path %q", rel) } return filepath.Join(root, localRel), nil diff --git a/cmd/share_link_download_test.go b/cmd/share_link_download_test.go index cfd3fb9..8b27e39 100644 --- a/cmd/share_link_download_test.go +++ b/cmd/share_link_download_test.go @@ -21,6 +21,7 @@ import ( "io" "os" "path/filepath" + "runtime" "strings" "testing" "time" @@ -680,6 +681,9 @@ func TestSharedLinkEntryRelativePath(t *testing.T) { {"docs", "docs", "docs/docs"}, {"", "İ.txt", "İ.txt"}, {"sub", "a..b", "sub/a..b"}, + {"資料", "e\u0301.txt", "資料/e\u0301.txt"}, + {"sub", "CON", "sub/CON"}, + {"sub", "C:notes", "sub/C:notes"}, } for _, tt := range tests { t.Run(tt.want, func(t *testing.T) { @@ -946,3 +950,22 @@ func assertFileContent(t *testing.T, path string, want string) { t.Fatalf("%s = %q, want %q", path, string(got), want) } } + +func TestSharedLinkLocalPathKeepsLocalFilesystemChecks(t *testing.T) { + root := t.TempDir() + for _, rel := range []string{"", ".", "..", "../escape", "/absolute", "nested/../../escape"} { + if got, err := sharedLinkLocalPath(root, rel); err == nil { + t.Fatalf("local path %q = %q, want rejection", rel, got) + } + } + for _, rel := range []string{"CON", "nested/NUL.txt", "C:notes", "nested/report:2026.txt"} { + got, err := sharedLinkLocalPath(root, rel) + if runtime.GOOS == "windows" { + if err == nil { + t.Fatalf("unsafe Windows local path %q = %q", rel, got) + } + } else if err != nil || got != filepath.Join(root, filepath.FromSlash(rel)) { + t.Fatalf("valid local path %q = %q, %v", rel, got, err) + } + } +} diff --git a/cmd/share_link_ls.go b/cmd/share_link_ls.go index 402fd1e..10bcfd0 100644 --- a/cmd/share_link_ls.go +++ b/cmd/share_link_ls.go @@ -64,16 +64,20 @@ func listSharedLink(cmd *cobra.Command, linkURL string) error { if value == "" { return invalidArgumentsErrorWithDetails("`--path` requires a non-empty path", flagErrorDetails("path")) } - // Validate each component before normalization, using the same name rules as downloads. - if value != "/" { - for _, name := range strings.Split(strings.TrimPrefix(value, "/"), "/") { - relFolder, err = sharedLinkEntryRelativePath(relFolder, name) - if err != nil { - return invalidArgumentsErrorWithDetails(err.Error(), mergeJSONErrorDetails(flagErrorDetails("path"), pathErrorDetails(value))) - } + // Check names before cleaning so traversal cannot disappear during normalization. + for _, name := range strings.Split(value, "/") { + if name == "" || name == "." { + continue + } + if _, err := sharedLinkEntryRelativePath("", name); err != nil { + return invalidArgumentsErrorWithDetails(err.Error(), mergeJSONErrorDetails(flagErrorDetails("path"), pathErrorDetails(value))) } } - input.Path = sharedLinkAPIPath(relFolder) + input.Path, err = validatePath(value) + if err != nil { + return err + } + relFolder = strings.TrimPrefix(input.Path, "/") if input.Path == "" { input.Path = "/" } diff --git a/cmd/share_link_ls_test.go b/cmd/share_link_ls_test.go index 0187591..fbc7de0 100644 --- a/cmd/share_link_ls_test.go +++ b/cmd/share_link_ls_test.go @@ -262,7 +262,7 @@ func TestShareLinkLSInvalidArguments(t *testing.T) { t.Fatalf("args=%v, err=%v, out=%s", args, err, &out) } } - for _, value := range []string{"", "../escape", "/a/../escape", "/a/./b", "/a//b", "/a/", "/a\\b", "/nul\x00"} { + for _, value := range []string{"", "../escape", "/a/../escape", "//a//../escape/", "/a\\b", `C:\documents`, `\\server\share`, "/nul\x00"} { var out bytes.Buffer cmd := newShareLinkLSTestCommand(&out) _ = cmd.Flags().Set("path", value) @@ -490,27 +490,80 @@ func TestShareLinkLSAPIErrorDetails(t *testing.T) { } func TestShareLinkLSRootAndRelativePath(t *testing.T) { - for _, value := range []string{"/", "documents/sub"} { - t.Run(value, func(t *testing.T) { - want := strings.TrimPrefix(value, "/") + cases := []struct{ value, want string }{ + {"/", ""}, {"//", ""}, {".", ""}, {"/./", ""}, + {"documents/sub", "/documents/sub"}, + {"//documents", "/documents"}, {"/documents/", "/documents"}, + {"//documents//sub/", "/documents/sub"}, + {"/documents/./sub/.", "/documents/sub"}, + {"/資料/é/", "/資料/é"}, {"/資料/e\u0301/", "/資料/e\u0301"}, + {"/CON/", "/CON"}, {"/C:notes/", "/C:notes"}, {"C:/documents/", "/C:/documents"}, + } + for _, tt := range cases { + t.Run(tt.value, func(t *testing.T) { + normalized, err := validatePath(tt.value) + if err != nil || normalized != tt.want { + t.Fatalf("validatePath = %q, %v, want %q", normalized, err, tt.want) + } stubSharedLinkClient(t, &mockSharedLinkClient{getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { - if arg.Path != sharedLinkAPIPath(want) { - t.Fatalf("metadata path=%s", arg.Path) + if arg.Path != tt.want { + t.Fatalf("metadata path = %q, want %q", arg.Path, tt.want) } return sharedLinkFolder("/elsewhere", arg.Url), nil }}) stubFilesClient(t, &mockFilesClient{listFolderFn: func(arg *files.ListFolderArg) (*files.ListFolderResult, error) { - if arg.Path != sharedLinkAPIPath(want) { - t.Fatalf("listing path=%s", arg.Path) + if arg.Path != tt.want { + t.Fatalf("listing path = %q, want %q", arg.Path, tt.want) } - return &files.ListFolderResult{}, nil + return &files.ListFolderResult{Entries: []files.IsMetadata{listingFile("résumé.txt", 1)}}, nil }}) var out bytes.Buffer cmd := newShareLinkLSTestCommand(&out) - _ = cmd.Flags().Set("path", value) + setShareLinkOutputJSON(t, cmd) + if err := cmd.Flags().Set("path", tt.value); err != nil { + t.Fatal(err) + } if err := shareLinkLS(cmd, []string{listingURL}); err != nil { t.Fatal(err) } + got := decodeShareLinkOperationOutput[shareLinkLSInput, shareLinkLSEntry](t, out.Bytes()) + inputPath := tt.want + if inputPath == "" { + inputPath = "/" + } + if got.Input.Path != inputPath || len(got.Results) != 1 || got.Results[0].Result.Path != tt.want+"/résumé.txt" { + t.Fatalf("output = %s", out.String()) + } + }) + } +} + +func TestShareLinkLSRemoteNamesArePlatformIndependent(t *testing.T) { + for _, name := range []string{"résumé.txt", "re\u0301sume\u0301.txt", "資料.txt", "📁.txt", "İ.txt", "CON", "NUL.txt", "COM1", "LPT1.txt", "C:notes", "report:2026.txt", "report?.txt", "report*.txt", "report|name.txt", `.txt`, `quote".txt`, "trailing.", "trailing "} { + t.Run(name, func(t *testing.T) { + stubListingFolder(t) + stubFilesClient(t, &mockFilesClient{listFolderFn: func(arg *files.ListFolderArg) (*files.ListFolderResult, error) { + if arg.Path == "" { + return &files.ListFolderResult{Entries: []files.IsMetadata{listingFolder(name)}}, nil + } + if arg.Path != "/"+name { + t.Fatalf("nested listing path = %q", arg.Path) + } + return &files.ListFolderResult{Entries: []files.IsMetadata{listingFile(name, 0)}}, nil + }}) + var out bytes.Buffer + cmd := newShareLinkLSTestCommand(&out) + setShareLinkOutputJSON(t, cmd) + if err := cmd.Flags().Set("recursive", "true"); err != nil { + t.Fatal(err) + } + if err := shareLinkLS(cmd, []string{listingURL}); err != nil { + t.Fatal(err) + } + got := decodeShareLinkOperationOutput[shareLinkLSInput, shareLinkLSEntry](t, out.Bytes()) + if len(got.Results) != 2 || got.Results[0].Result.Name != name || got.Results[0].Result.Path != "/"+name || got.Results[1].Result.Name != name || got.Results[1].Result.Path != "/"+name+"/"+name { + t.Fatalf("remote names were changed: %s", out.String()) + } }) } } diff --git a/docs/sharing.md b/docs/sharing.md index 5a09f89..1bb292d 100644 --- a/docs/sharing.md +++ b/docs/sharing.md @@ -79,8 +79,14 @@ not mounted in your Dropbox. `share-link list` lists shared links themselves. Without `--recursive`, only immediate children are listed. With `--path`, all returned paths still start at the shared-link root, so they can be passed to `share-link download --path` or `share-link info --path`. Use `/` for the root. -Path components must be non-empty names, without `.` or `..` components or -backslashes. +Repeated slashes, trailing slashes, and `.` components are normalized like +other Dropbox paths. `..` components, backslashes, and NUL bytes are rejected +before normalization. + +Remote names are preserved independently of the local OS, including Unicode, +colons, and Windows device names such as `CON`. Dropbox paths always use `/`; +drive prefixes have no special meaning. Local filesystem restrictions are +checked separately when downloading entries to disk. By default, text output shows only paths in columns, like `dbxcli ls`. Use `--long` (`-l`) to show type, human-readable file size (`-` for folders), From 5da9487a3d5390e7892da5b48ac5bd5b8b76d9b3 Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Thu, 12 Nov 2026 08:48:39 -0800 Subject: [PATCH 4/4] Use a portable reserved Windows name in download test --- cmd/share_link_download_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/share_link_download_test.go b/cmd/share_link_download_test.go index 8b27e39..8feca70 100644 --- a/cmd/share_link_download_test.go +++ b/cmd/share_link_download_test.go @@ -958,7 +958,9 @@ func TestSharedLinkLocalPathKeepsLocalFilesystemChecks(t *testing.T) { t.Fatalf("local path %q = %q, want rejection", rel, got) } } - for _, rel := range []string{"CON", "nested/NUL.txt", "C:notes", "nested/report:2026.txt"} { + // Device names with extensions can be valid on newer Windows versions. + // Bare device names remain reserved on all supported Windows versions. + for _, rel := range []string{"CON", "nested/NUL", "C:notes", "nested/report:2026.txt"} { got, err := sharedLinkLocalPath(root, rel) if runtime.GOOS == "windows" { if err == nil {