Repository: rclone/rclone
Pull request #5590 (2021-09-09 19:58:02)

ftp: fix deadlock after failed update when concurrency=1
#### What is the purpose of this change?

Our users complained many times that rclone FTP client manifests random problems with completing a file upload.

As my investigations have shown, the bug triggers because [jlaffaye/ftp does not properly nudge](https://github.com/jlaffaye/ftp/blob/5d41901190670f202a4aafb39c81d72e2e42bd8d/ftp.go#L721) the [fshttp anti-hanging timer](https://github.com/rclone/rclone/blob/v1.56.0/fs/fshttp/dialer.go#L85) on command channel after a file upload. I have a [working fix](https://github.com/ivandeex/rclone/releases) for [jlaffaye/ftp](https://github.com/ivandeex/ftp/commit/08390f8cbaa60e98bb1687989752e31feaf8b416) and [backend/ftp](https://github.com/ivandeex/rclone/commit/f70e98d1595feadf6d52ade6e9565aec3ed77aa3) which I will submit **later**.

This pull request solves a second-stage bug triggered by the above bug which can be fixed **solely** in rclone. Frequently when users reported the above bug we recommended them to simplify their FTP client settings, in particular set `--ftp-concurrency=1`. When the FTP backend's [upload routine](https://github.com/rclone/rclone/blob/v1.56.0/backend/ftp/ftp.go#L1063) encounters a error, it will remove the failed file. The removal needs another connection but due to low concurrency setting it will wait for [connection token](https://github.com/rclone/rclone/blob/v1.56.0/backend/ftp/ftp.go#L317) forever.

This patch modifies the upload routine so that after error it will _first_ release the failed connection and _then_ remove the failed file and thus avoid the deadlock.
Due to presence of another bug I will include a [relevant unit test](https://github.com/ivandeex/rclone/commit/f70e98d1595feadf6d52ade6e9565aec3ed77aa3#diff-67452ac6849fa36379c4aa76d2d528fd2dbe411ac03493522a63e59ea03a8c40R84) with a dedicated pull request #5596, not here. I just hope that the change looks self-evident. @ncw ??

#### Was the change discussed in an issue or in the forum before?

- https://forum.rclone.org/t/rclone-copy-to-ftp-rclone-sends-a-quit-and-never-ends-itself/24046/10
- #5596 

#### Checklist

- [x] I have read the [contribution guidelines](https://github.com/rclone/rclone/blob/master/CONTRIBUTING.md#submitting-a-pull-request).
- [x] I have added tests for all changes in this PR if appropriate (see **manual test** below)
- [ ] I have added documentation for the changes if appropriate.
- [x] All commit messages are in [house style](https://github.com/rclone/rclone/blob/master/CONTRIBUTING.md#commit-messages).
- [x] I'm done, this Pull Request is ready for review :-)


ivandeex: @ncw 
This request is ready for review
Can we have it in 1.57 please?
ivandeex: @ncw 
I would also recommend to increase average planned time between releases to 3-4 months.
2 months between 1.56 and 1.57 was too short a period for anything but simple bugfixes.

Patch:
diff --git a/backend/crypt/crypt.go b/backend/crypt/crypt.go
index 6d186d13d..14d7915cb 100644
--- a/backend/crypt/crypt.go
+++ b/backend/crypt/crypt.go
@@ -39,7 +39,7 @@ func init() {
 			Examples: []fs.OptionExample{
 				{
 					Value: "standard",
-					Help:  "Encrypt the filenames see the docs for the details.",
+					Help:  "Encrypt the filenames. See the docs for the details.",
 				}, {
 					Value: "obfuscate",
 					Help:  "Very simple filename obfuscation.",
diff --git a/backend/ftp/ftp.go b/backend/ftp/ftp.go
index 28db54430..b5cfe2f24 100644
--- a/backend/ftp/ftp.go
+++ b/backend/ftp/ftp.go
@@ -1062,8 +1062,9 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
 	}
 	if err != nil {
 		_ = c.Quit() // toss this connection to avoid sync errors
-		remove()
+		// recycle connection in advance to let remove() find free token
 		o.fs.putFtpConnection(nil, err)
+		remove()
 		return errors.Wrap(err, "update stor")
 	}
 	o.fs.putFtpConnection(&c, nil)
diff --git a/backend/jottacloud/jottacloud.go b/backend/jottacloud/jottacloud.go
index 679208486..5c6b802fd 100644
--- a/backend/jottacloud/jottacloud.go
+++ b/backend/jottacloud/jottacloud.go
@@ -807,8 +807,10 @@ func (f *Fs) newObjectWithInfo(ctx context.Context, remote string, info *api.Jot
 	}
 	var err error
 	if info != nil {
-		// Set info
-		err = o.setMetaData(info)
+		if !f.validFile(info) {
+			return nil, fs.ErrorObjectNotFound
+		}
+		err = o.setMetaData(info) // sets the info
 	} else {
 		err = o.readMetaData(ctx, false) // reads info and meta, returning an error
 	}
@@ -880,37 +882,27 @@ func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err e
 		return nil, errors.Wrap(err, "couldn't list files")
 	}
 
-	if bool(result.Deleted) && !f.opt.TrashedOnly {
+	if !f.validFolder(&result) {
 		return nil, fs.ErrorDirNotFound
 	}
 
 	for i := range result.Folders {
 		item := &result.Folders[i]
-		if !f.opt.TrashedOnly && bool(item.Deleted) {
-			continue
+		if f.validFolder(item) {
+			remote := path.Join(dir, f.opt.Enc.ToStandardName(item.Name))
+			d := fs.NewDir(remote, time.Time(item.ModifiedAt))
+			entries = append(entries, d)
 		}
-		remote := path.Join(dir, f.opt.Enc.ToStandardName(item.Name))
-		d := fs.NewDir(remote, time.Time(item.ModifiedAt))
-		entries = append(entries, d)
 	}
 
 	for i := range result.Files {
 		item := &result.Files[i]
-		if f.opt.TrashedOnly {
-			if !item.Deleted || item.State != "COMPLETED" {
-				continue
-			}
-		} else {
-			if item.Deleted || item.State != "COMPLETED" {
-				continue
+		if f.validFile(item) {
+			remote := path.Join(dir, f.opt.Enc.ToStandardName(item.Name))
+			if o, err := f.newObjectWithInfo(ctx, remote, item); err == nil {
+				entries = append(entries, o)
 			}
 		}
-		remote := path.Join(dir, f.opt.Enc.ToStandardName(item.Name))
-		o, err := f.newObjectWithInfo(ctx, remote, item)
-		if err != nil {
-			continue
-		}
-		entries = append(entries, o)
 	}
 	return entries, nil
 }
@@ -927,7 +919,7 @@ func (f *Fs) listFileDir(ctx context.Context, remoteStartPath string, startFolde
 	startPathLength := len(startPath)
 	for i := range startFolder.Folders {
 		folder := &startFolder.Folders[i]
-		if folder.Deleted {
+		if !f.validFolder(folder) {
 			return nil
 		}
 		folderPath := f.opt.Enc.ToStandardPath(path.Join(folder.Path, folder.Name))
@@ -945,17 +937,16 @@ func (f *Fs) listFileDir(ctx context.Context, remoteStartPath string, startFolde
 		}
 		for i := range folder.Files {
 			file := &folder.Files[i]
-			if file.Deleted || file.State != "COMPLETED" {
-				continue
-			}
-			remoteFile := path.Join(remoteDir, f.opt.Enc.ToStandardName(file.Name))
-			o, err := f.newObjectWithInfo(ctx, remoteFile, file)
-			if err != nil {
-				return err
-			}
-			err = fn(o)
-			if err != nil {
-				return err
+			if f.validFile(file) {
+				remoteFile := path.Join(remoteDir, f.opt.Enc.ToStandardName(file.Name))
+				o, err := f.newObjectWithInfo(ctx, remoteFile, file)
+				if err != nil {
+					return err
+				}
+				err = fn(o)
+				if err != nil {
+					return err
+				}
 			}
 		}
 	}
@@ -1366,6 +1357,25 @@ func (o *Object) MimeType(ctx context.Context) string {
 	return o.mimeType
 }
 
+// validFile checks if info indicates file is valid
+func (f *Fs) validFile(info *api.JottaFile) bool {
+	if info.State != "COMPLETED" {
+		return false // File is incomplete or corrupt
+	}
+	if !info.Deleted {
+		return !f.opt.TrashedOnly // Regular file; return false if TrashedOnly, else true
+	}
+	return f.opt.TrashedOnly // Deleted file; return true if TrashedOnly, else false
+}
+
+// validFolder checks if info indicates folder is valid
+func (f *Fs) validFolder(info *api.JottaFolder) bool {
+	// Returns true if folder is not deleted.
+	// If TrashedOnly option then always returns true, because a folder not
+	// in trash must be traversed to get to files/subfolders that are.
+	return !bool(info.Deleted) || f.opt.TrashedOnly
+}
+
 // setMetaData sets the metadata from info
 func (o *Object) setMetaData(info *api.JottaFile) (err error) {
 	o.hasMetaData = true
@@ -1385,7 +1395,7 @@ func (o *Object) readMetaData(ctx context.Context, force bool) (err error) {
 	if err != nil {
 		return err
 	}
-	if bool(info.Deleted) && !o.fs.opt.TrashedOnly {
+	if !o.fs.validFile(info) {
 		return fs.ErrorObjectNotFound
 	}
 	return o.setMetaData(info)
@@ -1406,7 +1416,50 @@ func (o *Object) ModTime(ctx context.Context) time.Time {
 
 // SetModTime sets the modification time of the local fs object
 func (o *Object) SetModTime(ctx context.Context, modTime time.Time) error {
-	return fs.ErrorCantSetModTime
+	// make sure metadata is available, we need its current size and md5
+	err := o.readMetaData(ctx, false)
+	if err != nil {
+		fs.Logf(o, "Failed to read metadata: %v", err)
+		return err
+	}
+
+	// prepare allocate request with existing metadata but changed timestamps
+	var resp *http.Response
+	var options []fs.OpenOption
+	opts := rest.Opts{
+		Method:       "POST",
+		Path:         "files/v1/allocate",
+		Options:      options,
+		ExtraHeaders: make(map[string]string),
+	}
+	fileDate := api.Time(modTime).APIString()
+	var request = api.AllocateFileRequest{
+		Bytes:    o.size,
+		Created:  fileDate,
+		Modified: fileDate,
+		Md5:      o.md5,
+		Path:     path.Join(o.fs.opt.Mountpoint, o.fs.opt.Enc.FromStandardPath(path.Join(o.fs.root, o.remote))),
+	}
+
+	// send it
+	var response api.AllocateFileResponse
+	err = o.fs.pacer.Call(func() (bool, error) {
+		resp, err = o.fs.apiSrv.CallJSON(ctx, &opts, &request, &response)
+		return shouldRetry(ctx, resp, err)
+	})
+	if err != nil {
+		return err
+	}
+
+	// check response
+	if response.State != "COMPLETED" {
+		// could be the file was modified (size/md5 changed) between readMetaData and the allocate request
+		return errors.New("metadata did not match")
+	}
+
+	// update local metadata
+	o.modTime = modTime
+	return nil
 }
 
 // Storable returns a boolean showing whether this object storable
diff --git a/backend/union/union.go b/backend/union/union.go
index 5009ee28c..966f8ccc1 100644
--- a/backend/union/union.go
+++ b/backend/union/union.go
@@ -254,7 +254,7 @@ func (f *Fs) Move(ctx context.Context, src fs.Object, remote string) (fs.Object,
 		return nil, err
 	}
 	for _, e := range entries {
-		if e.UpstreamFs().Features().Move == nil {
+		if !operations.CanServerSideMove(e.UpstreamFs()) {
 			return nil, fs.ErrorCantMove
 		}
 	}
@@ -277,12 +277,27 @@ func (f *Fs) Move(ctx context.Context, src fs.Object, remote string) (fs.Object,
 			errs[i] = errors.Wrap(fs.ErrorCantMove, su.Name()+":"+remote)
 			return
 		}
-		mo, err := du.Features().Move(ctx, o.UnWrap(), remote)
-		if err != nil || mo == nil {
+		srcObj := o.UnWrap()
+		duFeatures := du.Features()
+		do := duFeatures.Move
+		if duFeatures.Move == nil {
+			do = duFeatures.Copy
+		}
+		// Do the Move or Copy
+		dstObj, err := do(ctx, srcObj, remote)
+		if err != nil || dstObj == nil {
 			errs[i] = errors.Wrap(err, su.Name())
 			return
 		}
-		objs[i] = du.WrapObject(mo)
+		objs[i] = du.WrapObject(dstObj)
+		// Delete the source object if Copy
+		if duFeatures.Move == nil {
+			err = srcObj.Remove(ctx)
+			if err != nil {
+				errs[i] = errors.Wrap(err, su.Name())
+				return
+			}
+		}
 	})
 	var en []upstream.Entry
 	for _, o := range objs {
@@ -848,8 +863,16 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
 		SetTier:                 true,
 		GetTier:                 true,
 	}).Fill(ctx, f)
+	canMove := true
 	for _, f := range upstreams {
 		features = features.Mask(ctx, f) // Mask all upstream fs
+		if !operations.CanServerSideMove(f) {
+			canMove = false
+		}
+	}
+	// We can move if all remotes support Move or Copy
+	if canMove {
+		features.Move = f.Move
 	}
 
 	// Enable ListR when upstreams either support ListR or is local
diff --git a/docs/content/authors.md b/docs/content/authors.md
index 5172d030f..a04a29b7e 100644
--- a/docs/content/authors.md
+++ b/docs/content/authors.md
@@ -3,6 +3,8 @@ title: "Authors"
 description: "Rclone Authors and Contributors"
 ---
 
+# Authors and contributors
+
 Authors
 -------
 
@@ -522,3 +524,4 @@ put them back in again.` >}}
   * Parth Shukla <pparth@pparth.net>
   * wzl <wangzl31@outlook.com>
   * HNGamingUK <connor@earnshawhome.co.uk>
+  * Jonta <359397+Jonta@users.noreply.github.com>
diff --git a/docs/content/crypt.md b/docs/content/crypt.md
index 4afb08a7d..1ccfe70cb 100644
--- a/docs/content/crypt.md
+++ b/docs/content/crypt.md
@@ -110,7 +110,7 @@ remote> remote:path
 How to encrypt the filenames.
 Enter a string value. Press Enter for the default ("standard").
 Choose a number from below, or type in your own value
- 1 / Encrypt the filenames see the docs for the details.
+ 1 / Encrypt the filenames. See the docs for the details.
    \ "standard"
  2 / Very simple filename obfuscation.
    \ "obfuscate"
diff --git a/docs/content/docs.md b/docs/content/docs.md
index 4d5a4dab6..e1823815f 100644
--- a/docs/content/docs.md
+++ b/docs/content/docs.md
@@ -3,6 +3,15 @@ title: "Documentation"
 description: "Rclone Usage"
 ---
 
+# Usage
+
+Rclone is a command line program to manage files on cloud storage.
+After [download](/downloads/) and [install](/install), continue
+here to learn how to use it: Initial [configuration](#configure),
+what the [basic syntax](#basic-syntax) looks like, describes the
+various [subcommands](#subcommands), the various [options](#options),
+and more.
+
 Configure
 ---------
 
@@ -62,7 +71,7 @@ See the following for detailed instructions for
   * [Zoho WorkDrive](/zoho/)
   * [The local filesystem](/local/)
 
-Usage
+Basic syntax
 -----
 
 Rclone syncs a directory tree from one storage system to another.
diff --git a/docs/content/downloads.md b/docs/content/downloads.md
index 8828a42e9..7db0bc4e7 100644
--- a/docs/content/downloads.md
+++ b/docs/content/downloads.md
@@ -4,8 +4,13 @@ description: "Download rclone binaries for your OS."
 type: page
 ---
 
-Rclone Download {{< version >}}
-=====================
+# Downloads
+
+Rclone is single executable (`rclone`, or `rclone.exe` on Windows) that you can
+simply download as a zip archive and extract into a location of your choosing.
+See the [install](https://rclone.org/install/) documentation for more details.
+
+## Release {{< version >}}
 
 | Arch-OS | Windows | macOS | Linux | .deb | .rpm | FreeBSD | NetBSD | OpenBSD | Plan9 | Solaris |
 |:-------:|:-------:|:-----:|:-----:|:----:|:----:|:-------:|:------:|:-------:|:-----:|:-------:|
@@ -32,8 +37,7 @@ For beta installation, run:
 Note that this script checks the version of rclone installed first and
 won't re-download if not needed.
 
-Beta releases
-=============
+## Beta releases
 
 [Beta releases](https://beta.rclone.org) are generated from each commit
 to master.  Note these are named like
@@ -70,8 +74,7 @@ Note that [rclone.org](https://rclone.org/) is only updated on
 releases - to see the documentation for the latest beta go to
 [tip.rclone.org](https://tip.rclone.org/).
 
-Downloads for scripting
-=======================
+## Downloads for scripting
 
 If you would like to download the current version (maybe from a
 script) from a URL which doesn't change then you can use these links.
@@ -86,7 +89,6 @@ script) from a URL which doesn't change then you can use these links.
 | MIPS - Big Endian | - | - | {{< cdownload linux mips >}} | {{< cdownload linux mips deb >}} | {{< cdownload linux mips rpm >}} | - | - | - | - | - |
 | MIPS - Little Endian | - | - | {{< cdownload linux mipsle >}} | {{< cdownload linux mipsle deb >}} | {{< cdownload linux mipsle rpm >}} | - | - | - | - | - |
 
-Older Downloads
-==============
+## Older Downloads
 
 Older downloads can be found [here](https://downloads.rclone.org/).
diff --git a/docs/content/faq.md b/docs/content/faq.md
index 8265dfe3a..43641a35e 100644
--- a/docs/content/faq.md
+++ b/docs/content/faq.md
@@ -3,8 +3,7 @@ title: "FAQ"
 description: "Rclone Frequently Asked Questions"
 ---
 
-Frequently Asked Questions
---------------------------
+# Frequently Asked Questions
 
 ### Do all cloud storage systems support all rclone commands ###
 
diff --git a/docs/content/install.md b/docs/content/install.md
index a40ce9280..a64676968 100644
--- a/docs/content/install.md
+++ b/docs/content/install.md
@@ -16,7 +16,7 @@ Rclone is a Go program and comes as a single binary file.
 
 See below for some expanded Linux / macOS instructions.
 
-See the [Usage section](/docs/#usage) of the docs for how to use rclone, or
+See the [usage](/docs/) docs for how to use rclone, or
 run `rclone -h`.
 
 Already installed rclone can be easily updated to the latest version
@@ -228,7 +228,7 @@ Instructions
           - rclone
 ```
 
-# Autostart #
+## Autostart
 
 After installing and configuring rclone, as described above, you are ready to use rclone
 as an interactive command line utility. If your goal is to perform *periodic* operations,
@@ -244,14 +244,14 @@ different operating systems.
 NOTE: Before setting up autorun it is highly recommended that you have tested your command
 manually from a Command Prompt first.
 
-## Autostart on Windows ##
+### Autostart on Windows
 
 The most relevant alternatives for autostart on Windows are:
 - Run at user log on using the Startup folder
 - Run at user log on, at system startup or at schedule using Task Scheduler
 - Run at system startup using Windows service
 
-### Running in background
+#### Running in background
 
 Rclone is a console application, so if not starting from an existing Command Prompt,
 e.g. when starting rclone.exe from a shortcut, it will open a Command Prompt window.
@@ -267,7 +267,7 @@ Example command to run a sync in background:
 c:\rclone\rclone.exe sync c:\files remote:/files --no-console --log-file c:\rclone\logs\sync_files.txt
 ```
 
-### User account
+#### User account
 
 As mentioned in the [mount](https://rclone.org/commands/rclone_move/) documentation,
 mounted drives created as Administrator are not visible to other accounts, not even the
@@ -286,7 +286,7 @@ the [PsExec](https://docs.microsoft.com/en-us/sysinternals/downloads/psexec)
 utility from Microsoft's Sysinternals suite, which takes option `-s` to
 execute commands as the `SYSTEM` user.
 
-### Start from Startup folder ###
+#### Start from Startup folder ###
 
 To quickly execute an rclone command you can simply create a standard
 Windows Explorer shortcut for the complete rclone command you want to run. If you
@@ -301,7 +301,7 @@ functionality to set it to run as different user, or to set conditions or
 actions on certain events. Setting up a scheduled task as described below
 will often give you better results.
 
-### Start from Task Scheduler ###
+#### Start from Task Scheduler ###
 
 Task Scheduler is an administrative tool built into Windows, and it can be used to
 configure rclone to be started automatically in a highly configurable way, e.g.
@@ -311,12 +311,12 @@ be available to all users it can run as the `SYSTEM` user.
 For technical information, see
 https://docs.microsoft.com/windows/win32/taskschd/task-scheduler-start-page.
 
-### Run as service ###
+#### Run as service ###
 
 For running rclone at system startup, you can create a Windows service that executes
 your rclone command, as an alternative to scheduled task configured to run at startup.
 
-#### Mount command built-in service integration ####
+##### Mount command built-in service integration ####
 
 For mount commands, Rclone has a built-in Windows service integration via the third party
 WinFsp library it uses. Registering as a regular Windows service easy, as you just have to
@@ -338,7 +338,7 @@ Windows standard methods for managing network drives. This is currently not
 officially supported by Rclone, but with WinFsp version 2019.3 B2 / v1.5B2 or later
 it should be possible through path rewriting as described [here](https://github.com/rclone/rclone/issues/3340).
 
-#### Third party service integration ####
+##### Third party service integration ####
 
 To Windows service running any rclone command, the excellent third party utility
 [NSSM](http://nssm.cc), the "Non-Sucking Service Manager", can be used.
@@ -358,9 +358,9 @@ settings, without having go through manual steps in a GUI. One thing to note is
 by default it does not restart the service on error, one have to explicit enable this
 in the configuration file (via the "onfailure" parameter).
 
-## Autostart on Linux
+### Autostart on Linux
 
-### Start as a service
+#### Start as a service
 
 To always run rclone in background, relevant for mount commands etc,
 you can use systemd to set up rclone as a system or user service. Running as a
@@ -368,6 +368,6 @@ system service ensures that it is run at startup even if the user it is running
 has no active session. Running rclone as a user service ensures that it only
 starts after the configured user has logged into the system.
 
-### Run periodically from cron
+#### Run periodically from cron
 
 To run a periodic command, such as a copy/sync, you can set up a cron job.
diff --git a/docs/content/licence.md b/docs/content/licence.md
index b84739210..a12d71de4 100644
--- a/docs/content/licence.md
+++ b/docs/content/licence.md
@@ -3,8 +3,7 @@ title: "Licence"
 description: "Rclone Licence"
 ---
 
-License
--------
+# License
 
 This is free software under the terms of MIT the license (check the
 COPYING file included with the source code).


Test patch:
diff --git a/backend/union/union_internal_test.go b/backend/union/union_internal_test.go
index db5367561..8171f593f 100644
--- a/backend/union/union_internal_test.go
+++ b/backend/union/union_internal_test.go
@@ -3,10 +3,15 @@ package union
 import (
 	"bytes"
 	"context"
+	"fmt"
+	"io/ioutil"
+	"os"
 	"testing"
 	"time"
 
+	"github.com/rclone/rclone/fs"
 	"github.com/rclone/rclone/fs/object"
+	"github.com/rclone/rclone/fs/operations"
 	"github.com/rclone/rclone/fstest"
 	"github.com/rclone/rclone/fstest/fstests"
 	"github.com/rclone/rclone/lib/random"
@@ -14,6 +19,22 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
+// MakeTestDirs makes directories in /tmp for testing
+func MakeTestDirs(t *testing.T, n int) (dirs []string, clean func()) {
+	for i := 1; i <= n; i++ {
+		dir, err := ioutil.TempDir("", fmt.Sprintf("rclone-union-test-%d", n))
+		require.NoError(t, err)
+		dirs = append(dirs, dir)
+	}
+	clean = func() {
+		for _, dir := range dirs {
+			err := os.RemoveAll(dir)
+			assert.NoError(t, err)
+		}
+	}
+	return dirs, clean
+}
+
 func (f *Fs) TestInternalReadOnly(t *testing.T) {
 	if f.name != "TestUnionRO" {
 		t.Skip("Only on RO union")
@@ -65,3 +86,83 @@ func (f *Fs) InternalTest(t *testing.T) {
 }
 
 var _ fstests.InternalTester = (*Fs)(nil)
+
+// This specifically tests a union of local which can Move but not
+// Copy and :memory: which can Copy but not Move to makes sure that
+// the resulting union can Move
+func TestMoveCopy(t *testing.T) {
+	if *fstest.RemoteName != "" {
+		t.Skip("Skipping as -remote set")
+	}
+	ctx := context.Background()
+	dirs, clean := MakeTestDirs(t, 1)
+	defer clean()
+	fsString := fmt.Sprintf(":union,upstreams='%s :memory:bucket':", dirs[0])
+	f, err := fs.NewFs(ctx, fsString)
+	require.NoError(t, err)
+
+	unionFs := f.(*Fs)
+	fLocal := unionFs.upstreams[0].Fs
+	fMemory := unionFs.upstreams[1].Fs
+
+	t.Run("Features", func(t *testing.T) {
+		assert.NotNil(t, f.Features().Move)
+		assert.Nil(t, f.Features().Copy)
+
+		// Check underlying are as we are expect
+		assert.NotNil(t, fLocal.Features().Move)
+		assert.Nil(t, fLocal.Features().Copy)
+		assert.Nil(t, fMemory.Features().Move)
+		assert.NotNil(t, fMemory.Features().Copy)
+	})
+
+	// Put a file onto the local fs
+	contentsLocal := random.String(50)
+	fileLocal := fstest.NewItem("local.txt", contentsLocal, time.Now())
+	_, _ = fstests.PutTestContents(ctx, t, fLocal, &fileLocal, contentsLocal, true)
+	objLocal, err := f.NewObject(ctx, fileLocal.Path)
+	require.NoError(t, err)
+
+	// Put a file onto the memory fs
+	contentsMemory := random.String(60)
+	fileMemory := fstest.NewItem("memory.txt", contentsMemory, time.Now())
+	_, _ = fstests.PutTestContents(ctx, t, fMemory, &fileMemory, contentsMemory, true)
+	objMemory, err := f.NewObject(ctx, fileMemory.Path)
+	require.NoError(t, err)
+
+	fstest.CheckListing(t, f, []fstest.Item{fileLocal, fileMemory})
+
+	t.Run("MoveLocal", func(t *testing.T) {
+		fileLocal.Path = "local-renamed.txt"
+		_, err := operations.Move(ctx, f, nil, fileLocal.Path, objLocal)
+		require.NoError(t, err)
+		fstest.CheckListing(t, f, []fstest.Item{fileLocal, fileMemory})
+
+		// Check can retrieve object from union
+		obj, err := f.NewObject(ctx, fileLocal.Path)
+		require.NoError(t, err)
+		assert.Equal(t, fileLocal.Size, obj.Size())
+
+		// Check can retrieve object from underlying
+		obj, err = fLocal.NewObject(ctx, fileLocal.Path)
+		require.NoError(t, err)
+		assert.Equal(t, fileLocal.Size, obj.Size())
+
+		t.Run("MoveMemory", func(t *testing.T) {
+			fileMemory.Path = "memory-renamed.txt"
+			_, err := operations.Move(ctx, f, nil, fileMemory.Path, objMemory)
+			require.NoError(t, err)
+			fstest.CheckListing(t, f, []fstest.Item{fileLocal, fileMemory})
+
+			// Check can retrieve object from union
+			obj, err := f.NewObject(ctx, fileMemory.Path)
+			require.NoError(t, err)
+			assert.Equal(t, fileMemory.Size, obj.Size())
+
+			// Check can retrieve object from underlying
+			obj, err = fMemory.NewObject(ctx, fileMemory.Path)
+			require.NoError(t, err)
+			assert.Equal(t, fileMemory.Size, obj.Size())
+		})
+	})
+}
diff --git a/backend/union/union_test.go b/backend/union/union_test.go
index 07b3bb230..c58936e9d 100644
--- a/backend/union/union_test.go
+++ b/backend/union/union_test.go
@@ -2,16 +2,13 @@
 package union_test
 
 import (
-	"fmt"
-	"io/ioutil"
-	"os"
 	"testing"
 
 	_ "github.com/rclone/rclone/backend/local"
+	_ "github.com/rclone/rclone/backend/memory"
+	"github.com/rclone/rclone/backend/union"
 	"github.com/rclone/rclone/fstest"
 	"github.com/rclone/rclone/fstest/fstests"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
 )
 
 // TestIntegration runs integration tests against the remote
@@ -26,26 +23,11 @@ func TestIntegration(t *testing.T) {
 	})
 }
 
-func makeTestDirs(t *testing.T, n int) (dirs []string, clean func()) {
-	for i := 1; i <= n; i++ {
-		dir, err := ioutil.TempDir("", fmt.Sprintf("rclone-union-test-%d", n))
-		require.NoError(t, err)
-		dirs = append(dirs, dir)
-	}
-	clean = func() {
-		for _, dir := range dirs {
-			err := os.RemoveAll(dir)
-			assert.NoError(t, err)
-		}
-	}
-	return dirs, clean
-}
-
 func TestStandard(t *testing.T) {
 	if *fstest.RemoteName != "" {
 		t.Skip("Skipping as -remote set")
 	}
-	dirs, clean := makeTestDirs(t, 3)
+	dirs, clean := union.MakeTestDirs(t, 3)
 	defer clean()
 	upstreams := dirs[0] + " " + dirs[1] + " " + dirs[2]
 	name := "TestUnion"
@@ -67,7 +49,7 @@ func TestRO(t *testing.T) {
 	if *fstest.RemoteName != "" {
 		t.Skip("Skipping as -remote set")
 	}
-	dirs, clean := makeTestDirs(t, 3)
+	dirs, clean := union.MakeTestDirs(t, 3)
 	defer clean()
 	upstreams := dirs[0] + " " + dirs[1] + ":ro " + dirs[2] + ":ro"
 	name := "TestUnionRO"
@@ -89,7 +71,7 @@ func TestNC(t *testing.T) {
 	if *fstest.RemoteName != "" {
 		t.Skip("Skipping as -remote set")
 	}
-	dirs, clean := makeTestDirs(t, 3)
+	dirs, clean := union.MakeTestDirs(t, 3)
 	defer clean()
 	upstreams := dirs[0] + " " + dirs[1] + ":nc " + dirs[2] + ":nc"
 	name := "TestUnionNC"
@@ -111,7 +93,7 @@ func TestPolicy1(t *testing.T) {
 	if *fstest.RemoteName != "" {
 		t.Skip("Skipping as -remote set")
 	}
-	dirs, clean := makeTestDirs(t, 3)
+	dirs, clean := union.MakeTestDirs(t, 3)
 	defer clean()
 	upstreams := dirs[0] + " " + dirs[1] + " " + dirs[2]
 	name := "TestUnionPolicy1"
@@ -133,7 +115,7 @@ func TestPolicy2(t *testing.T) {
 	if *fstest.RemoteName != "" {
 		t.Skip("Skipping as -remote set")
 	}
-	dirs, clean := makeTestDirs(t, 3)
+	dirs, clean := union.MakeTestDirs(t, 3)
 	defer clean()
 	upstreams := dirs[0] + " " + dirs[1] + " " + dirs[2]
 	name := "TestUnionPolicy2"
@@ -155,7 +137,7 @@ func TestPolicy3(t *testing.T) {
 	if *fstest.RemoteName != "" {
 		t.Skip("Skipping as -remote set")
 	}
-	dirs, clean := makeTestDirs(t, 3)
+	dirs, clean := union.MakeTestDirs(t, 3)
 	defer clean()
 	upstreams := dirs[0] + " " + dirs[1] + " " + dirs[2]
 	name := "TestUnionPolicy3"