From 00bf6c19d80a222d93c2bb244899e8f3b87d1d82 Mon Sep 17 00:00:00 2001 From: Nick Craig-Wood Date: Thu, 2 Jul 2026 11:06:24 +0100 Subject: [PATCH] drive: detect shortcut loops to avoid infinite recursion A folder shortcut pointing at one of its own ancestor folders (for example a shortcut to FolderX placed inside FolderX) made rclone recurse forever when dereferencing shortcuts, duplicating the folder contents until the disk was full. Rclone now detects when a folder shortcut targets an ancestor directory using the directory cache, leaves that shortcut out of the listing and logs an ERROR, so the rest of the drive can still be copied. Fixes #7118 Fixes #9565 Closes #9051 --- backend/drive/drive.go | 28 +++++++++++++++++++++++++++ backend/drive/drive_internal_test.go | 29 ++++++++++++++++++++++++++++ docs/content/drive.md | 7 ++++--- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/backend/drive/drive.go b/backend/drive/drive.go index 5d844604e..33c5ce38f 100644 --- a/backend/drive/drive.go +++ b/backend/drive/drive.go @@ -2416,12 +2416,40 @@ func (f *Fs) resolveShortcut(ctx context.Context, item *drive.File) (newItem *dr return newItem, nil } +// shortcutLoop reports whether a folder shortcut at remote pointing to +// the directory targetID would create a recursive loop. +// +// This is the case when targetID is the ID of remote's parent or any +// other ancestor directory, as descending into it would list an +// ancestor again forever. The ancestor IDs are looked up in the +// directory cache which is always populated for the parents of remote +// as they are listed before their children. +func (f *Fs) shortcutLoop(remote, targetID string) bool { + for dir := path.Dir(remote); ; dir = path.Dir(dir) { + if dir == "." { + dir = "" + } + if id, ok := f.dirCache.Get(dir); ok && actualID(id) == targetID { + return true + } + if dir == "" { + return false + } + } +} + // itemToDirEntry converts a drive.File to an fs.DirEntry. // When the drive.File cannot be represented as an fs.DirEntry // (nil, nil) is returned. func (f *Fs) itemToDirEntry(ctx context.Context, remote string, item *drive.File) (entry fs.DirEntry, err error) { switch { case item.MimeType == driveFolderType: + // A folder shortcut pointing at one of its own ancestors + // would make rclone recurse forever, so drop it. + if isShortcutID(item.Id) && f.shortcutLoop(remote, actualID(item.Id)) { + fs.Errorf(remote, "Ignoring folder shortcut as it points to an ancestor directory creating a loop") + return nil, nil + } // cache the directory ID for later lookups f.dirCache.Put(remote, item.Id) // cache the resource key for later lookups diff --git a/backend/drive/drive_internal_test.go b/backend/drive/drive_internal_test.go index d5c2aff26..d2595d621 100644 --- a/backend/drive/drive_internal_test.go +++ b/backend/drive/drive_internal_test.go @@ -24,6 +24,7 @@ import ( "github.com/rclone/rclone/fs/sync" "github.com/rclone/rclone/fstest" "github.com/rclone/rclone/fstest/fstests" + "github.com/rclone/rclone/lib/dircache" "github.com/rclone/rclone/lib/random" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -145,6 +146,34 @@ func TestInternalFindExportFormat(t *testing.T) { } } +func TestShortcutLoop(t *testing.T) { + f := &Fs{} + f.dirCache = dircache.New("", "root", f) + // root is FolderX with ID "X" + f.dirCache.Put("", "X") + // a real subfolder + f.dirCache.Put("sub", "sub-id") + // a nested subfolder reached via a shortcut, composite ID + f.dirCache.Put("sub/nested", joinID("nested-id", "shortcut-id")) + + for _, test := range []struct { + name string + remote string + targetID string + want bool + }{ + {"shortcut to the root itself", "loop", "X", true}, + {"shortcut to the parent", "sub/loop", "sub-id", true}, + {"shortcut to a grandparent", "sub/nested/loop", "X", true}, + {"shortcut to a composite ancestor", "sub/nested/loop", "nested-id", true}, + {"shortcut to an unrelated folder", "sub/loop", "other-id", false}, + {"shortcut to a sibling", "sub/loop", "sub-id2", false}, + } { + got := f.shortcutLoop(test.remote, test.targetID) + assert.Equal(t, test.want, got, test.name) + } +} + func TestMimeTypesToExtension(t *testing.T) { for mimeType, extension := range _mimeTypeToExtension { extensions, err := mime.ExtensionsByType(mimeType) diff --git a/docs/content/drive.md b/docs/content/drive.md index 4cb758b6d..f9920a382 100644 --- a/docs/content/drive.md +++ b/docs/content/drive.md @@ -483,9 +483,10 @@ used to create shortcuts. Shortcuts can be completely ignored with the `--drive-skip-shortcuts` flag or the corresponding `skip_shortcuts` configuration setting. -If you have shortcuts that lead to an infinite recursion in your drive (e.g. a -shortcut pointing to a parent folder), `skip_shortcuts` might be mandatory to -be able to copy the drive. +If you have a folder shortcut that points at one of its own parent folders it +would lead to an infinite recursion. Rclone detects this, leaves the offending +shortcut out of the listing and logs an ERROR, so the rest of the drive can +still be copied. ### Emptying trash