From 4af64270cc4d978027782ca68e801bab0446345d Mon Sep 17 00:00:00 2001 From: Loi Nguyen Date: Thu, 27 Aug 2026 12:22:09 +0100 Subject: [PATCH] dropbox: fix ChangeNotify when the root's case differs from Dropbox's - fixes #9692 Dropbox is case insensitive and the path_display it returns in change notifications may not match the case of the configured root. Before this change the root was trimmed with a case sensitive prefix match, so when the cases differed the full path was passed to the ChangeNotify callback and the notification was ignored. This trims the root case insensitively while preserving the display case of the remaining path. --- backend/dropbox/dropbox.go | 39 ++++++++++++-- backend/dropbox/dropbox_internal_test.go | 65 ++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 5 deletions(-) diff --git a/backend/dropbox/dropbox.go b/backend/dropbox/dropbox.go index 9d41a81c6..e69ff17ce 100644 --- a/backend/dropbox/dropbox.go +++ b/backend/dropbox/dropbox.go @@ -392,8 +392,8 @@ type Fs struct { sharing sharing.ContextClient // as above, but for generating sharing links users users.ContextClient // as above, but for accessing user information team team.ContextClient // for the Teams API - slashRoot string // root with "/" prefix, lowercase - slashRootSlash string // root with "/" prefix and postfix, lowercase + slashRoot string // root with "/" prefix + slashRootSlash string // root with "/" prefix and postfix pacer *fs.Pacer // To pace the API calls ns string // The namespace we are using or "" for none batcher *batcher.Batcher[*files.UploadSessionFinishArg, *files.FileMetadata] @@ -1672,6 +1672,34 @@ func (f *Fs) changeNotifyCursor(ctx context.Context) (cursor string, err error) return startCursor.Cursor, nil } +// trimPrefixFold returns s with the leading prefix removed, matching +// case insensitively rune by rune so folded runes of differing UTF-8 +// length still match. +// +// If s is exactly the root that prefix names (prefix without its +// trailing "/") it returns "". If prefix doesn't match it returns s +// unchanged. +func trimPrefixFold(s, prefix string) string { + if strings.HasSuffix(prefix, "/") && strings.EqualFold(s, strings.TrimSuffix(prefix, "/")) { + return "" + } + + offset := 0 + for prefix != "" { + if offset >= len(s) { + return s + } + sRune, sSize := utf8.DecodeRuneInString(s[offset:]) + prefixRune, prefixSize := utf8.DecodeRuneInString(prefix) + if !strings.EqualFold(string(sRune), string(prefixRune)) { + return s + } + offset += sSize + prefix = prefix[prefixSize:] + } + return s[offset:] +} + func (f *Fs) changeNotifyRunner(ctx context.Context, notifyFunc func(string, fs.EntryType), startCursor string) (newCursor string, err error) { cursor := startCursor var res *files.ListFolderLongpollResult @@ -1731,17 +1759,18 @@ func (f *Fs) changeNotifyRunner(ctx context.Context, notifyFunc func(string, fs. switch info := entry.(type) { case *files.FolderMetadata: entryType = fs.EntryDirectory - entryPath = strings.TrimPrefix(info.PathDisplay, f.slashRootSlash) + entryPath = info.PathDisplay case *files.FileMetadata: entryType = fs.EntryObject - entryPath = strings.TrimPrefix(info.PathDisplay, f.slashRootSlash) + entryPath = info.PathDisplay case *files.DeletedMetadata: entryType = fs.EntryObject - entryPath = strings.TrimPrefix(info.PathDisplay, f.slashRootSlash) + entryPath = info.PathDisplay default: fs.Errorf(entry, "dropbox ChangeNotify: ignoring unknown EntryType %T", entry) continue } + entryPath = trimPrefixFold(entryPath, f.slashRootSlash) if entryPath != "" { notifyFunc(f.opt.Enc.ToStandardPath(entryPath), entryType) diff --git a/backend/dropbox/dropbox_internal_test.go b/backend/dropbox/dropbox_internal_test.go index c25f6124a..c6140af17 100644 --- a/backend/dropbox/dropbox_internal_test.go +++ b/backend/dropbox/dropbox_internal_test.go @@ -86,6 +86,71 @@ func TestInternalGetMetadataCancellation(t *testing.T) { } } +type changeNotifyClient struct { + files.ContextClient + entries []files.IsMetadata +} + +func (c changeNotifyClient) ListFolderLongpollContext(context.Context, *files.ListFolderLongpollArg) (*files.ListFolderLongpollResult, error) { + return files.NewListFolderLongpollResult(true), nil +} + +func (c changeNotifyClient) ListFolderContinueContext(context.Context, *files.ListFolderContinueArg) (*files.ListFolderResult, error) { + return files.NewListFolderResult(c.entries, "next", false), nil +} + +func TestTrimPrefixFold(t *testing.T) { + for _, test := range []struct { + name string + path string + prefix string + want string + }{ + {name: "exact casing", path: "/Docs/Sub", prefix: "/Docs/", want: "Sub"}, + {name: "different casing", path: "/docs/Sub", prefix: "/Docs/", want: "Sub"}, + {name: "different UTF-8 lengths", path: "/K/Sub", prefix: "/K/", want: "Sub"}, + {name: "exact root", path: "/docs", prefix: "/Docs/", want: ""}, + {name: "sibling boundary", path: "/Docs2/File", prefix: "/Docs/", want: "/Docs2/File"}, + {name: "root remote", path: "/File", prefix: "/", want: "File"}, + } { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, trimPrefixFold(test.path, test.prefix)) + }) + } +} + +func TestChangeNotifyTrimsRootCaseInsensitively(t *testing.T) { + ctx := context.Background() + client := changeNotifyClient{entries: []files.IsMetadata{ + &files.FolderMetadata{Metadata: files.Metadata{PathDisplay: "/docs/Sub"}}, + &files.FileMetadata{Metadata: files.Metadata{PathDisplay: "/docs/Sub/File.TXT"}}, + &files.DeletedMetadata{Metadata: files.Metadata{PathDisplay: "/docs/Gone.md"}}, + }} + f := &Fs{ + ci: fs.GetConfig(ctx), + srv: client, + svc: client, + slashRootSlash: "/Docs/", + pacer: fs.NewPacer(ctx, pacer.NewDefault()), + } + + type notification struct { + path string + entryType fs.EntryType + } + var notifications []notification + cursor, err := f.changeNotifyRunner(ctx, func(path string, entryType fs.EntryType) { + notifications = append(notifications, notification{path: path, entryType: entryType}) + }, "start") + require.NoError(t, err) + assert.Equal(t, "next", cursor) + assert.Equal(t, []notification{ + {path: "Sub", entryType: fs.EntryDirectory}, + {path: "Sub/File.TXT", entryType: fs.EntryObject}, + {path: "Gone.md", entryType: fs.EntryObject}, + }, notifications) +} + func TestInternalCheckPathLength(t *testing.T) { rep := func(n int, r rune) (out string) { rs := make([]rune, n)