Repository: ncw/rclone (go)

Issue:
Azure blob integration tests broken by context propagation
Eg https://pub.rclone.org/integration-tests/2019-06-28-050155/

See https://pub.rclone.org/integration-tests/current for the most recent

![image](https://user-images.githubusercontent.com/536803/60346073-4e654980-99b2-11e9-9d5f-33f23aa0cc93.png)

These all appear to have something to do with renaming directories...

According to git bisect this was the commit which broke it f78cd1e0434ee597fb8937ba3cda0d5fd58becfe

This added context into the interface and passed the context on to the AzureBlob SDK which doesn't look as if it should have broken anything.

The error is this

```
$ ( cd fs/operations && go test -v -remote TestAzureBlob: -run TestDirMove -verbose )
=== RUN   TestDirMove
2019/06/28 14:53:16 DEBUG : A1/B2: Making directory
2019/06/28 14:53:16 DEBUG : A1/B1/C3: Making directory
2019/06/28 14:53:16 INFO  : A1/B1/three: Copied (server side copy)
2019/06/28 14:53:16 INFO  : A1/one: Copied (server side copy)
2019/06/28 14:53:16 INFO  : A1/two: Copied (server side copy)
2019/06/28 14:53:16 INFO  : A1/B1/C1/four: Copied (server side copy)
2019/06/28 14:53:16 INFO  : A1/B1/three: Deleted
2019/06/28 14:53:16 INFO  : A1/one: Deleted
2019/06/28 14:53:16 INFO  : A1/two: Deleted
2019/06/28 14:53:16 INFO  : A1/B1/C1/four: Deleted
2019/06/28 14:53:16 INFO  : A1/B1/C2/five: Copied (server side copy)
2019/06/28 14:53:16 INFO  : A1/B1/C2/five: Deleted
--- FAIL: TestDirMove (0.44s)
    run.go:172: Remote "Azure container rclone-test-jiganin7cujuvex3zahebab9", Local "Local file system at /tmp/rclone881171044", Modify Window "1ns"
    fstest.go:260: Filtering empty directory "A1/B2"
    fstest.go:260: Filtering empty directory "A1/B1/C3"
    require.go:794: 
        	Error Trace:	operations_test.go:1168
        	Error:      	Received unexpected error:
        	            	-> github.com/ncw/rclone/vendor/github.com/Azure/azure-pipeline-go/pipeline.NewError, /home/ncw/go/src/github.com/ncw/rclone/vendor/github.com/Azure/azure-pipeline-go/pipeline/error.go:154
        	            	HTTP request failed
        	            	
        	            	Get https://rclone.blob.core.windows.net/rclone-test-jiganin7cujuvex3zahebab9?comp=list&delimiter=&include=metadata&maxresults=1&prefix=A1%2FB1%2FC2%2F&restype=container&timeout=31536001: context canceled
        	            	
        	            	RenameDir rmdir
        	            	github.com/ncw/rclone/fs/operations.DirMove
        	            		/home/ncw/go/src/github.com/ncw/rclone/fs/operations/operations.go:1802
        	            	github.com/ncw/rclone/fs/operations_test.TestDirMove
        	            		/home/ncw/go/src/github.com/ncw/rclone/fs/operations/operations_test.go:1168
        	            	testing.tRunner
        	            		/opt/go/go1.12/src/testing/testing.go:865
        	            	runtime.goexit
        	            		/opt/go/go1.12/src/runtime/asm_amd64.s:1337
        	Test:       	TestDirMove
FAIL
2019/06/28 14:53:16 DEBUG : Azure container rclone-test-jiganin7cujuvex3zahebab9: Purge remote
exit status 1
FAIL	github.com/ncw/rclone/fs/operations	0.475s
```

Making this very small change (stopping the context propagation into the Azure SDK) fixes the problem

```diff
diff --git a/backend/azureblob/azureblob.go b/backend/azureblob/azureblob.go
index 92c6893ec..0c54057fb 100644
--- a/backend/azureblob/azureblob.go
+++ b/backend/azureblob/azureblob.go
@@ -520,6 +520,7 @@ type listFn func(remote string, object *azblob.BlobItem, isDirectory bool) error
 //
 // dir is the starting directory, "" for root
 func (f *Fs) list(ctx context.Context, dir string, recurse bool, maxResults uint, fn listFn) error {
+	ctx = context.Background()
 	f.containerOKMu.Lock()
 	deleted := f.containerDeleted
 	f.containerOKMu.Unlock()
```

Any ideas @sandeepkru or @ajankovic ?

Pull request:
context: fix errgroup interaction with context
#### What is the purpose of this change?

Fixes #3307

`errgroup` wraps context with new one that is canceled once the first Go routine from the group returns. We should use separate context for operations related to this part.

I haven't tested this as I can't run integration tests.

#### Checklist

- [x] I have read the [contribution guidelines](https://github.com/ncw/rclone/blob/master/CONTRIBUTING.md#submitting-a-pull-request).
- [x] I have added tests for all changes in this PR if appropriate.
- [x] I have added documentation for the changes if appropriate.
- [x] All commit messages are in [house style](https://github.com/ncw/rclone/blob/master/CONTRIBUTING.md#commit-messages).
- [x] I'm done, this Pull Request is ready for review :-)


Patch:
diff --git a/cmd/sync/sync.go b/cmd/sync/sync.go
index d840db248..aef4bd43d 100644
--- a/cmd/sync/sync.go
+++ b/cmd/sync/sync.go
@@ -4,6 +4,7 @@ import (
 	"context"
 
 	"github.com/ncw/rclone/cmd"
+	"github.com/ncw/rclone/fs/operations"
 	"github.com/ncw/rclone/fs/sync"
 	"github.com/spf13/cobra"
 )
@@ -44,9 +45,12 @@ go there.
 `,
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(2, 2, command, args)
-		fsrc, fdst := cmd.NewFsSrcDst(args)
+		fsrc, srcFileName, fdst := cmd.NewFsSrcFileDst(args)
 		cmd.Run(true, true, command, func() error {
-			return sync.Sync(context.Background(), fdst, fsrc, createEmptySrcDirs)
+			if srcFileName == "" {
+				return sync.Sync(context.Background(), fdst, fsrc, createEmptySrcDirs)
+			}
+			return operations.CopyFile(context.Background(), fdst, fsrc, srcFileName, srcFileName)
 		})
 	},
 }
diff --git a/docs/content/authors.md b/docs/content/authors.md
index 8364450cd..2abb8db0c 100644
--- a/docs/content/authors.md
+++ b/docs/content/authors.md
@@ -266,4 +266,5 @@ Contributors
   * Aleksandar Jankovic <office@ajankovic.com>
   * Maran <maran@protonmail.com>
   * nguyenhuuluan434 <nguyenhuuluan434@gmail.com>
-  * Laura <zotan@zotan.pw>
+  * Laura Hausmann <zotan@zotan.pw>
+  * yparitcher <y@paritcher.com>
diff --git a/docs/content/docs.md b/docs/content/docs.md
index 79e782192..2e37b07cc 100644
--- a/docs/content/docs.md
+++ b/docs/content/docs.md
@@ -881,11 +881,23 @@ The default is `bytes`.
 
 ### --suffix=SUFFIX ###
 
-This is for use with `--backup-dir` only.  If this isn't set then
-`--backup-dir` will move files with their original name.  If it is set
-then the files will have SUFFIX added on to them.
+When using `sync`, `copy` or `move` any files which would have been
+overwritten or deleted will have the suffix added to them.  If there 
+is a file with the same path (after the suffix has been added), then 
+it will be overwritten.
+
+The remote in use must support server side move or copy and you must
+use the same remote as the destination of the sync.
+
+This is for use with files to add the suffix in the current directory 
+or with `--backup-dir`. See `--backup-dir` for more info.
 
-See `--backup-dir` for more info.
+For example
+
+    rclone sync /path/to/local/file remote:current --suffix .bak
+
+will sync `/path/to/local` to `remote:current`, but for any files
+which would have been updated or deleted have .bak added.
 
 ### --suffix-keep-extension ###
 
diff --git a/fs/config/configflags/configflags.go b/fs/config/configflags/configflags.go
index f585e283d..97e5cbe11 100644
--- a/fs/config/configflags/configflags.go
+++ b/fs/config/configflags/configflags.go
@@ -68,7 +68,7 @@ func AddFlags(flagSet *pflag.FlagSet) {
 	flags.BoolVarP(flagSet, &fs.Config.NoTraverse, "no-traverse", "", fs.Config.NoTraverse, "Don't traverse destination file system on copy.")
 	flags.BoolVarP(flagSet, &fs.Config.NoUpdateModTime, "no-update-modtime", "", fs.Config.NoUpdateModTime, "Don't update destination mod-time if files identical.")
 	flags.StringVarP(flagSet, &fs.Config.BackupDir, "backup-dir", "", fs.Config.BackupDir, "Make backups into hierarchy based in DIR.")
-	flags.StringVarP(flagSet, &fs.Config.Suffix, "suffix", "", fs.Config.Suffix, "Suffix for use with --backup-dir.")
+	flags.StringVarP(flagSet, &fs.Config.Suffix, "suffix", "", fs.Config.Suffix, "Suffix to add to changed files.")
 	flags.BoolVarP(flagSet, &fs.Config.SuffixKeepExtension, "suffix-keep-extension", "", fs.Config.SuffixKeepExtension, "Preserve the extension when using --suffix.")
 	flags.BoolVarP(flagSet, &fs.Config.UseListR, "fast-list", "", fs.Config.UseListR, "Use recursive list if available. Uses more memory but fewer transactions.")
 	flags.Float64VarP(flagSet, &fs.Config.TPSLimit, "tpslimit", "", fs.Config.TPSLimit, "Limit HTTP transactions per second to this.")
@@ -152,10 +152,6 @@ func SetFlags() {
 		log.Fatalf(`Can't use --size-only and --ignore-size together.`)
 	}
 
-	if fs.Config.Suffix != "" && fs.Config.BackupDir == "" {
-		log.Fatalf(`Can only use --suffix with --backup-dir.`)
-	}
-
 	switch {
 	case len(fs.Config.StatsOneLineDateFormat) > 0:
 		fs.Config.StatsOneLineDate = true
diff --git a/fs/operations/multithread.go b/fs/operations/multithread.go
index 211b32102..56d7677ab 100644
--- a/fs/operations/multithread.go
+++ b/fs/operations/multithread.go
@@ -122,9 +122,9 @@ func multiThreadCopy(ctx context.Context, f fs.Fs, remote string, src fs.Object,
 		return nil, errors.New("multi-thread copy: can't copy zero sized file")
 	}
 
-	g, ctx := errgroup.WithContext(context.Background())
+	g, gCtx := errgroup.WithContext(ctx)
 	mc := &multiThreadCopyState{
-		ctx:     ctx,
+		ctx:     gCtx,
 		size:    src.Size(),
 		src:     src,
 		streams: streams,
@@ -136,7 +136,7 @@ func multiThreadCopy(ctx context.Context, f fs.Fs, remote string, src fs.Object,
 	defer fs.CheckClose(mc.acc, &err)
 
 	// create write file handle
-	mc.wc, err = openWriterAt(ctx, remote, mc.size)
+	mc.wc, err = openWriterAt(gCtx, remote, mc.size)
 	if err != nil {
 		return nil, errors.Wrap(err, "multpart copy: failed to open destination")
 	}
@@ -146,7 +146,7 @@ func multiThreadCopy(ctx context.Context, f fs.Fs, remote string, src fs.Object,
 	for stream := 0; stream < mc.streams; stream++ {
 		stream := stream
 		g.Go(func() (err error) {
-			return mc.copyStream(ctx, stream)
+			return mc.copyStream(gCtx, stream)
 		})
 	}
 	err = g.Wait()
diff --git a/fs/operations/operations.go b/fs/operations/operations.go
index 10c6e107d..107a8b2b8 100644
--- a/fs/operations/operations.go
+++ b/fs/operations/operations.go
@@ -513,13 +513,7 @@ func DeleteFileWithBackupDir(ctx context.Context, dst fs.Object, backupDir fs.Fs
 	if fs.Config.DryRun {
 		fs.Logf(dst, "Not %s as --dry-run", actioning)
 	} else if backupDir != nil {
-		if !SameConfig(dst.Fs(), backupDir) {
-			err = errors.New("parameter to --backup-dir has to be on the same remote as destination")
-		} else {
-			remoteWithSuffix := SuffixName(dst.Remote())
-			overwritten, _ := backupDir.NewObject(ctx, remoteWithSuffix)
-			_, err = Move(ctx, backupDir, overwritten, remoteWithSuffix, dst)
-		}
+		err = MoveBackupDir(ctx, backupDir, dst)
 	} else {
 		err = dst.Remove(ctx)
 	}
@@ -601,25 +595,41 @@ func Same(fdst, fsrc fs.Info) bool {
 	return SameConfig(fdst, fsrc) && strings.Trim(fdst.Root(), "/") == strings.Trim(fsrc.Root(), "/")
 }
 
+// fixRoot returns the Root with a trailing / if not empty. It is
+// aware of case insensitive filesystems.
+func fixRoot(f fs.Info) string {
+	s := strings.Trim(filepath.ToSlash(f.Root()), "/")
+	if s != "" {
+		s += "/"
+	}
+	if f.Features().CaseInsensitive {
+		s = strings.ToLower(s)
+	}
+	return s
+}
+
 // Overlapping returns true if fdst and fsrc point to the same
 // underlying Fs and they overlap.
 func Overlapping(fdst, fsrc fs.Info) bool {
 	if !SameConfig(fdst, fsrc) {
 		return false
 	}
-	// Return the Root with a trailing / if not empty
-	fixedRoot := func(f fs.Info) string {
-		s := strings.Trim(filepath.ToSlash(f.Root()), "/")
-		if s != "" {
-			s += "/"
-		}
-		return s
-	}
-	fdstRoot := fixedRoot(fdst)
-	fsrcRoot := fixedRoot(fsrc)
+	fdstRoot := fixRoot(fdst)
+	fsrcRoot := fixRoot(fsrc)
 	return strings.HasPrefix(fdstRoot, fsrcRoot) || strings.HasPrefix(fsrcRoot, fdstRoot)
 }
 
+// SameDir returns true if fdst and fsrc point to the same
+// underlying Fs and they are the same directory.
+func SameDir(fdst, fsrc fs.Info) bool {
+	if !SameConfig(fdst, fsrc) {
+		return false
+	}
+	fdstRoot := fixRoot(fdst)
+	fsrcRoot := fixRoot(fsrc)
+	return fdstRoot == fsrcRoot
+}
+
 // checkIdentical checks to see if dst and src are identical
 //
 // it returns true if differences were found
@@ -1465,6 +1475,54 @@ func CopyURL(ctx context.Context, fdst fs.Fs, dstFileName string, url string) (d
 	return RcatSize(ctx, fdst, dstFileName, resp.Body, resp.ContentLength, time.Now())
 }
 
+// BackupDir returns the correctly configured --backup-dir
+func BackupDir(fdst fs.Fs, fsrc fs.Fs, srcFileName string) (backupDir fs.Fs, err error) {
+	if fs.Config.BackupDir != "" {
+		backupDir, err = cache.Get(fs.Config.BackupDir)
+		if err != nil {
+			return nil, fserrors.FatalError(errors.Errorf("Failed to make fs for --backup-dir %q: %v", fs.Config.BackupDir, err))
+		}
+		if !SameConfig(fdst, backupDir) {
+			return nil, fserrors.FatalError(errors.New("parameter to --backup-dir has to be on the same remote as destination"))
+		}
+		if srcFileName == "" {
+			if Overlapping(fdst, backupDir) {
+				return nil, fserrors.FatalError(errors.New("destination and parameter to --backup-dir mustn't overlap"))
+			}
+			if Overlapping(fsrc, backupDir) {
+				return nil, fserrors.FatalError(errors.New("source and parameter to --backup-dir mustn't overlap"))
+			}
+		} else {
+			if fs.Config.Suffix == "" {
+				if SameDir(fdst, backupDir) {
+					return nil, fserrors.FatalError(errors.New("destination and parameter to --backup-dir mustn't be the same"))
+				}
+				if SameDir(fsrc, backupDir) {
+					return nil, fserrors.FatalError(errors.New("source and parameter to --backup-dir mustn't be the same"))
+				}
+			}
+		}
+	} else {
+		if srcFileName == "" {
+			return nil, fserrors.FatalError(errors.New("--suffix must be used with a file or with --backup-dir"))
+		}
+		// --backup-dir is not set but --suffix is - use the destination as the backupDir
+		backupDir = fdst
+	}
+	if !CanServerSideMove(backupDir) {
+		return nil, fserrors.FatalError(errors.New("can't use --backup-dir on a remote which doesn't support server side move or copy"))
+	}
+	return backupDir, nil
+}
+
+// MoveBackupDir moves a file to the backup dir
+func MoveBackupDir(ctx context.Context, backupDir fs.Fs, dst fs.Object) (err error) {
+	remoteWithSuffix := SuffixName(dst.Remote())
+	overwritten, _ := backupDir.NewObject(ctx, remoteWithSuffix)
+	_, err = Move(ctx, backupDir, overwritten, remoteWithSuffix, dst)
+	return err
+}
+
 // moveOrCopyFile moves or copies a single file possibly to a new name
 func moveOrCopyFile(ctx context.Context, fdst fs.Fs, fsrc fs.Fs, dstFileName string, srcFileName string, cp bool) (err error) {
 	dstFilePath := path.Join(fdst.Root(), dstFileName)
@@ -1521,14 +1579,12 @@ func moveOrCopyFile(ctx context.Context, fdst fs.Fs, fsrc fs.Fs, dstFileName str
 
 	if NeedTransfer(ctx, dstObj, srcObj) {
 		// If destination already exists, then we must move it into --backup-dir if required
-		if dstObj != nil && fs.Config.BackupDir != "" {
-			backupDir, err := cache.Get(fs.Config.BackupDir)
+		if dstObj != nil && (fs.Config.BackupDir != "" || fs.Config.Suffix != "") {
+			backupDir, err := BackupDir(fdst, fsrc, srcFileName)
 			if err != nil {
 				return errors.Wrap(err, "creating Fs for --backup-dir failed")
 			}
-			remoteWithSuffix := SuffixName(dstObj.Remote())
-			overwritten, _ := backupDir.NewObject(ctx, remoteWithSuffix)
-			_, err = Move(ctx, backupDir, overwritten, remoteWithSuffix, dstObj)
+			err = MoveBackupDir(ctx, backupDir, dstObj)
 			if err != nil {
 				return errors.Wrap(err, "moving to --backup-dir failed")
 			}
@@ -1762,18 +1818,18 @@ func DirMove(ctx context.Context, f fs.Fs, srcRemote, dstRemote string) (err err
 		newPath string
 	}
 	renames := make(chan rename, fs.Config.Transfers)
-	g, ctx := errgroup.WithContext(context.Background())
+	g, gCtx := errgroup.WithContext(context.Background())
 	for i := 0; i < fs.Config.Transfers; i++ {
 		g.Go(func() error {
 			for job := range renames {
-				dstOverwritten, _ := f.NewObject(ctx, job.newPath)
-				_, err := Move(ctx, f, dstOverwritten, job.newPath, job.o)
+				dstOverwritten, _ := f.NewObject(gCtx, job.newPath)
+				_, err := Move(gCtx, f, dstOverwritten, job.newPath, job.o)
 				if err != nil {
 					return err
 				}
 				select {
-				case <-ctx.Done():
-					return ctx.Err()
+				case <-gCtx.Done():
+					return gCtx.Err()
 				default:
 				}
 
diff --git a/fs/sync/sync.go b/fs/sync/sync.go
index c2ce37ae0..7e610712e 100644
--- a/fs/sync/sync.go
+++ b/fs/sync/sync.go
@@ -10,7 +10,6 @@ import (
 
 	"github.com/ncw/rclone/fs"
 	"github.com/ncw/rclone/fs/accounting"
-	"github.com/ncw/rclone/fs/cache"
 	"github.com/ncw/rclone/fs/filter"
 	"github.com/ncw/rclone/fs/fserrors"
 	"github.com/ncw/rclone/fs/hash"
@@ -62,7 +61,6 @@ type syncCopyMove struct {
 	trackRenamesCh chan fs.Object         // objects are pumped in here
 	renameCheck    []fs.Object            // accumulate files to check for rename here
 	backupDir      fs.Fs                  // place to store overwrites/deletes
-	suffix         string                 // suffix to add to files placed in backupDir
 }
 
 func newSyncCopyMove(ctx context.Context, fdst, fsrc fs.Fs, deleteMode fs.DeleteMode, DoMove bool, deleteEmptySrcDirs bool, copyEmptySrcDirs bool) (*syncCopyMove, error) {
@@ -122,25 +120,12 @@ func newSyncCopyMove(ctx context.Context, fdst, fsrc fs.Fs, deleteMode fs.Delete
 		}
 	}
 	// Make Fs for --backup-dir if required
-	if fs.Config.BackupDir != "" {
+	if fs.Config.BackupDir != "" || fs.Config.Suffix != "" {
 		var err error
-		s.backupDir, err = cache.Get(fs.Config.BackupDir)
+		s.backupDir, err = operations.BackupDir(fdst, fsrc, "")
 		if err != nil {
-			return nil, fserrors.FatalError(errors.Errorf("Failed to make fs for --backup-dir %q: %v", fs.Config.BackupDir, err))
+			return nil, err
 		}
-		if !operations.CanServerSideMove(s.backupDir) {
-			return nil, fserrors.FatalError(errors.New("can't use --backup-dir on a remote which doesn't support server side move or copy"))
-		}
-		if !operations.SameConfig(fdst, s.backupDir) {
-			return nil, fserrors.FatalError(errors.New("parameter to --backup-dir has to be on the same remote as destination"))
-		}
-		if operations.Overlapping(fdst, s.backupDir) {
-			return nil, fserrors.FatalError(errors.New("destination and parameter to --backup-dir mustn't overlap"))
-		}
-		if operations.Overlapping(fsrc, s.backupDir) {
-			return nil, fserrors.FatalError(errors.New("source and parameter to --backup-dir mustn't overlap"))
-		}
-		s.suffix = fs.Config.Suffix
 	}
 	return s, nil
 }
@@ -227,9 +212,7 @@ func (s *syncCopyMove) pairChecker(in *pipe, out *pipe, wg *sync.WaitGroup) {
 				} else {
 					// If destination already exists, then we must move it into --backup-dir if required
 					if pair.Dst != nil && s.backupDir != nil {
-						remoteWithSuffix := operations.SuffixName(pair.Dst.Remote())
-						overwritten, _ := s.backupDir.NewObject(s.ctx, remoteWithSuffix)
-						_, err := operations.Move(s.ctx, s.backupDir, overwritten, remoteWithSuffix, pair.Dst)
+						err := operations.MoveBackupDir(s.ctx, s.backupDir, pair.Dst)
 						if err != nil {
 							s.processError(err)
 						} else {


Test patch:
diff --git a/fs/sync/sync_test.go b/fs/sync/sync_test.go
index 231c2d24a..66fb62b14 100644
--- a/fs/sync/sync_test.go
+++ b/fs/sync/sync_test.go
@@ -1281,6 +1281,94 @@ func TestSyncBackupDir(t *testing.T)                        { testSyncBackupDir(
 func TestSyncBackupDirWithSuffix(t *testing.T)              { testSyncBackupDir(t, ".bak", false) }
 func TestSyncBackupDirWithSuffixKeepExtension(t *testing.T) { testSyncBackupDir(t, "-2019-01-01", true) }
 
+// Test with Suffix set
+func testSyncSuffix(t *testing.T, suffix string, suffixKeepExtension bool) {
+	r := fstest.NewRun(t)
+	defer r.Finalise()
+
+	if !operations.CanServerSideMove(r.Fremote) {
+		t.Skip("Skipping test as remote does not support server side move")
+	}
+	r.Mkdir(context.Background(), r.Fremote)
+
+	fs.Config.Suffix = suffix
+	fs.Config.SuffixKeepExtension = suffixKeepExtension
+	defer func() {
+		fs.Config.BackupDir = ""
+		fs.Config.Suffix = ""
+		fs.Config.SuffixKeepExtension = false
+	}()
+
+	// Make the setup so we have one, two, three in the dest
+	// and one (different), two (same) in the source
+	file1 := r.WriteObject(context.Background(), "dst/one", "one", t1)
+	file2 := r.WriteObject(context.Background(), "dst/two", "two", t1)
+	file3 := r.WriteObject(context.Background(), "dst/three.txt", "three", t1)
+	file2a := r.WriteFile("two", "two", t1)
+	file1a := r.WriteFile("one", "oneA", t2)
+	file3a := r.WriteFile("three.txt", "threeA", t1)
+
+	fstest.CheckItems(t, r.Fremote, file1, file2, file3)
+	fstest.CheckItems(t, r.Flocal, file1a, file2a, file3a)
+
+	fdst, err := fs.NewFs(r.FremoteName + "/dst")
+	require.NoError(t, err)
+
+	accounting.Stats.ResetCounters()
+	err = operations.CopyFile(context.Background(), fdst, r.Flocal, "one", "one")
+	require.NoError(t, err)
+	err = operations.CopyFile(context.Background(), fdst, r.Flocal, "two", "two")
+	require.NoError(t, err)
+	err = operations.CopyFile(context.Background(), fdst, r.Flocal, "three.txt", "three.txt")
+	require.NoError(t, err)
+
+	// one should be moved to the backup dir and the new one installed
+	file1.Path = "dst/one" + suffix
+	file1a.Path = "dst/one"
+	// two should be unchanged
+	// three should be moved to the backup dir
+	if suffixKeepExtension {
+		file3.Path = "dst/three" + suffix + ".txt"
+	} else {
+		file3.Path = "dst/three.txt" + suffix
+	}
+	file3a.Path = "dst/three.txt"
+
+	fstest.CheckItems(t, r.Fremote, file1, file2, file3, file1a, file3a)
+
+	// Now check what happens if we do it again
+	// Restore a different three and update one in the source
+	file3b := r.WriteFile("three.txt", "threeB", t3)
+	file1b := r.WriteFile("one", "oneBB", t3)
+	fstest.CheckItems(t, r.Fremote, file1, file2, file3, file1a, file3a)
+
+	// This should delete three and overwrite one again, checking
+	// the files got overwritten correctly in backup-dir
+	accounting.Stats.ResetCounters()
+	err = operations.CopyFile(context.Background(), fdst, r.Flocal, "one", "one")
+	require.NoError(t, err)
+	err = operations.CopyFile(context.Background(), fdst, r.Flocal, "two", "two")
+	require.NoError(t, err)
+	err = operations.CopyFile(context.Background(), fdst, r.Flocal, "three.txt", "three.txt")
+	require.NoError(t, err)
+
+	// one should be moved to the backup dir and the new one installed
+	file1a.Path = "dst/one" + suffix
+	file1b.Path = "dst/one"
+	// two should be unchanged
+	// three should be moved to the backup dir
+	if suffixKeepExtension {
+		file3a.Path = "dst/three" + suffix + ".txt"
+	} else {
+		file3a.Path = "dst/three.txt" + suffix
+	}
+	file3b.Path = "dst/three.txt"
+
+	fstest.CheckItems(t, r.Fremote, file1b, file3b, file2, file3a, file1a)
+}
+func TestSyncSuffix(t *testing.T)              { testSyncSuffix(t, ".bak", false) }
+func TestSyncSuffixKeepExtension(t *testing.T) { testSyncSuffix(t, "-2019-01-01", true) }
+
 // Check we can sync two files with differing UTF-8 representations
 func TestSyncUTFNorm(t *testing.T) {
 	if runtime.GOOS == "darwin" {