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)