bisync/dropbox: fail closed on listing collapse and cursor races
build / lint (push) Canceled after 0s
build / android-all (push) Canceled after 0s
Build & Push Docker Images / Build Docker Image for linux/386 (push) Canceled after 0s
Build & Push Docker Images / Build Docker Image for linux/amd64 (push) Canceled after 0s
Build & Push Docker Images / Build Docker Image for linux/arm/v6 (push) Canceled after 0s
Build & Push Docker Images / Build Docker Image for linux/arm/v7 (push) Canceled after 0s
Build & Push Docker Images / Build Docker Image for linux/arm64 (push) Canceled after 0s
build / windows (push) Canceled after 0s
build / other_os (push) Canceled after 0s
build / mac_amd64 (push) Canceled after 0s
build / mac_arm64 (push) Canceled after 0s
build / linux (push) Canceled after 0s
build / go1.26 (push) Canceled after 0s
build / linux_386 (push) Canceled after 0s
Build & Push Docker Images / Merge & Push Final Docker Image (push) Canceled after 0s
build / lint (push) Canceled after 0s
build / android-all (push) Canceled after 0s
Build & Push Docker Images / Build Docker Image for linux/386 (push) Canceled after 0s
Build & Push Docker Images / Build Docker Image for linux/amd64 (push) Canceled after 0s
Build & Push Docker Images / Build Docker Image for linux/arm/v6 (push) Canceled after 0s
Build & Push Docker Images / Build Docker Image for linux/arm/v7 (push) Canceled after 0s
Build & Push Docker Images / Build Docker Image for linux/arm64 (push) Canceled after 0s
build / windows (push) Canceled after 0s
build / other_os (push) Canceled after 0s
build / mac_amd64 (push) Canceled after 0s
build / mac_arm64 (push) Canceled after 0s
build / linux (push) Canceled after 0s
build / go1.26 (push) Canceled after 0s
build / linux_386 (push) Canceled after 0s
Build & Push Docker Images / Merge & Push Final Docker Image (push) Canceled after 0s
Refuse --delta-list without --check-access. Abort if a listing goes empty or shrinks past --max-delete versus the prior snapshot (full relist and local walk included). Persist cursor before listing so a crash cannot pair a new listing with a stale cursor. Reconstruct delta remotes from path_lower plus leaf casing; ListR probes a non-recursive list when the recursive result is empty.
This commit is contained in:
@@ -1382,6 +1382,26 @@ func (f *Fs) ListR(ctx context.Context, dir string, callback fs.ListRCallback) (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(items) == 0 {
|
||||||
|
arg := files.NewListFolderArg(f.opt.Enc.FromStandardPath(root))
|
||||||
|
arg.Recursive = false
|
||||||
|
arg.Limit = 1
|
||||||
|
if root == "/" {
|
||||||
|
arg.Path = ""
|
||||||
|
}
|
||||||
|
var probe *files.ListFolderResult
|
||||||
|
err = f.pacer.Call(func() (bool, error) {
|
||||||
|
probe, err = f.srv.ListFolderContext(ctx, arg)
|
||||||
|
return shouldRetry(ctx, err)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("dropbox ListR: empty recursive listing and probe failed: %w", err)
|
||||||
|
}
|
||||||
|
if probe != nil && len(probe.Entries) > 0 {
|
||||||
|
return errors.New("dropbox ListR: recursive listing empty but directory has children; refusing incomplete listing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
skipPrefixes := make([]string, 0)
|
skipPrefixes := make([]string, 0)
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
if it.folder == nil || it.folder.SharingInfo == nil || it.folder.SharingInfo.SharedFolderId == "" {
|
if it.folder == nil || it.folder.SharingInfo == nil || it.folder.SharingInfo.SharedFolderId == "" {
|
||||||
@@ -2630,30 +2650,45 @@ func (f *Fs) ListFolderDeltas(ctx context.Context, cursor string) (newCursor str
|
|||||||
|
|
||||||
func (f *Fs) listingChangeFromEntry(entry files.IsMetadata) (ListingChange, bool) {
|
func (f *Fs) listingChangeFromEntry(entry files.IsMetadata) (ListingChange, bool) {
|
||||||
var (
|
var (
|
||||||
ch ListingChange
|
ch ListingChange
|
||||||
entryPath string
|
md *files.Metadata
|
||||||
|
lower string
|
||||||
)
|
)
|
||||||
switch info := entry.(type) {
|
switch info := entry.(type) {
|
||||||
case *files.FolderMetadata:
|
case *files.FolderMetadata:
|
||||||
ch.IsDir = true
|
ch.IsDir = true
|
||||||
entryPath = info.PathDisplay
|
md = &info.Metadata
|
||||||
|
lower = info.PathLower
|
||||||
case *files.FileMetadata:
|
case *files.FileMetadata:
|
||||||
entryPath = info.PathDisplay
|
md = &info.Metadata
|
||||||
|
lower = info.PathLower
|
||||||
ch.Size = int64(info.Size)
|
ch.Size = int64(info.Size)
|
||||||
ch.ModTime = time.Time(info.ClientModified)
|
ch.ModTime = time.Time(info.ClientModified)
|
||||||
ch.Hash = info.ContentHash
|
ch.Hash = info.ContentHash
|
||||||
case *files.DeletedMetadata:
|
case *files.DeletedMetadata:
|
||||||
ch.Deleted = true
|
ch.Deleted = true
|
||||||
ch.IsDir = true
|
md = &info.Metadata
|
||||||
entryPath = info.PathDisplay
|
lower = info.PathLower
|
||||||
default:
|
default:
|
||||||
return ListingChange{}, false
|
return ListingChange{}, false
|
||||||
}
|
}
|
||||||
entryPath = trimPrefixFold(entryPath, f.slashRootSlash)
|
if lower == "" {
|
||||||
if entryPath == "" {
|
|
||||||
return ListingChange{}, false
|
return ListingChange{}, false
|
||||||
}
|
}
|
||||||
ch.Remote = f.opt.Enc.ToStandardPath(entryPath)
|
leaf := listRLeaf(md)
|
||||||
|
rel := trimPrefixFold(lower, f.slashRootSlash)
|
||||||
|
if rel == "" {
|
||||||
|
return ListingChange{}, false
|
||||||
|
}
|
||||||
|
parent := path.Dir(rel)
|
||||||
|
if parent == "." || parent == "/" {
|
||||||
|
parent = ""
|
||||||
|
}
|
||||||
|
if parent == "" {
|
||||||
|
ch.Remote = f.opt.Enc.ToStandardName(leaf)
|
||||||
|
} else {
|
||||||
|
ch.Remote = path.Join(f.opt.Enc.ToStandardPath(parent), f.opt.Enc.ToStandardName(leaf))
|
||||||
|
}
|
||||||
return ch, true
|
return ch, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -545,6 +545,7 @@ func TestListingChangeFromEntry(t *testing.T) {
|
|||||||
Metadata: files.Metadata{
|
Metadata: files.Metadata{
|
||||||
Name: "a.txt",
|
Name: "a.txt",
|
||||||
PathDisplay: "/Dropbox/sub/a.txt",
|
PathDisplay: "/Dropbox/sub/a.txt",
|
||||||
|
PathLower: "/dropbox/sub/a.txt",
|
||||||
},
|
},
|
||||||
Size: 42,
|
Size: 42,
|
||||||
ContentHash: "abc",
|
ContentHash: "abc",
|
||||||
@@ -561,6 +562,7 @@ func TestListingChangeFromEntry(t *testing.T) {
|
|||||||
Metadata: files.Metadata{
|
Metadata: files.Metadata{
|
||||||
Name: "sub",
|
Name: "sub",
|
||||||
PathDisplay: "/Dropbox/sub",
|
PathDisplay: "/Dropbox/sub",
|
||||||
|
PathLower: "/dropbox/sub",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
ch, ok = f.listingChangeFromEntry(del)
|
ch, ok = f.listingChangeFromEntry(del)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -17,6 +18,67 @@ var errDeltaTooManyDeletes = errors.New("delta list: too many deletes")
|
|||||||
// The in-memory listing must be left unchanged so the caller can full-relist.
|
// The in-memory listing must be left unchanged so the caller can full-relist.
|
||||||
var errDeltaCursorReset = errors.New("delta list: cursor reset")
|
var errDeltaCursorReset = errors.New("delta list: cursor reset")
|
||||||
|
|
||||||
|
// errDeltaListingEmptied is returned when a prior non-empty listing would be replaced by zero files.
|
||||||
|
var errDeltaListingEmptied = errors.New("delta list: listing became empty while prior listing had files")
|
||||||
|
|
||||||
|
// errDeltaListingCollapsed is returned when file count dropped beyond --max-delete.
|
||||||
|
var errDeltaListingCollapsed = errors.New("delta list: listing shrank beyond --max-delete")
|
||||||
|
|
||||||
|
func countListingFiles(ls *fileList) int {
|
||||||
|
if ls == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for _, name := range ls.list {
|
||||||
|
if !ls.isDir(name) {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func guardFileCount(oldC, newC int, opt deltaApplyOpts) error {
|
||||||
|
if opt.Force || oldC == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if newC == 0 {
|
||||||
|
return errDeltaListingEmptied
|
||||||
|
}
|
||||||
|
if opt.MaxDelete >= 0 {
|
||||||
|
dropped := oldC - newC
|
||||||
|
if dropped > 0 && float64(dropped)/float64(oldC) > float64(opt.MaxDelete)/100.0 {
|
||||||
|
return errDeltaListingCollapsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func guardListingShrink(prior, next *fileList, opt deltaApplyOpts) error {
|
||||||
|
if prior == nil || next == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return guardFileCount(countListingFiles(prior), countListingFiles(next), opt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func caseDeltaRemote(ls *fileList, remote string) string {
|
||||||
|
parent := path.Dir(remote)
|
||||||
|
if parent == "." {
|
||||||
|
parent = ""
|
||||||
|
}
|
||||||
|
if parent == "" {
|
||||||
|
return remote
|
||||||
|
}
|
||||||
|
if ls.has(parent) {
|
||||||
|
return path.Join(parent, path.Base(remote))
|
||||||
|
}
|
||||||
|
for _, name := range ls.list {
|
||||||
|
if strings.EqualFold(name, parent) {
|
||||||
|
return path.Join(name, path.Base(remote))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return remote
|
||||||
|
}
|
||||||
|
|
||||||
type listingDelta struct {
|
type listingDelta struct {
|
||||||
Remote string
|
Remote string
|
||||||
Deleted bool
|
Deleted bool
|
||||||
@@ -63,6 +125,20 @@ func applyListingDeltas(ls *fileList, deltas []listingDelta, opt deltaApplyOpts)
|
|||||||
deletedFiles := 0
|
deletedFiles := 0
|
||||||
|
|
||||||
for _, d := range deltas {
|
for _, d := range deltas {
|
||||||
|
if d.Deleted {
|
||||||
|
if ls.has(d.Remote) {
|
||||||
|
// keep
|
||||||
|
} else {
|
||||||
|
for _, name := range ls.list {
|
||||||
|
if strings.EqualFold(name, d.Remote) {
|
||||||
|
d.Remote = name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
d.Remote = caseDeltaRemote(ls, d.Remote)
|
||||||
|
}
|
||||||
if d.Deleted {
|
if d.Deleted {
|
||||||
// Dropbox DeletedMetadata: remove the path and all children.
|
// Dropbox DeletedMetadata: remove the path and all children.
|
||||||
prefix := d.Remote
|
prefix := d.Remote
|
||||||
@@ -114,13 +190,18 @@ func saveListingAndCursor(listing, cursor, root string, ls *fileList) error {
|
|||||||
_ = os.Remove(tmpListing)
|
_ = os.Remove(tmpListing)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := os.Rename(tmpListing, listing); err != nil {
|
// Cursor first: a crash here leaves old listing + new cursor (missed
|
||||||
|
// updates, not a mass-delete). Listing-first would leave a new listing
|
||||||
|
// with a stale cursor, which can look like deletes on the next run.
|
||||||
|
if err := os.Rename(tmpCursor, cursorPath(listing)); err != nil {
|
||||||
_ = os.Remove(tmpListing)
|
_ = os.Remove(tmpListing)
|
||||||
_ = os.Remove(tmpCursor)
|
_ = os.Remove(tmpCursor)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := os.Rename(tmpCursor, cursorPath(listing)); err != nil {
|
if err := os.Rename(tmpListing, listing); err != nil {
|
||||||
return fmt.Errorf("listing saved but cursor rename failed (next run will full-list): %w", err)
|
_ = os.Remove(tmpListing)
|
||||||
|
_ = os.Remove(cursorPath(listing))
|
||||||
|
return fmt.Errorf("cursor saved but listing rename failed (cursor removed to force full-list): %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ func (b *bisyncRun) makeDeltaListing(ctx context.Context) (*fileList, *fileList,
|
|||||||
} else {
|
} else {
|
||||||
fs.Infof(nil, "Path1 full listing (no USN/journal; local walk)")
|
fs.Infof(nil, "Path1 full listing (no USN/journal; local walk)")
|
||||||
err = b.listOneFs(ctx, true)
|
err = b.listOneFs(ctx, true)
|
||||||
|
if err == nil {
|
||||||
|
if prior, lerr := b.loadListing(b.listing1); lerr == nil {
|
||||||
|
err = guardListingShrink(prior, b.march.ls1, deltaApplyOpts{MaxDelete: b.opt.MaxDelete, Force: b.opt.Force})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.handleErr("delta-list", "error listing Path1", err, true, true)
|
b.handleErr("delta-list", "error listing Path1", err, true, true)
|
||||||
@@ -56,6 +61,11 @@ func (b *bisyncRun) makeDeltaListing(ctx context.Context) (*fileList, *fileList,
|
|||||||
} else {
|
} else {
|
||||||
fs.Infof(nil, "Path2 full listing (no USN/journal; local walk)")
|
fs.Infof(nil, "Path2 full listing (no USN/journal; local walk)")
|
||||||
err = b.listOneFs(ctx, false)
|
err = b.listOneFs(ctx, false)
|
||||||
|
if err == nil {
|
||||||
|
if prior, lerr := b.loadListing(b.listing2); lerr == nil {
|
||||||
|
err = guardListingShrink(prior, b.march.ls2, deltaApplyOpts{MaxDelete: b.opt.MaxDelete, Force: b.opt.Force})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.handleErr("delta-list", "error listing Path2", err, true, true)
|
b.handleErr("delta-list", "error listing Path2", err, true, true)
|
||||||
@@ -97,15 +107,19 @@ func (b *bisyncRun) refreshDropboxListing(ctx context.Context, dbx *dropbox.Fs,
|
|||||||
cursor = ""
|
cursor = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var prior *fileList
|
||||||
if cursor != "" {
|
if cursor != "" {
|
||||||
prior, err := b.loadListing(listing)
|
loaded, err := b.loadListing(listing)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fs.Infof(nil, "No usable prior listing (%v); full Dropbox relist", err)
|
fs.Infof(nil, "No usable prior listing (%v); full Dropbox relist", err)
|
||||||
cursor = ""
|
cursor = ""
|
||||||
} else {
|
} else {
|
||||||
ls = prior
|
ls = loaded
|
||||||
ls.hash = b.hashTypeForFs(f)
|
ls.hash = b.hashTypeForFs(f)
|
||||||
|
prior = loaded
|
||||||
}
|
}
|
||||||
|
} else if loaded, err := b.loadListing(listing); err == nil {
|
||||||
|
prior = loaded
|
||||||
}
|
}
|
||||||
|
|
||||||
newCursor, reset, changes, err := dbx.ListFolderDeltas(ctx, cursor)
|
newCursor, reset, changes, err := dbx.ListFolderDeltas(ctx, cursor)
|
||||||
@@ -152,13 +166,24 @@ func (b *bisyncRun) refreshDropboxListing(ctx context.Context, dbx *dropbox.Fs,
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
applyErr := applyListingDeltas(ls, deltas, deltaApplyOpts{
|
applyOpts := deltaApplyOpts{
|
||||||
MaxDelete: b.opt.MaxDelete,
|
MaxDelete: b.opt.MaxDelete,
|
||||||
Force: b.opt.Force,
|
Force: b.opt.Force,
|
||||||
})
|
}
|
||||||
|
priorCount := 0
|
||||||
|
hadPrior := prior != nil
|
||||||
|
if hadPrior {
|
||||||
|
priorCount = countListingFiles(prior)
|
||||||
|
}
|
||||||
|
applyErr := applyListingDeltas(ls, deltas, applyOpts)
|
||||||
if applyErr != nil {
|
if applyErr != nil {
|
||||||
return nil, applyErr
|
return nil, applyErr
|
||||||
}
|
}
|
||||||
|
if hadPrior {
|
||||||
|
if err := guardFileCount(priorCount, countListingFiles(ls), applyOpts); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if b.opt.DryRun {
|
if b.opt.DryRun {
|
||||||
fs.Infof(nil, "dry-run: not saving Dropbox listing cursor")
|
fs.Infof(nil, "dry-run: not saving Dropbox listing cursor")
|
||||||
|
|||||||
@@ -120,3 +120,12 @@ func TestLoadCursor_MissingIsEmpty(t *testing.T) {
|
|||||||
_, err = os.Stat(cursorPath(listing))
|
_, err = os.Stat(cursorPath(listing))
|
||||||
assert.True(t, os.IsNotExist(err))
|
assert.True(t, os.IsNotExist(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGuardFileCount(t *testing.T) {
|
||||||
|
opt := deltaApplyOpts{MaxDelete: 50}
|
||||||
|
require.NoError(t, guardFileCount(0, 0, opt))
|
||||||
|
require.NoError(t, guardFileCount(100, 80, opt))
|
||||||
|
require.ErrorIs(t, guardFileCount(100, 0, opt), errDeltaListingEmptied)
|
||||||
|
require.ErrorIs(t, guardFileCount(100, 40, opt), errDeltaListingCollapsed)
|
||||||
|
require.NoError(t, guardFileCount(100, 0, deltaApplyOpts{MaxDelete: 50, Force: true}))
|
||||||
|
}
|
||||||
|
|||||||
@@ -124,6 +124,10 @@ func Bisync(ctx context.Context, fs1, fs2 fs.Fs, optArg *Options) (err error) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if opt.DeltaList && !opt.CheckAccess && !opt.Force {
|
||||||
|
return fmt.Errorf("--delta-list requires --check-access (or --force to bypass)")
|
||||||
|
}
|
||||||
|
|
||||||
// Handle lock file
|
// Handle lock file
|
||||||
err = b.setLockFile()
|
err = b.setLockFile()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user