Repository: rclone/rclone
Pull request #6963 (2023-04-20 20:37:19)

s3: add petabox.io to s3 providers list
<!--
Thank you very much for contributing code or documentation to rclone! Please
fill out the following questions to make it easier for us to review your
changes.

You do not need to check all the boxes below all at once, feel free to take
your time and add more commits. If you're done and ready for review, please
check the last box.
-->

#### What is the purpose of this change?
Add [Petabox.io](https://petabox.io) to S3 providers. I've ran rclone's integration tests successfully.
<!--
Describe the changes here
-->

#### Was the change discussed in an issue or in the forum before?
No.
<!--
Link issues and relevant forum posts here.
-->

#### Checklist

- [X] I have read the [contribution guidelines](https://github.com/rclone/rclone/blob/master/CONTRIBUTING.md#submitting-a-new-feature-or-bug-fix).
- [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/rclone/rclone/blob/master/CONTRIBUTING.md#commit-messages).
- [X] I'm done, this Pull Request is ready for review :-)



Patch:
diff --git a/README.md b/README.md
index 6ecaf766d..57ae36cc8 100644
--- a/README.md
+++ b/README.md
@@ -67,6 +67,7 @@ Rclone *("rsync for cloud storage")* is a command-line program to sync files and
   * Oracle Object Storage [:page_facing_up:](https://rclone.org/oracleobjectstorage/)
   * ownCloud [:page_facing_up:](https://rclone.org/webdav/#owncloud)
   * pCloud [:page_facing_up:](https://rclone.org/pcloud/)
+  * Petabox [:page_facing_up:](https://rclone.org/s3/#petabox)
   * PikPak [:page_facing_up:](https://rclone.org/pikpak/)
   * premiumize.me [:page_facing_up:](https://rclone.org/premiumizeme/)
   * put.io [:page_facing_up:](https://rclone.org/putio/)
diff --git a/backend/combine/combine.go b/backend/combine/combine.go
index f6763ee0d..2e1ad2a7b 100644
--- a/backend/combine/combine.go
+++ b/backend/combine/combine.go
@@ -916,7 +916,7 @@ func (o *Object) String() string {
 func (o *Object) Remote() string {
 	newPath, err := o.u.pathAdjustment.do(o.Object.String())
 	if err != nil {
-		fs.Errorf(o, "Bad object: %v", err)
+		fs.Errorf(o.Object, "Bad object: %v", err)
 		return err.Error()
 	}
 	return newPath
diff --git a/backend/crypt/cipher.go b/backend/crypt/cipher.go
index c6cdb47c6..d0f8a658e 100644
--- a/backend/crypt/cipher.go
+++ b/backend/crypt/cipher.go
@@ -804,7 +804,7 @@ func (c *Cipher) newDecrypter(rc io.ReadCloser) (*decrypter, error) {
 	if n < fileHeaderSize && err == io.EOF {
 		// This read from 0..fileHeaderSize-1 bytes
 		return nil, fh.finishAndClose(ErrorEncryptedFileTooShort)
-	} else if err != nil {
+	} else if err != io.EOF && err != nil {
 		return nil, fh.finishAndClose(err)
 	}
 	// check the magic
diff --git a/backend/drive/drive.go b/backend/drive/drive.go
index 5f1472ccf..b07602bb4 100644
--- a/backend/drive/drive.go
+++ b/backend/drive/drive.go
@@ -1512,6 +1512,9 @@ func (f *Fs) newObjectWithExportInfo(
 // NewObject finds the Object at remote.  If it can't be found
 // it returns the error fs.ErrorObjectNotFound.
 func (f *Fs) NewObject(ctx context.Context, remote string) (fs.Object, error) {
+	if strings.HasSuffix(remote, "/") {
+		return nil, fs.ErrorIsDir
+	}
 	info, extension, exportName, exportMimeType, isDocument, err := f.getRemoteInfoWithExport(ctx, remote)
 	if err != nil {
 		return nil, err
@@ -3881,7 +3884,7 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
 	if err != nil {
 		return err
 	}
-	newO, err := o.fs.newObjectWithInfo(ctx, src.Remote(), info)
+	newO, err := o.fs.newObjectWithInfo(ctx, o.remote, info)
 	if err != nil {
 		return err
 	}
diff --git a/backend/dropbox/dropbox.go b/backend/dropbox/dropbox.go
index d0eaabd2f..eaefaa4b1 100644
--- a/backend/dropbox/dropbox.go
+++ b/backend/dropbox/dropbox.go
@@ -260,8 +260,8 @@ uploaded.
 The default for this is 0 which means rclone will choose a sensible
 default based on the batch_mode in use.
 
-- batch_mode: async - default batch_timeout is 500ms
-- batch_mode: sync - default batch_timeout is 10s
+- batch_mode: async - default batch_timeout is 10s
+- batch_mode: sync - default batch_timeout is 500ms
 - batch_mode: off - not in use
 `,
 			Default:  fs.Duration(0),
@@ -719,7 +719,7 @@ func (f *Fs) listSharedFolders(ctx context.Context) (entries fs.DirEntries, err
 		}
 		for _, entry := range res.Entries {
 			leaf := f.opt.Enc.ToStandardName(entry.Name)
-			d := fs.NewDir(leaf, time.Now()).SetID(entry.SharedFolderId)
+			d := fs.NewDir(leaf, time.Time{}).SetID(entry.SharedFolderId)
 			entries = append(entries, d)
 			if err != nil {
 				return nil, err
@@ -906,7 +906,7 @@ func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err e
 			leaf := f.opt.Enc.ToStandardName(path.Base(entryPath))
 			remote := path.Join(dir, leaf)
 			if folderInfo != nil {
-				d := fs.NewDir(remote, time.Now()).SetID(folderInfo.Id)
+				d := fs.NewDir(remote, time.Time{}).SetID(folderInfo.Id)
 				entries = append(entries, d)
 			} else if fileInfo != nil {
 				o, err := f.newObjectWithInfo(ctx, remote, fileInfo)
diff --git a/backend/ftp/ftp.go b/backend/ftp/ftp.go
index 2491e10d2..7aa9935c5 100644
--- a/backend/ftp/ftp.go
+++ b/backend/ftp/ftp.go
@@ -1098,7 +1098,7 @@ func (o *Object) ModTime(ctx context.Context) time.Time {
 // SetModTime sets the modification time of the object
 func (o *Object) SetModTime(ctx context.Context, modTime time.Time) error {
 	if !o.fs.fSetTime {
-		fs.Errorf(o.fs, "SetModTime is not supported")
+		fs.Debugf(o.fs, "SetModTime is not supported")
 		return nil
 	}
 	c, err := o.fs.getFtpConnection(ctx)
diff --git a/backend/googlecloudstorage/googlecloudstorage.go b/backend/googlecloudstorage/googlecloudstorage.go
index 753cf04b2..e3b9586cb 100644
--- a/backend/googlecloudstorage/googlecloudstorage.go
+++ b/backend/googlecloudstorage/googlecloudstorage.go
@@ -301,6 +301,15 @@ Docs: https://cloud.google.com/storage/docs/bucket-policy-only
 				Value: "DURABLE_REDUCED_AVAILABILITY",
 				Help:  "Durable reduced availability storage class",
 			}},
+		}, {
+			Name:     "directory_markers",
+			Default:  false,
+			Advanced: true,
+			Help: `Upload an empty object with a trailing slash when a new directory is created
+
+Empty folders are unsupported for bucket based remotes, this option creates an empty
+object ending with "/", to persist the folder.
+`,
 		}, {
 			Name: "no_check_bucket",
 			Help: `If set, don't attempt to check the bucket exists or create it.
@@ -366,6 +375,7 @@ type Options struct {
 	Endpoint                  string               `config:"endpoint"`
 	Enc                       encoder.MultiEncoder `config:"encoding"`
 	EnvAuth                   bool                 `config:"env_auth"`
+	DirectoryMarkers          bool                 `config:"directory_markers"`
 }
 
 // Fs represents a remote storage server
@@ -461,7 +471,7 @@ func parsePath(path string) (root string) {
 // split returns bucket and bucketPath from the rootRelativePath
 // relative to f.root
 func (f *Fs) split(rootRelativePath string) (bucketName, bucketPath string) {
-	bucketName, bucketPath = bucket.Split(path.Join(f.root, rootRelativePath))
+	bucketName, bucketPath = bucket.Split(bucket.Join(f.root, rootRelativePath))
 	return f.opt.Enc.FromStandardName(bucketName), f.opt.Enc.FromStandardPath(bucketPath)
 }
 
@@ -547,6 +557,9 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
 		BucketBased:       true,
 		BucketBasedRootOK: true,
 	}).Fill(ctx, f)
+	if opt.DirectoryMarkers {
+		f.features.CanHaveEmptyDirectories = true
+	}
 
 	// Create a new authorized Drive client.
 	f.client = oAuthClient
@@ -633,6 +646,7 @@ func (f *Fs) list(ctx context.Context, bucket, directory, prefix string, addBuck
 	if !recurse {
 		list = list.Delimiter("/")
 	}
+	foundItems := 0
 	for {
 		var objects *storage.Objects
 		err = f.pacer.Call(func() (bool, error) {
@@ -648,6 +662,7 @@ func (f *Fs) list(ctx context.Context, bucket, directory, prefix string, addBuck
 			return err
 		}
 		if !recurse {
+			foundItems += len(objects.Prefixes)
 			var object storage.Object
 			for _, remote := range objects.Prefixes {
 				if !strings.HasSuffix(remote, "/") {
@@ -668,6 +683,7 @@ func (f *Fs) list(ctx context.Context, bucket, directory, prefix string, addBuck
 				}
 			}
 		}
+		foundItems += len(objects.Items)
 		for _, object := range objects.Items {
 			remote := f.opt.Enc.ToStandardPath(object.Name)
 			if !strings.HasPrefix(remote, prefix) {
@@ -681,9 +697,15 @@ func (f *Fs) list(ctx context.Context, bucket, directory, prefix string, addBuck
 			}
 			// is this a directory marker?
 			if isDirectory {
-				continue // skip directory marker
+				// Don't insert the root directory
+				if remote == directory {
+					continue
+				}
+				// process directory markers as directories
+				remote = strings.TrimRight(remote, "/")
 			}
-			err = fn(remote, object, false)
+
+			err = fn(remote, object, isDirectory)
 			if err != nil {
 				return err
 			}
@@ -693,6 +715,17 @@ func (f *Fs) list(ctx context.Context, bucket, directory, prefix string, addBuck
 		}
 		list.PageToken(objects.NextPageToken)
 	}
+	if f.opt.DirectoryMarkers && foundItems == 0 && directory != "" {
+		// Determine whether the directory exists or not by whether it has a marker
+		_, err := f.readObjectInfo(ctx, bucket, directory)
+		if err != nil {
+			if err == fs.ErrorObjectNotFound {
+				return fs.ErrorDirNotFound
+			}
+			return err
+		}
+	}
+
 	return nil
 }
 
@@ -856,10 +889,68 @@ func (f *Fs) PutStream(ctx context.Context, in io.Reader, src fs.ObjectInfo, opt
 	return f.Put(ctx, in, src, options...)
 }
 
+// Create directory marker file and parents
+func (f *Fs) createDirectoryMarker(ctx context.Context, bucket, dir string) error {
+	if !f.opt.DirectoryMarkers || bucket == "" {
+		return nil
+	}
+
+	// Object to be uploaded
+	o := &Object{
+		fs: f,
+	}
+
+	for {
+		_, bucketPath := f.split(dir)
+		// Don't create the directory marker if it is the bucket or at the very root
+		if bucketPath == "" {
+			break
+		}
+		o.remote = dir + "/"
+
+		// Check to see if object already exists
+		_, err := o.readObjectInfo(ctx)
+		if err == nil {
+			return nil
+		}
+
+		// Upload it if not
+		fs.Debugf(o, "Creating directory marker")
+		content := io.Reader(strings.NewReader(""))
+		_, err = f.Put(ctx, content, o)
+		if err != nil {
+			return fmt.Errorf("creating directory marker failed: %w", err)
+		}
+
+		// Now check parent directory exists
+		dir = path.Dir(dir)
+		if dir == "/" || dir == "." {
+			break
+		}
+	}
+
+	return nil
+}
+
 // Mkdir creates the bucket if it doesn't exist
 func (f *Fs) Mkdir(ctx context.Context, dir string) (err error) {
 	bucket, _ := f.split(dir)
-	return f.makeBucket(ctx, bucket)
+	e := f.checkBucket(ctx, bucket)
+	if e != nil {
+		return e
+	}
+	return f.createDirectoryMarker(ctx, bucket, dir)
+
+}
+
+// mkdirParent creates the parent bucket/directory if it doesn't exist
+func (f *Fs) mkdirParent(ctx context.Context, remote string) error {
+	remote = strings.TrimRight(remote, "/")
+	dir := path.Dir(remote)
+	if dir == "/" || dir == "." {
+		dir = ""
+	}
+	return f.Mkdir(ctx, dir)
 }
 
 // makeBucket creates the bucket if it doesn't exist
@@ -931,6 +1022,18 @@ func (f *Fs) checkBucket(ctx context.Context, bucket string) error {
 // to delete was not empty.
 func (f *Fs) Rmdir(ctx context.Context, dir string) (err error) {
 	bucket, directory := f.split(dir)
+	// Remove directory marker file
+	if f.opt.DirectoryMarkers && bucket != "" && dir != "" {
+		o := &Object{
+			fs:     f,
+			remote: dir + "/",
+		}
+		fs.Debugf(o, "Removing directory marker")
+		err := o.Remove(ctx)
+		if err != nil {
+			return fmt.Errorf("removing directory marker failed: %w", err)
+		}
+	}
 	if bucket == "" || directory != "" {
 		return nil
 	}
@@ -962,7 +1065,7 @@ func (f *Fs) Precision() time.Duration {
 // If it isn't possible then return fs.ErrorCantCopy
 func (f *Fs) Copy(ctx context.Context, src fs.Object, remote string) (fs.Object, error) {
 	dstBucket, dstPath := f.split(remote)
-	err := f.checkBucket(ctx, dstBucket)
+	err := f.mkdirParent(ctx, remote)
 	if err != nil {
 		return nil, err
 	}
@@ -1100,10 +1203,15 @@ func (o *Object) setMetaData(info *storage.Object) {
 // readObjectInfo reads the definition for an object
 func (o *Object) readObjectInfo(ctx context.Context) (object *storage.Object, err error) {
 	bucket, bucketPath := o.split()
-	err = o.fs.pacer.Call(func() (bool, error) {
-		get := o.fs.svc.Objects.Get(bucket, bucketPath).Context(ctx)
-		if o.fs.opt.UserProject != "" {
-			get = get.UserProject(o.fs.opt.UserProject)
+	return o.fs.readObjectInfo(ctx, bucket, bucketPath)
+}
+
+// readObjectInfo reads the definition for an object
+func (f *Fs) readObjectInfo(ctx context.Context, bucket, bucketPath string) (object *storage.Object, err error) {
+	err = f.pacer.Call(func() (bool, error) {
+		get := f.svc.Objects.Get(bucket, bucketPath).Context(ctx)
+		if f.opt.UserProject != "" {
+			get = get.UserProject(f.opt.UserProject)
 		}
 		object, err = get.Do()
 		return shouldRetry(ctx, err)
@@ -1244,11 +1352,14 @@ func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (in io.Read
 // Update the object with the contents of the io.Reader, modTime and size
 //
 // The new object may have been created if an error is returned
-func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) error {
+func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (err error) {
 	bucket, bucketPath := o.split()
-	err := o.fs.checkBucket(ctx, bucket)
-	if err != nil {
-		return err
+	// Create parent dir/bucket if not saving directory marker
+	if !strings.HasSuffix(o.remote, "/") {
+		err = o.fs.mkdirParent(ctx, o.remote)
+		if err != nil {
+			return err
+		}
 	}
 	modTime := src.ModTime(ctx)
 
diff --git a/backend/http/http.go b/backend/http/http.go
index 518ea411c..5c1f4832c 100644
--- a/backend/http/http.go
+++ b/backend/http/http.go
@@ -495,7 +495,7 @@ func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err e
 					add(file)
 				case fs.ErrorNotAFile:
 					// ...found a directory not a file
-					add(fs.NewDir(remote, timeUnset))
+					add(fs.NewDir(remote, time.Time{}))
 				default:
 					fs.Debugf(remote, "skipping because of error: %v", err)
 				}
@@ -507,7 +507,7 @@ func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err e
 		name = strings.TrimRight(name, "/")
 		remote := path.Join(dir, name)
 		if isDir {
-			add(fs.NewDir(remote, timeUnset))
+			add(fs.NewDir(remote, time.Time{}))
 		} else {
 			in <- remote
 		}
diff --git a/backend/koofr/koofr.go b/backend/koofr/koofr.go
index 54513f0c1..7b2dfefe2 100644
--- a/backend/koofr/koofr.go
+++ b/backend/koofr/koofr.go
@@ -376,7 +376,7 @@ func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err e
 	for i, file := range files {
 		remote := path.Join(dir, f.opt.Enc.ToStandardName(file.Name))
 		if file.Type == "dir" {
-			entries[i] = fs.NewDir(remote, time.Unix(0, 0))
+			entries[i] = fs.NewDir(remote, time.Time{})
 		} else {
 			entries[i] = &Object{
 				fs:     f,
diff --git a/backend/onedrive/onedrive.go b/backend/onedrive/onedrive.go
index f0483d5f2..aa634b544 100644
--- a/backend/onedrive/onedrive.go
+++ b/backend/onedrive/onedrive.go
@@ -301,6 +301,24 @@ rclone.
 				Help:  "None - don't use any hashes",
 			}},
 			Advanced: true,
+		}, {
+			Name:    "av_override",
+			Default: false,
+			Help: `Allows download of files the server thinks has a virus.
+
+The onedrive/sharepoint server may check files uploaded with an Anti
+Virus checker. If it detects any potential viruses or malware it will
+block download of the file.
+
+In this case you will see a message like this
+
+    server reports this file is infected with a virus - use --onedrive-av-override to download anyway: Infected (name of virus): 403 Forbidden: 
+
+If you are 100% sure you want to download this file anyway then use
+the --onedrive-av-override flag, or av_override = true in the config
+file.
+`,
+			Advanced: true,
 		}, {
 			Name:     config.ConfigEncoding,
 			Help:     config.ConfigEncodingHelp,
@@ -640,6 +658,7 @@ type Options struct {
 	LinkType                string               `config:"link_type"`
 	LinkPassword            string               `config:"link_password"`
 	HashType                string               `config:"hash_type"`
+	AVOverride              bool                 `config:"av_override"`
 	Enc                     encoder.MultiEncoder `config:"encoding"`
 }
 
@@ -1966,12 +1985,20 @@ func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (in io.Read
 	var resp *http.Response
 	opts := o.fs.newOptsCall(o.id, "GET", "/content")
 	opts.Options = options
+	if o.fs.opt.AVOverride {
+		opts.Parameters = url.Values{"AVOverride": {"1"}}
+	}
 
 	err = o.fs.pacer.Call(func() (bool, error) {
 		resp, err = o.fs.srv.Call(ctx, &opts)
 		return shouldRetry(ctx, resp, err)
 	})
 	if err != nil {
+		if resp != nil {
+			if virus := resp.Header.Get("X-Virus-Infected"); virus != "" {
+				err = fmt.Errorf("server reports this file is infected with a virus - use --onedrive-av-override to download anyway: %s: %w", virus, err)
+			}
+		}
 		return nil, err
 	}
 
diff --git a/backend/s3/s3.go b/backend/s3/s3.go
index 63c2cac6d..e6cf4eea1 100644
--- a/backend/s3/s3.go
+++ b/backend/s3/s3.go
@@ -66,7 +66,7 @@ import (
 func init() {
 	fs.Register(&fs.RegInfo{
 		Name:        "s3",
-		Description: "Amazon S3 Compliant Storage Providers including AWS, Alibaba, Ceph, China Mobile, Cloudflare, GCS, ArvanCloud, DigitalOcean, Dreamhost, Huawei OBS, IBM COS, IDrive e2, IONOS Cloud, Liara, Lyve Cloud, Minio, Netease, RackCorp, Scaleway, SeaweedFS, StackPath, Storj, Tencent COS, Qiniu and Wasabi",
+		Description: "Amazon S3 Compliant Storage Providers including AWS, Alibaba, Ceph, China Mobile, Cloudflare, GCS, ArvanCloud, DigitalOcean, Dreamhost, Huawei OBS, IBM COS, IDrive e2, IONOS Cloud, Liara, Lyve Cloud, Minio, Netease, Petabox, RackCorp, Scaleway, SeaweedFS, StackPath, Storj, Tencent COS, Qiniu and Wasabi",
 		NewFs:       NewFs,
 		CommandHelp: commandHelp,
 		Config: func(ctx context.Context, name string, m configmap.Mapper, config fs.ConfigIn) (*fs.ConfigOut, error) {
@@ -136,6 +136,9 @@ func init() {
 			}, {
 				Value: "Netease",
 				Help:  "Netease Object Storage (NOS)",
+			}, {
+				Value: "Petabox",
+				Help:  "Petabox Object Storage",
 			}, {
 				Value: "RackCorp",
 				Help:  "RackCorp Object Storage",
@@ -440,10 +443,30 @@ func init() {
 				Value: "eu-south-2",
 				Help:  "Logrono, Spain",
 			}},
+		}, {
+			Name:     "region",
+			Help:     "Region where your bucket will be created and your data stored.\n",
+			Provider: "Petabox",
+			Examples: []fs.OptionExample{{
+				Value: "us-east-1",
+				Help:  "US East (N. Virginia)",
+			}, {
+				Value: "eu-central-1",
+				Help:  "Europe (Frankfurt)",
+			}, {
+				Value: "ap-southeast-1",
+				Help:  "Asia Pacific (Singapore)",
+			}, {
+				Value: "me-south-1",
+				Help:  "Middle East (Bahrain)",
+			}, {
+				Value: "sa-east-1",
+				Help:  "South America (São Paulo)",
+			}},
 		}, {
 			Name:     "region",
 			Help:     "Region to connect to.\n\nLeave blank if you are using an S3 clone and you don't have a region.",
-			Provider: "!AWS,Alibaba,ChinaMobile,Cloudflare,IONOS,ArvanCloud,Liara,Qiniu,RackCorp,Scaleway,Storj,TencentCOS,HuaweiOBS,IDrive",
+			Provider: "!AWS,Alibaba,ChinaMobile,Cloudflare,IONOS,Petabox,ArvanCloud,Liara,Qiniu,RackCorp,Scaleway,Storj,TencentCOS,HuaweiOBS,IDrive",
 			Examples: []fs.OptionExample{{
 				Value: "",
 				Help:  "Use this if unsure.\nWill use v4 signatures and an empty region.",
@@ -768,6 +791,30 @@ func init() {
 				Value: "s3-eu-south-2.ionoscloud.com",
 				Help:  "Logrono, Spain",
 			}},
+		}, {
+			Name:     "endpoint",
+			Help:     "Endpoint for Petabox S3 Object Storage.\n\nSpecify the endpoint from the same region.",
+			Provider: "Petabox",
+			Required: true,
+			Examples: []fs.OptionExample{{
+				Value: "s3.petabox.io",
+				Help:  "US East (N. Virginia)",
+			}, {
+				Value: "s3.us-east-1.petabox.io",
+				Help:  "US East (N. Virginia)",
+			}, {
+				Value: "s3.eu-central-1.petabox.io",
+				Help:  "Europe (Frankfurt)",
+			}, {
+				Value: "s3.ap-southeast-1.petabox.io",
+				Help:  "Asia Pacific (Singapore)",
+			}, {
+				Value: "s3.me-south-1.petabox.io",
+				Help:  "Middle East (Bahrain)",
+			}, {
+				Value: "s3.sa-east-1.petabox.io",
+				Help:  "South America (São Paulo)",
+			}},
 		}, {
 			// Liara endpoints: https://liara.ir/landing/object-storage
 			Name:     "endpoint",
@@ -1109,7 +1156,7 @@ func init() {
 		}, {
 			Name:     "endpoint",
 			Help:     "Endpoint for S3 API.\n\nRequired when using an S3 clone.",
-			Provider: "!AWS,IBMCOS,IDrive,IONOS,TencentCOS,HuaweiOBS,Alibaba,ChinaMobile,GCS,Liara,ArvanCloud,Scaleway,StackPath,Storj,RackCorp,Qiniu",
+			Provider: "!AWS,IBMCOS,IDrive,IONOS,TencentCOS,HuaweiOBS,Alibaba,ChinaMobile,GCS,Liara,ArvanCloud,Scaleway,StackPath,Storj,RackCorp,Qiniu,Petabox",
 			Examples: []fs.OptionExample{{
 				Value:    "objects-us-east-1.dream.io",
 				Help:     "Dream Objects endpoint",
@@ -1593,7 +1640,7 @@ func init() {
 		}, {
 			Name:     "location_constraint",
 			Help:     "Location constraint - must be set to match the Region.\n\nLeave blank if not sure. Used when creating buckets only.",
-			Provider: "!AWS,Alibaba,HuaweiOBS,ChinaMobile,Cloudflare,IBMCOS,IDrive,IONOS,Liara,ArvanCloud,Qiniu,RackCorp,Scaleway,StackPath,Storj,TencentCOS",
+			Provider: "!AWS,Alibaba,HuaweiOBS,ChinaMobile,Cloudflare,IBMCOS,IDrive,IONOS,Liara,ArvanCloud,Qiniu,RackCorp,Scaleway,StackPath,Storj,TencentCOS,Petabox",
 		}, {
 			Name: "acl",
 			Help: `Canned ACL used when creating buckets and storing or copying objects.
@@ -2196,6 +2243,15 @@ See: https://github.com/rclone/rclone/issues/4673, https://github.com/rclone/rcl
 This is usually set to a CloudFront CDN URL as AWS S3 offers
 cheaper egress for data downloaded through the CloudFront network.`,
 			Advanced: true,
+		}, {
+			Name:     "directory_markers",
+			Default:  false,
+			Advanced: true,
+			Help: `Upload an empty object with a trailing slash when a new directory is created
+
+Empty folders are unsupported for bucket based remotes, this option creates an empty
+object ending with "/", to persist the folder.
+`,
 		}, {
 			Name: "use_multipart_etag",
 			Help: `Whether to use ETag in multipart uploads for verification
@@ -2425,6 +2481,7 @@ type Options struct {
 	MemoryPoolUseMmap     bool                 `config:"memory_pool_use_mmap"`
 	DisableHTTP2          bool                 `config:"disable_http2"`
 	DownloadURL           string               `config:"download_url"`
+	DirectoryMarkers      bool                 `config:"directory_markers"`
 	UseMultipartEtag      fs.Tristate          `config:"use_multipart_etag"`
 	UsePresignedRequest   bool                 `config:"use_presigned_request"`
 	Versions              bool                 `config:"versions"`
@@ -2871,6 +2928,8 @@ func setQuirks(opt *Options) {
 		// listObjectsV2 supported - https://api.ionos.com/docs/s3/#Basic-Operations-get-Bucket-list-type-2
 		virtualHostStyle = false
 		urlEncodeListings = false
+	case "Petabox":
+		// No quirks
 	case "Liara":
 		virtualHostStyle = false
 		urlEncodeListings = false
@@ -3082,6 +3141,9 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
 	if opt.Provider == "IDrive" {
 		f.features.SetTier = false
 	}
+	if opt.DirectoryMarkers {
+		f.features.CanHaveEmptyDirectories = true
+	}
 	// f.listMultipartUploads()
 
 	if f.rootBucket != "" && f.rootDirectory != "" && !opt.NoHeadObject && !strings.HasSuffix(root, "/") {
@@ -3574,6 +3636,7 @@ func (f *Fs) list(ctx context.Context, opt listOpt, fn listFn) error {
 	default:
 		listBucket = f.newV2List(&req)
 	}
+	foundItems := 0
 	for {
 		var resp *s3.ListObjectsV2Output
 		var err error
@@ -3615,6 +3678,7 @@ func (f *Fs) list(ctx context.Context, opt listOpt, fn listFn) error {
 			return err
 		}
 		if !opt.recurse {
+			foundItems += len(resp.CommonPrefixes)
 			for _, commonPrefix := range resp.CommonPrefixes {
 				if commonPrefix.Prefix == nil {
 					fs.Logf(f, "Nil common prefix received")
@@ -3647,6 +3711,7 @@ func (f *Fs) list(ctx context.Context, opt listOpt, fn listFn) error {
 				}
 			}
 		}
+		foundItems += len(resp.Contents)
 		for i, object := range resp.Contents {
 			remote := aws.StringValue(object.Key)
 			if urlEncodeListings {
@@ -3662,18 +3727,28 @@ func (f *Fs) list(ctx context.Context, opt listOpt, fn listFn) error {
 				continue
 			}
 			remote = remote[len(opt.prefix):]
-			isDirectory := remote == "" || strings.HasSuffix(remote, "/")
+			isDirectory := (remote == "" || strings.HasSuffix(remote, "/")) && object.Size != nil && *object.Size == 0
 			if opt.addBucket {
 				remote = bucket.Join(opt.bucket, remote)
 			}
 			// is this a directory marker?
-			if isDirectory && object.Size != nil && *object.Size == 0 && !opt.noSkipMarkers {
-				continue // skip directory marker
+			if isDirectory {
+				if opt.noSkipMarkers {
+					// process directory markers as files
+					isDirectory = false
+				} else {
+					// Don't insert the root directory
+					if remote == opt.directory {
+						continue
+					}
+					// process directory markers as directories
+					remote = strings.TrimRight(remote, "/")
+				}
 			}
 			if versionIDs != nil {
-				err = fn(remote, object, versionIDs[i], false)
+				err = fn(remote, object, versionIDs[i], isDirectory)
 			} else {
-				err = fn(remote, object, nil, false)
+				err = fn(remote, object, nil, isDirectory)
 			}
 			if err != nil {
 				if err == errEndList {
@@ -3686,6 +3761,20 @@ func (f *Fs) list(ctx context.Context, opt listOpt, fn listFn) error {
 			break
 		}
 	}
+	if f.opt.DirectoryMarkers && foundItems == 0 && opt.directory != "" {
+		// Determine whether the directory exists or not by whether it has a marker
+		req := s3.HeadObjectInput{
+			Bucket: &opt.bucket,
+			Key:    &opt.directory,
+		}
+		_, err := f.headObject(ctx, &req)
+		if err != nil {
+			if err == fs.ErrorObjectNotFound {
+				return fs.ErrorDirNotFound
+			}
+			return err
+		}
+	}
 	return nil
 }
 
@@ -3876,10 +3965,70 @@ func (f *Fs) bucketExists(ctx context.Context, bucket string) (bool, error) {
 	return false, err
 }
 
+// Create directory marker file and parents
+func (f *Fs) createDirectoryMarker(ctx context.Context, bucket, dir string) error {
+	if !f.opt.DirectoryMarkers || bucket == "" {
+		return nil
+	}
+
+	// Object to be uploaded
+	o := &Object{
+		fs: f,
+		meta: map[string]string{
+			metaMtime: swift.TimeToFloatString(time.Now()),
+		},
+	}
+
+	for {
+		_, bucketPath := f.split(dir)
+		// Don't create the directory marker if it is the bucket or at the very root
+		if bucketPath == "" {
+			break
+		}
+		o.remote = dir + "/"
+
+		// Check to see if object already exists
+		_, err := o.headObject(ctx)
+		if err == nil {
+			return nil
+		}
+
+		// Upload it if not
+		fs.Debugf(o, "Creating directory marker")
+		content := io.Reader(strings.NewReader(""))
+		_, err = f.Put(ctx, content, o)
+		if err != nil {
+			return fmt.Errorf("creating directory marker failed: %w", err)
+		}
+
+		// Now check parent directory exists
+		dir = path.Dir(dir)
+		if dir == "/" || dir == "." {
+			break
+		}
+	}
+
+	return nil
+}
+
 // Mkdir creates the bucket if it doesn't exist
 func (f *Fs) Mkdir(ctx context.Context, dir string) error {
 	bucket, _ := f.split(dir)
-	return f.makeBucket(ctx, bucket)
+	e := f.makeBucket(ctx, bucket)
+	if e != nil {
+		return e
+	}
+	return f.createDirectoryMarker(ctx, bucket, dir)
+}
+
+// mkdirParent creates the parent bucket/directory if it doesn't exist
+func (f *Fs) mkdirParent(ctx context.Context, remote string) error {
+	remote = strings.TrimRight(remote, "/")
+	dir := path.Dir(remote)
+	if dir == "/" || dir == "." {
+		dir = ""
+	}
+	return f.Mkdir(ctx, dir)
 }
 
 // makeBucket creates the bucket if it doesn't exist
@@ -3920,6 +4069,18 @@ func (f *Fs) makeBucket(ctx context.Context, bucket string) error {
 // Returns an error if it isn't empty
 func (f *Fs) Rmdir(ctx context.Context, dir string) error {
 	bucket, directory := f.split(dir)
+	// Remove directory marker file
+	if f.opt.DirectoryMarkers && bucket != "" && dir != "" {
+		o := &Object{
+			fs:     f,
+			remote: dir + "/",
+		}
+		fs.Debugf(o, "Removing directory marker")
+		err := o.Remove(ctx)
+		if err != nil {
+			return fmt.Errorf("removing directory marker failed: %w", err)
+		}
+	}
 	if bucket == "" || directory != "" {
 		return nil
 	}
@@ -4118,7 +4279,7 @@ func (f *Fs) Copy(ctx context.Context, src fs.Object, remote string) (fs.Object,
 		return nil, errNotWithVersionAt
 	}
 	dstBucket, dstPath := f.split(remote)
-	err := f.makeBucket(ctx, dstBucket)
+	err := f.mkdirParent(ctx, remote)
 	if err != nil {
 		return nil, err
 	}
@@ -4745,22 +4906,26 @@ func (o *Object) headObject(ctx context.Context) (resp *s3.HeadObjectOutput, err
 		Key:       &bucketPath,
 		VersionId: o.versionID,
 	}
-	if o.fs.opt.RequesterPays {
+	return o.fs.headObject(ctx, &req)
+}
+
+func (f *Fs) headObject(ctx context.Context, req *s3.HeadObjectInput) (resp *s3.HeadObjectOutput, err error) {
+	if f.opt.RequesterPays {
 		req.RequestPayer = aws.String(s3.RequestPayerRequester)
 	}
-	if o.fs.opt.SSECustomerAlgorithm != "" {
-		req.SSECustomerAlgorithm = &o.fs.opt.SSECustomerAlgorithm
+	if f.opt.SSECustomerAlgorithm != "" {
+		req.SSECustomerAlgorithm = &f.opt.SSECustomerAlgorithm
 	}
-	if o.fs.opt.SSECustomerKey != "" {
-		req.SSECustomerKey = &o.fs.opt.SSECustomerKey
+	if f.opt.SSECustomerKey != "" {
+		req.SSECustomerKey = &f.opt.SSECustomerKey
 	}
-	if o.fs.opt.SSECustomerKeyMD5 != "" {
-		req.SSECustomerKeyMD5 = &o.fs.opt.SSECustomerKeyMD5
+	if f.opt.SSECustomerKeyMD5 != "" {
+		req.SSECustomerKeyMD5 = &f.opt.SSECustomerKeyMD5
 	}
-	err = o.fs.pacer.Call(func() (bool, error) {
+	err = f.pacer.Call(func() (bool, error) {
 		var err error
-		resp, err = o.fs.c.HeadObjectWithContext(ctx, &req)
-		return o.fs.shouldRetry(ctx, err)
+		resp, err = f.c.HeadObjectWithContext(ctx, req)
+		return f.shouldRetry(ctx, err)
 	})
 	if err != nil {
 		if awsErr, ok := err.(awserr.RequestFailure); ok {
@@ -4770,7 +4935,9 @@ func (o *Object) headObject(ctx context.Context) (resp *s3.HeadObjectOutput, err
 		}
 		return nil, err
 	}
-	o.fs.cache.MarkOK(bucket)
+	if req.Bucket != nil {
+		f.cache.MarkOK(*req.Bucket)
+	}
 	return resp, nil
 }
 
@@ -5419,9 +5586,12 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
 		return errNotWithVersionAt
 	}
 	bucket, bucketPath := o.split()
-	err := o.fs.makeBucket(ctx, bucket)
-	if err != nil {
-		return err
+	// Create parent dir/bucket if not saving directory marker
+	if !strings.HasSuffix(o.remote, "/") {
+		err := o.fs.mkdirParent(ctx, o.remote)
+		if err != nil {
+			return err
+		}
 	}
 	modTime := src.ModTime(ctx)
 	size := src.Size()
@@ -5744,7 +5914,7 @@ func (o *Object) Metadata(ctx context.Context) (metadata fs.Metadata, err error)
 	setMetadata("content-disposition", o.contentDisposition)
 	setMetadata("content-encoding", o.contentEncoding)
 	setMetadata("content-language", o.contentLanguage)
-	setMetadata("tier", o.storageClass)
+	metadata["tier"] = o.GetTier()
 
 	return metadata, nil
 }
diff --git a/backend/swift/swift.go b/backend/swift/swift.go
index 7806dd505..95163d912 100644
--- a/backend/swift/swift.go
+++ b/backend/swift/swift.go
@@ -1558,6 +1558,10 @@ func (o *Object) Remove(ctx context.Context) (err error) {
 	// Remove file/manifest first
 	err = o.fs.pacer.Call(func() (bool, error) {
 		err = o.fs.c.ObjectDelete(ctx, container, containerPath)
+		if err == swift.ObjectNotFound {
+			fs.Errorf(o, "Dangling object - ignoring: %v", err)
+			err = nil
+		}
 		return shouldRetry(ctx, err)
 	})
 	if err != nil {
diff --git a/backend/union/policy/policy.go b/backend/union/policy/policy.go
index d784c8e29..cc7623a4d 100644
--- a/backend/union/policy/policy.go
+++ b/backend/union/policy/policy.go
@@ -3,7 +3,6 @@ package policy
 import (
 	"context"
 	"fmt"
-	"math/rand"
 	"path"
 	"strings"
 	"time"
@@ -109,9 +108,7 @@ func findEntry(ctx context.Context, f fs.Fs, remote string) fs.DirEntry {
 		if err != nil {
 			return nil
 		}
-		// random modtime for root
-		randomNow := time.Unix(time.Now().Unix()-rand.Int63n(10000), 0)
-		return fs.NewDir("", randomNow)
+		return fs.NewDir("", time.Time{})
 	}
 	found := false
 	for _, e := range entries {
diff --git a/backend/uptobox/uptobox.go b/backend/uptobox/uptobox.go
index ad827377b..4f09ad8e6 100644
--- a/backend/uptobox/uptobox.go
+++ b/backend/uptobox/uptobox.go
@@ -923,7 +923,8 @@ func (o *Object) Remote() string {
 // It attempts to read the objects mtime and if that isn't present the
 // LastModified returned in the http headers
 func (o *Object) ModTime(ctx context.Context) time.Time {
-	return time.Now()
+	ci := fs.GetConfig(ctx)
+	return time.Time(ci.DefaultTime)
 }
 
 // Size returns the size of an object in bytes
diff --git a/backend/webdav/webdav.go b/backend/webdav/webdav.go
index cc714d826..206b11bd1 100644
--- a/backend/webdav/webdav.go
+++ b/backend/webdav/webdav.go
@@ -175,6 +175,7 @@ type Fs struct {
 	precision          time.Duration // mod time precision
 	canStream          bool          // set if can stream
 	useOCMtime         bool          // set if can use X-OC-Mtime
+	propsetMtime       bool          // set if can use propset
 	retryWithZeroDepth bool          // some vendors (sharepoint) won't list files when Depth is 1 (our default)
 	checkBeforePurge   bool          // enables extra check that directory to purge really exists
 	hasOCMD5           bool          // set if can use owncloud style checksums for MD5
@@ -582,11 +583,13 @@ func (f *Fs) setQuirks(ctx context.Context, vendor string) error {
 		f.canStream = true
 		f.precision = time.Second
 		f.useOCMtime = true
+		f.propsetMtime = true
 		f.hasOCMD5 = true
 		f.hasOCSHA1 = true
 	case "nextcloud":
 		f.precision = time.Second
 		f.useOCMtime = true
+		f.propsetMtime = true
 		f.hasOCSHA1 = true
 		f.canChunk = true
 		if err := f.verifyChunkConfig(); err != nil {
@@ -1047,7 +1050,7 @@ func (f *Fs) copyOrMove(ctx context.Context, src fs.Object, remote string, metho
 		NoResponse: true,
 		ExtraHeaders: map[string]string{
 			"Destination": destinationURL.String(),
-			"Overwrite":   "F",
+			"Overwrite":   "T",
 		},
 	}
 	if f.useOCMtime {
@@ -1147,7 +1150,7 @@ func (f *Fs) DirMove(ctx context.Context, src fs.Fs, srcRemote, dstRemote string
 		NoResponse: true,
 		ExtraHeaders: map[string]string{
 			"Destination": addSlash(destinationURL.String()),
-			"Overwrite":   "F",
+			"Overwrite":   "T",
 		},
 	}
 	// Direct the MOVE/COPY to the source server
@@ -1299,8 +1302,53 @@ func (o *Object) ModTime(ctx context.Context) time.Time {
 	return o.modTime
 }
 
+// Set modified time using propset
+//
+// <d:multistatus xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns"><d:response><d:href>/ocm/remote.php/webdav/office/wir.jpg</d:href><d:propstat><d:prop><d:lastmodified/></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>
+var owncloudPropset = `<?xml version="1.0" encoding="utf-8" ?>
+<D:propertyupdate xmlns:D="DAV:">
+ <D:set>
+  <D:prop>
+   <lastmodified xmlns="DAV:">%d</lastmodified>
+  </D:prop>
+ </D:set>
+</D:propertyupdate>
+`
+
 // SetModTime sets the modification time of the local fs object
 func (o *Object) SetModTime(ctx context.Context, modTime time.Time) error {
+	if o.fs.propsetMtime {
+		opts := rest.Opts{
+			Method:     "PROPPATCH",
+			Path:       o.filePath(),
+			NoRedirect: true,
+			Body:       strings.NewReader(fmt.Sprintf(owncloudPropset, modTime.Unix())),
+		}
+		var result api.Multistatus
+		var resp *http.Response
+		var err error
+		err = o.fs.pacer.Call(func() (bool, error) {
+			resp, err = o.fs.srv.CallXML(ctx, &opts, nil, &result)
+			return o.fs.shouldRetry(ctx, resp, err)
+		})
+		if err != nil {
+			if apiErr, ok := err.(*api.Error); ok {
+				// does not exist
+				if apiErr.StatusCode == http.StatusNotFound {
+					return fs.ErrorObjectNotFound
+				}
+			}
+			return fmt.Errorf("couldn't set modified time: %w", err)
+		}
+		// FIXME check if response is valid
+		if len(result.Responses) == 1 && result.Responses[0].Props.StatusOK() {
+			// update cached modtime
+			o.modTime = modTime
+			return nil
+		}
+		// fallback
+		return fs.ErrorCantSetModTime
+	}
 	return fs.ErrorCantSetModTime
 }
 
diff --git a/cmd/cat/cat.go b/cmd/cat/cat.go
index 03ed4e6e3..b92f3efbb 100644
--- a/cmd/cat/cat.go
+++ b/cmd/cat/cat.go
@@ -16,11 +16,12 @@ import (
 
 // Globals
 var (
-	head    = int64(0)
-	tail    = int64(0)
-	offset  = int64(0)
-	count   = int64(-1)
-	discard = false
+	head      = int64(0)
+	tail      = int64(0)
+	offset    = int64(0)
+	count     = int64(-1)
+	discard   = false
+	separator = string("")
 )
 
 func init() {
@@ -31,6 +32,7 @@ func init() {
 	flags.Int64VarP(cmdFlags, &offset, "offset", "", offset, "Start printing at offset N (or from end if -ve)")
 	flags.Int64VarP(cmdFlags, &count, "count", "", count, "Only print N characters")
 	flags.BoolVarP(cmdFlags, &discard, "discard", "", discard, "Discard the output instead of printing")
+	flags.StringVarP(cmdFlags, &separator, "separator", "", separator, "Separator to use between objects when printing multiple files")
 }
 
 var commandDefinition = &cobra.Command{
@@ -56,6 +58,18 @@ Use the |--head| flag to print characters only at the start, |--tail| for
 the end and |--offset| and |--count| to print a section in the middle.
 Note that if offset is negative it will count from the end, so
 |--offset -1 --count 1| is equivalent to |--tail 1|.
+
+Use the |--separator| flag to print a separator value between files. Be sure to
+shell-escape special characters. For example, to print a newline between
+files, use:
+
+* bash:
+
+      rclone --include "*.txt" --separator $'\n' cat remote:path/to/dir
+
+* powershell:
+
+      rclone --include "*.txt" --separator "|n" cat remote:path/to/dir
 `, "|", "`"),
 	Annotations: map[string]string{
 		"versionIntroduced": "v1.33",
@@ -82,7 +96,7 @@ Note that if offset is negative it will count from the end, so
 			w = io.Discard
 		}
 		cmd.Run(false, false, command, func() error {
-			return operations.Cat(context.Background(), fsrc, w, offset, count)
+			return operations.Cat(context.Background(), fsrc, w, offset, count, []byte(separator))
 		})
 	},
 }
diff --git a/cmd/mount/handle.go b/cmd/mount/handle.go
index 7b7b6c010..c3293acc3 100644
--- a/cmd/mount/handle.go
+++ b/cmd/mount/handle.go
@@ -25,15 +25,13 @@ var _ fusefs.HandleReader = (*FileHandle)(nil)
 func (fh *FileHandle) Read(ctx context.Context, req *fuse.ReadRequest, resp *fuse.ReadResponse) (err error) {
 	var n int
 	defer log.Trace(fh, "len=%d, offset=%d", req.Size, req.Offset)("read=%d, err=%v", &n, &err)
-	data := make([]byte, req.Size)
+	data := resp.Data[:req.Size]
 	n, err = fh.Handle.ReadAt(data, req.Offset)
+	resp.Data = data[:n]
 	if err == io.EOF {
 		err = nil
-	} else if err != nil {
-		return translateError(err)
 	}
-	resp.Data = data[:n]
-	return nil
+	return translateError(err)
 }
 
 // Check interface satisfied
diff --git a/cmd/mount2/mount.go b/cmd/mount2/mount.go
index 152f3a89e..811cab235 100644
--- a/cmd/mount2/mount.go
+++ b/cmd/mount2/mount.go
@@ -26,12 +26,14 @@ func init() {
 // man mount.fuse for more info and note the -o flag for other options
 func mountOptions(fsys *FS, f fs.Fs, opt *mountlib.Options) (mountOpts *fuse.MountOptions) {
 	mountOpts = &fuse.MountOptions{
-		AllowOther:    fsys.opt.AllowOther,
-		FsName:        opt.DeviceName,
-		Name:          "rclone",
-		DisableXAttrs: true,
-		Debug:         fsys.opt.DebugFUSE,
-		MaxReadAhead:  int(fsys.opt.MaxReadAhead),
+		AllowOther:         fsys.opt.AllowOther,
+		FsName:             opt.DeviceName,
+		Name:               "rclone",
+		DisableXAttrs:      true,
+		Debug:              fsys.opt.DebugFUSE,
+		MaxReadAhead:       int(fsys.opt.MaxReadAhead),
+		MaxWrite:           1024 * 1024, // Linux v4.20+ caps requests at 1 MiB
+		DisableReadDirPlus: true,
 
 		// RememberInodes: true,
 		// SingleThreaded: true,
@@ -47,12 +49,42 @@ func mountOptions(fsys *FS, f fs.Fs, opt *mountlib.Options) (mountOpts *fuse.Mou
 			// async I/O.  Concurrency for synchronous I/O is not limited.
 			MaxBackground int
 
-			// Write size to use.  If 0, use default. This number is
-			// capped at the kernel maximum.
+			// MaxWrite is the max size for read and write requests. If 0, use
+			// go-fuse default (currently 64 kiB).
+			// This number is internally capped at MAX_KERNEL_WRITE (higher values don't make
+			// sense).
+			//
+			// Non-direct-io reads are mostly served via kernel readahead, which is
+			// additionally subject to the MaxReadAhead limit.
+			//
+			// Implementation notes:
+			//
+			// There's four values the Linux kernel looks at when deciding the request size:
+			// * MaxWrite, passed via InitOut.MaxWrite. Limits the WRITE size.
+			// * max_read, passed via a string mount option. Limits the READ size.
+			//   go-fuse sets max_read equal to MaxWrite.
+			//   You can see the current max_read value in /proc/self/mounts .
+			// * MaxPages, passed via InitOut.MaxPages. In Linux 4.20 and later, the value
+			//   can go up to 1 MiB and go-fuse calculates the MaxPages value acc.
+			//   to MaxWrite, rounding up.
+			//   On older kernels, the value is fixed at 128 kiB and the
+			//   passed value is ignored. No request can be larger than MaxPages, so
+			//   READ and WRITE are effectively capped at MaxPages.
+			// * MaxReadAhead, passed via InitOut.MaxReadAhead.
 			MaxWrite int
 
-			// Max read ahead to use.  If 0, use default. This number is
-			// capped at the kernel maximum.
+			// MaxReadAhead is the max read ahead size to use. It controls how much data the
+			// kernel reads in advance to satisfy future read requests from applications.
+			// How much exactly is subject to clever heuristics in the kernel
+			// (see https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/mm/readahead.c?h=v6.2-rc5#n375
+			// if you are brave) and hence also depends on the kernel version.
+			//
+			// If 0, use kernel default. This number is capped at the kernel maximum
+			// (128 kiB on Linux) and cannot be larger than MaxWrite.
+			//
+			// MaxReadAhead only affects buffered reads (=non-direct-io), but even then, the
+			// kernel can and does send larger reads to satisfy read reqests from applications
+			// (up to MaxWrite or VM_READAHEAD_PAGES=128 kiB, whichever is less).
 			MaxReadAhead int
 
 			// If IgnoreSecurityLabels is set, all security related xattr
@@ -87,9 +119,19 @@ func mountOptions(fsys *FS, f fs.Fs, opt *mountlib.Options) (mountOpts *fuse.Mou
 			// you must implement the GetLk/SetLk/SetLkw methods.
 			EnableLocks bool
 
+			// If set, the kernel caches all Readlink return values. The
+			// filesystem must use content notification to force the
+			// kernel to issue a new Readlink call.
+			EnableSymlinkCaching bool
+
 			// If set, ask kernel not to do automatic data cache invalidation.
 			// The filesystem is fully responsible for invalidating data cache.
 			ExplicitDataCacheControl bool
+
+			// Disable ReadDirPlus capability so ReadDir is used instead. Simple
+			// directory queries (i.e. 'ls' without '-l') can be faster with
+			// ReadDir, as no per-file stat calls are needed
+			DisableReadDirPlus bool
 		*/
 
 	}
@@ -176,8 +218,8 @@ func mount(VFS *vfs.VFS, mountpoint string, opt *mountlib.Options) (<-chan error
 		MountOptions: *mountOpts,
 		EntryTimeout: &opt.AttrTimeout,
 		AttrTimeout:  &opt.AttrTimeout,
-		// UID
-		// GID
+		GID:          VFS.Opt.GID,
+		UID:          VFS.Opt.UID,
 	}
 
 	root, err := fsys.Root()
diff --git a/cmd/mount2/node.go b/cmd/mount2/node.go
index fae416d64..0ba1aded4 100644
--- a/cmd/mount2/node.go
+++ b/cmd/mount2/node.go
@@ -85,17 +85,16 @@ func (n *Node) lookupVfsNodeInDir(leaf string) (vfsNode vfs.Node, errno syscall.
 // will not work.
 func (n *Node) Statfs(ctx context.Context, out *fuse.StatfsOut) syscall.Errno {
 	defer log.Trace(n, "")("out=%+v", &out)
-	out = new(fuse.StatfsOut)
 	const blockSize = 4096
-	const fsBlocks = (1 << 50) / blockSize
-	out.Blocks = fsBlocks  // Total data blocks in file system.
-	out.Bfree = fsBlocks   // Free blocks in file system.
-	out.Bavail = fsBlocks  // Free blocks in file system if you're not root.
-	out.Files = 1e9        // Total files in file system.
-	out.Ffree = 1e9        // Free files in file system.
-	out.Bsize = blockSize  // Block size
-	out.NameLen = 255      // Maximum file name length?
-	out.Frsize = blockSize // Fragment size, smallest addressable data size in the file system.
+	total, _, free := n.fsys.VFS.Statfs()
+	out.Blocks = uint64(total) / blockSize // Total data blocks in file system.
+	out.Bfree = uint64(free) / blockSize   // Free blocks in file system.
+	out.Bavail = out.Bfree                 // Free blocks in file system if you're not root.
+	out.Files = 1e9                        // Total files in file system.
+	out.Ffree = 1e9                        // Free files in file system.
+	out.Bsize = blockSize                  // Block size
+	out.NameLen = 255                      // Maximum file name length?
+	out.Frsize = blockSize                 // Fragment size, smallest addressable data size in the file system.
 	mountlib.ClipBlocks(&out.Blocks)
 	mountlib.ClipBlocks(&out.Bfree)
 	mountlib.ClipBlocks(&out.Bavail)
@@ -405,3 +404,40 @@ func (n *Node) Rename(ctx context.Context, oldName string, newParent fusefs.Inod
 }
 
 var _ = (fusefs.NodeRenamer)((*Node)(nil))
+
+// Getxattr should read data for the given attribute into
+// `dest` and return the number of bytes. If `dest` is too
+// small, it should return ERANGE and the size of the attribute.
+// If not defined, Getxattr will return ENOATTR.
+func (n *Node) Getxattr(ctx context.Context, attr string, dest []byte) (uint32, syscall.Errno) {
+	return 0, syscall.ENOSYS // we never implement this
+}
+
+var _ fusefs.NodeGetxattrer = (*Node)(nil)
+
+// Setxattr should store data for the given attribute.  See
+// setxattr(2) for information about flags.
+// If not defined, Setxattr will return ENOATTR.
+func (n *Node) Setxattr(ctx context.Context, attr string, data []byte, flags uint32) syscall.Errno {
+	return syscall.ENOSYS // we never implement this
+}
+
+var _ fusefs.NodeSetxattrer = (*Node)(nil)
+
+// Removexattr should delete the given attribute.
+// If not defined, Removexattr will return ENOATTR.
+func (n *Node) Removexattr(ctx context.Context, attr string) syscall.Errno {
+	return syscall.ENOSYS // we never implement this
+}
+
+var _ fusefs.NodeRemovexattrer = (*Node)(nil)
+
+// Listxattr should read all attributes (null terminated) into
+// `dest`. If the `dest` buffer is too small, it should return ERANGE
+// and the correct size.  If not defined, return an empty list and
+// success.
+func (n *Node) Listxattr(ctx context.Context, dest []byte) (uint32, syscall.Errno) {
+	return 0, syscall.ENOSYS // we never implement this
+}
+
+var _ fusefs.NodeListxattrer = (*Node)(nil)
diff --git a/cmd/selfupdate/selfupdate.go b/cmd/selfupdate/selfupdate.go
index 184710cb3..6da348050 100644
--- a/cmd/selfupdate/selfupdate.go
+++ b/cmd/selfupdate/selfupdate.go
@@ -68,13 +68,14 @@ var cmdSelfUpdate = &cobra.Command{
 		"versionIntroduced": "v1.55",
 	},
 	Run: func(command *cobra.Command, args []string) {
+		ctx := context.Background()
 		cmd.CheckArgs(0, 0, command, args)
 		if Opt.Package == "" {
 			Opt.Package = "zip"
 		}
 		gotActionFlags := Opt.Stable || Opt.Beta || Opt.Output != "" || Opt.Version != "" || Opt.Package != "zip"
 		if Opt.Check && !gotActionFlags {
-			versionCmd.CheckVersion()
+			versionCmd.CheckVersion(ctx)
 			return
 		}
 		if Opt.Package != "zip" {
@@ -108,7 +109,7 @@ func GetVersion(ctx context.Context, beta bool, version string) (newVersion, sit
 
 	if version == "" {
 		// Request the latest release number from the download site
-		_, newVersion, _, err = versionCmd.GetVersion(siteURL + "/version.txt")
+		_, newVersion, _, err = versionCmd.GetVersion(ctx, siteURL+"/version.txt")
 		return
 	}
 
diff --git a/cmd/serve/dlna/dlna.go b/cmd/serve/dlna/dlna.go
index 19db02f16..934316635 100644
--- a/cmd/serve/dlna/dlna.go
+++ b/cmd/serve/dlna/dlna.go
@@ -303,7 +303,7 @@ func (s *server) Serve() (err error) {
 	go func() {
 		fs.Logf(s.f, "Serving HTTP on %s", s.HTTPConn.Addr().String())
 
-		err = s.serveHTTP()
+		err := s.serveHTTP()
 		if err != nil {
 			fs.Logf(s.f, "Error on serving HTTP server: %v", err)
 		}
diff --git a/cmd/version/version.go b/cmd/version/version.go
index 5d35cb98c..df8ac271e 100644
--- a/cmd/version/version.go
+++ b/cmd/version/version.go
@@ -2,6 +2,7 @@
 package version
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"io"
@@ -13,6 +14,7 @@ import (
 	"github.com/rclone/rclone/cmd"
 	"github.com/rclone/rclone/fs"
 	"github.com/rclone/rclone/fs/config/flags"
+	"github.com/rclone/rclone/fs/fshttp"
 	"github.com/spf13/cobra"
 )
 
@@ -71,9 +73,10 @@ Or
 		"versionIntroduced": "v1.33",
 	},
 	Run: func(command *cobra.Command, args []string) {
+		ctx := context.Background()
 		cmd.CheckArgs(0, 0, command, args)
 		if check {
-			CheckVersion()
+			CheckVersion(ctx)
 		} else {
 			cmd.ShowVersion()
 		}
@@ -89,8 +92,8 @@ func stripV(s string) string {
 }
 
 // GetVersion gets the version available for download
-func GetVersion(url string) (v *semver.Version, vs string, date time.Time, err error) {
-	resp, err := http.Get(url)
+func GetVersion(ctx context.Context, url string) (v *semver.Version, vs string, date time.Time, err error) {
+	resp, err := fshttp.NewClient(ctx).Get(url)
 	if err != nil {
 		return v, vs, date, err
 	}
@@ -114,7 +117,7 @@ func GetVersion(url string) (v *semver.Version, vs string, date time.Time, err e
 }
 
 // CheckVersion checks the installed version against available downloads
-func CheckVersion() {
+func CheckVersion(ctx context.Context) {
 	vCurrent, err := semver.NewVersion(stripV(fs.Version))
 	if err != nil {
 		fs.Errorf(nil, "Failed to parse version: %v", err)
@@ -122,7 +125,7 @@ func CheckVersion() {
 	const timeFormat = "2006-01-02"
 
 	printVersion := func(what, url string) {
-		v, vs, t, err := GetVersion(url + "version.txt")
+		v, vs, t, err := GetVersion(ctx, url+"version.txt")
 		if err != nil {
 			fs.Errorf(nil, "Failed to get rclone %s version: %v", what, err)
 			return
diff --git a/docs/content/_index.md b/docs/content/_index.md
index a3539fa6e..97b473e56 100644
--- a/docs/content/_index.md
+++ b/docs/content/_index.md
@@ -152,6 +152,7 @@ WebDAV or S3, that work out of the box.)
 {{< provider name="Oracle Object Storage" home="https://www.oracle.com/cloud/storage/object-storage" config="/oracleobjectstorage/" >}}
 {{< provider name="ownCloud" home="https://owncloud.org/" config="/webdav/#owncloud" >}}
 {{< provider name="pCloud" home="https://www.pcloud.com/" config="/pcloud/" >}}
+{{< provider name="Petabox" home="https://petabox.io/" config="/s3/#petabox" >}}
 {{< provider name="PikPak" home="https://mypikpak.com/" config="/pikpak/" >}}
 {{< provider name="premiumize.me" home="https://premiumize.me/" config="/premiumizeme/" >}}
 {{< provider name="put.io" home="https://put.io/" config="/putio/" >}}
diff --git a/docs/content/authors.md b/docs/content/authors.md
index c9a47f7f1..f1b37cc66 100644
--- a/docs/content/authors.md
+++ b/docs/content/authors.md
@@ -718,3 +718,8 @@ put them back in again.` >}}
   * WeidiDeng <weidi_deng@icloud.com>
   * Brian Starkey <stark3y@gmail.com>
   * jladbrook <jhladbrook@gmail.com>
+  * Loren Gordon <lorengordon@users.noreply.github.com>
+  * dlitster <davidlitster@gmail.com>
+  * Tobias Gion <tobias@gion.io>
+  * Jānis Bebrītis <janis.bebritis@wunder.io>
+  * Adam K <github.com@ak.tidy.email>
diff --git a/docs/content/commands/rclone_cat.md b/docs/content/commands/rclone_cat.md
index 6ad41423d..2e6ec3869 100644
--- a/docs/content/commands/rclone_cat.md
+++ b/docs/content/commands/rclone_cat.md
@@ -32,6 +32,18 @@ the end and `--offset` and `--count` to print a section in the middle.
 Note that if offset is negative it will count from the end, so
 `--offset -1 --count 1` is equivalent to `--tail 1`.
 
+Use the `--separator` flag to print a separator value between files. Be sure to
+shell-escape special characters. For example, to print a newline between
+files, use:
+
+* bash:
+
+      rclone --include "*.txt" --separator $'\n' cat remote:path/to/dir
+
+* powershell:
+
+      rclone --include "*.txt" --separator "`n" cat remote:path/to/dir
+
 
 ```
 rclone cat remote:path [flags]
@@ -40,12 +52,13 @@ rclone cat remote:path [flags]
 ## Options
 
 ```
-      --count int    Only print N characters (default -1)
-      --discard      Discard the output instead of printing
-      --head int     Only print the first N characters
-  -h, --help         help for cat
-      --offset int   Start printing at offset N (or from end if -ve)
-      --tail int     Only print the last N characters
+      --count int          Only print N characters (default -1)
+      --discard            Discard the output instead of printing
+      --head int           Only print the first N characters
+  -h, --help               help for cat
+      --offset int         Start printing at offset N (or from end if -ve)
+      --separator string   Separator to use between objects when printing multiple files
+      --tail int           Only print the last N characters
 ```
 
 See the [global flags page](/flags/) for global options not listed here.
diff --git a/docs/content/docs.md b/docs/content/docs.md
index 941c9c842..22e8c8c93 100644
--- a/docs/content/docs.md
+++ b/docs/content/docs.md
@@ -992,6 +992,18 @@ Mode to run dedupe command in.  One of `interactive`, `skip`, `first`,
 `newest`, `oldest`, `rename`.  The default is `interactive`.  
 See the dedupe command for more information as to what these options mean.
 
+### --default-time TIME ###
+
+If a file or directory does have a modification time rclone can read
+then rclone will display this fixed time instead.
+
+The default is `2000-01-01 00:00:00 UTC`. This can be configured in
+any of the ways shown in [the time or duration options](#time-option).
+
+For example `--default-time 2020-06-01` to set the default time to the
+1st of June 2020 or `--default-time 0s` to set the default time to the
+time rclone started up.
+
 ### --disable FEATURE,FEATURE,... ###
 
 This disables a comma separated list of optional features. For example
diff --git a/docs/content/s3.md b/docs/content/s3.md
index 2cd0adc93..8ab7e41b1 100644
--- a/docs/content/s3.md
+++ b/docs/content/s3.md
@@ -24,6 +24,7 @@ The S3 backend can be used with a number of different providers:
 {{< provider name="IONOS Cloud" home="https://cloud.ionos.com/storage/object-storage" config="/s3/#ionos" >}}
 {{< provider name="Liara Object Storage" home="https://liara.ir/landing/object-storage" config="/s3/#liara-cloud" >}}
 {{< provider name="Minio" home="https://www.minio.io/" config="/s3/#minio" >}}
+{{< provider name="Petabox" home="https://petabox.io/" config="/s3/#petabox" >}}
 {{< provider name="Qiniu Cloud Object Storage (Kodo)" home="https://www.qiniu.com/en/products/kodo" config="/s3/#qiniu" >}}
 {{< provider name="RackCorp Object Storage" home="https://www.rackcorp.com/" config="/s3/#RackCorp" >}}
 {{< provider name="Scaleway" home="https://www.scaleway.com/en/object-storage/" config="/s3/#scaleway" >}}
@@ -380,7 +381,11 @@ However for objects which were uploaded as multipart uploads or with
 server side encryption (SSE-AWS or SSE-C) the `ETag` header is no
 longer the MD5 sum of the data, so rclone adds an additional piece of
 metadata `X-Amz-Meta-Md5chksum` which is a base64 encoded MD5 hash (in
-the same format as is required for `Content-MD5`).
+the same format as is required for `Content-MD5`).  You can use base64 -d and hexdump to check this value manually:
+
+    echo 'VWTGdNx3LyXQDfA0e2Edxw==' | base64 -d | hexdump
+
+or you can use `rclone check` to verify the hashes are OK.
 
 For large objects, calculating this hash can take some time so the
 addition of this hash can be disabled with `--s3-disable-checksum`.
@@ -5358,6 +5363,166 @@ For Netease NOS configure as per the configurator `rclone config`
 setting the provider `Netease`.  This will automatically set
 `force_path_style = false` which is necessary for it to run properly.
 
+### Petabox
+
+Here is an example of making a [Petabox](https://petabox.io/)
+configuration. First run:
+
+```bash
+rclone config
+```
+
+This will guide you through an interactive setup process.
+
+```
+No remotes found, make a new one?
+n) New remote
+s) Set configuration password
+n/s> n
+
+Enter name for new remote.
+name> My Petabox Storage
+
+Option Storage.
+Type of storage to configure.
+Choose a number from below, or type in your own value.
+[snip]
+XX / Amazon S3 Compliant Storage Providers including AWS, ...
+   \ "s3"
+[snip]
+Storage> s3
+
+Option provider.
+Choose your S3 provider.
+Choose a number from below, or type in your own value.
+Press Enter to leave empty.
+[snip]
+XX / Petabox Object Storage
+   \ (Petabox)
+[snip]
+provider> Petabox
+
+Option env_auth.
+Get AWS credentials from runtime (environment variables or EC2/ECS meta data if no env vars).
+Only applies if access_key_id and secret_access_key is blank.
+Choose a number from below, or type in your own boolean value (true or false).
+Press Enter for the default (false).
+ 1 / Enter AWS credentials in the next step.
+   \ (false)
+ 2 / Get AWS credentials from the environment (env vars or IAM).
+   \ (true)
+env_auth> 1
+
+Option access_key_id.
+AWS Access Key ID.
+Leave blank for anonymous access or runtime credentials.
+Enter a value. Press Enter to leave empty.
+access_key_id> YOUR_ACCESS_KEY_ID
+
+Option secret_access_key.
+AWS Secret Access Key (password).
+Leave blank for anonymous access or runtime credentials.
+Enter a value. Press Enter to leave empty.
+secret_access_key> YOUR_SECRET_ACCESS_KEY
+
+Option region.
+Region where your bucket will be created and your data stored.
+Choose a number from below, or type in your own value.
+Press Enter to leave empty.
+ 1 / US East (N. Virginia)
+   \ (us-east-1)
+ 2 / Europe (Frankfurt)
+   \ (eu-central-1)
+ 3 / Asia Pacific (Singapore)
+   \ (ap-southeast-1)
+ 4 / Middle East (Bahrain)
+   \ (me-south-1)
+ 5 / South America (São Paulo)
+   \ (sa-east-1)
+region> 1
+
+Option endpoint.
+Endpoint for Petabox S3 Object Storage.
+Specify the endpoint from the same region.
+Choose a number from below, or type in your own value.
+ 1 / US East (N. Virginia)
+   \ (s3.petabox.io)
+ 2 / US East (N. Virginia)
+   \ (s3.us-east-1.petabox.io)
+ 3 / Europe (Frankfurt)
+   \ (s3.eu-central-1.petabox.io)
+ 4 / Asia Pacific (Singapore)
+   \ (s3.ap-southeast-1.petabox.io)
+ 5 / Middle East (Bahrain)
+   \ (s3.me-south-1.petabox.io)
+ 6 / South America (São Paulo)
+   \ (s3.sa-east-1.petabox.io)
+endpoint> 1
+
+Option acl.
+Canned ACL used when creating buckets and storing or copying objects.
+This ACL is used for creating objects and if bucket_acl isn't set, for creating buckets too.
+For more info visit https://docs.aws.amazon.com/AmazonS3/latest/dev/acl-overview.html#canned-acl
+Note that this ACL is applied when server-side copying objects as S3
+doesn't copy the ACL from the source but rather writes a fresh one.
+If the acl is an empty string then no X-Amz-Acl: header is added and
+the default (private) will be used.
+Choose a number from below, or type in your own value.
+Press Enter to leave empty.
+   / Owner gets FULL_CONTROL.
+ 1 | No one else has access rights (default).
+   \ (private)
+   / Owner gets FULL_CONTROL.
+ 2 | The AllUsers group gets READ access.
+   \ (public-read)
+   / Owner gets FULL_CONTROL.
+ 3 | The AllUsers group gets READ and WRITE access.
+   | Granting this on a bucket is generally not recommended.
+   \ (public-read-write)
+   / Owner gets FULL_CONTROL.
+ 4 | The AuthenticatedUsers group gets READ access.
+   \ (authenticated-read)
+   / Object owner gets FULL_CONTROL.
+ 5 | Bucket owner gets READ access.
+   | If you specify this canned ACL when creating a bucket, Amazon S3 ignores it.
+   \ (bucket-owner-read)
+   / Both the object owner and the bucket owner get FULL_CONTROL over the object.
+ 6 | If you specify this canned ACL when creating a bucket, Amazon S3 ignores it.
+   \ (bucket-owner-full-control)
+acl> 1
+
+Edit advanced config?
+y) Yes
+n) No (default)
+y/n> No
+
+Configuration complete.
+Options:
+- type: s3
+- provider: Petabox
+- access_key_id: YOUR_ACCESS_KEY_ID
+- secret_access_key: YOUR_SECRET_ACCESS_KEY
+- region: us-east-1
+- endpoint: s3.petabox.io
+Keep this "My Petabox Storage" remote?
+y) Yes this is OK (default)
+e) Edit this remote
+d) Delete this remote
+y/e/d> y
+```
+
+This will leave the config file looking like this.
+
+```
+[My Petabox Storage]
+type = s3
+provider = Petabox
+access_key_id = YOUR_ACCESS_KEY_ID
+secret_access_key = YOUR_SECRET_ACCESS_KEY
+region = us-east-1
+endpoint = s3.petabox.io
+```
+
 ### Storj
 
 Storj is a decentralized cloud storage which can be used through its
diff --git a/docs/content/uptobox.md b/docs/content/uptobox.md
index 7324058fc..7f6e750ab 100644
--- a/docs/content/uptobox.md
+++ b/docs/content/uptobox.md
@@ -84,7 +84,8 @@ To copy a local directory to an Uptobox directory called backup
 
 ### Modified time and hashes
 
-Uptobox supports neither modified times nor checksums.
+Uptobox supports neither modified times nor checksums. All timestamps
+will read as that set by `--default-time`.
 
 ### Restricted filename characters
 
diff --git a/fs/config.go b/fs/config.go
index 4049b6ea4..d243d794a 100644
--- a/fs/config.go
+++ b/fs/config.go
@@ -144,6 +144,7 @@ type ConfigInfo struct {
 	Metadata                bool
 	ServerSideAcrossConfigs bool
 	TerminalColorMode       TerminalColorMode
+	DefaultTime             Time // time that directories with no time should display
 }
 
 // NewConfig creates a new config with everything set to the default
@@ -185,6 +186,7 @@ func NewConfig() *ConfigInfo {
 	c.FsCacheExpireDuration = 300 * time.Second
 	c.FsCacheExpireInterval = 60 * time.Second
 	c.KvLockTime = 1 * time.Second
+	c.DefaultTime = Time(time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC))
 
 	// Perform a simple check for debug flags to enable debug logging during the flag initialization
 	for argIndex, arg := range os.Args {
diff --git a/fs/config/config.go b/fs/config/config.go
index b2a354037..145f2a2df 100644
--- a/fs/config/config.go
+++ b/fs/config/config.go
@@ -475,6 +475,9 @@ func updateRemote(ctx context.Context, name string, keyValues rc.Params, opt Upd
 	// Set the config
 	for k, v := range keyValues {
 		vStr := fmt.Sprint(v)
+		if strings.ContainsAny(k, "\n\r") || strings.ContainsAny(vStr, "\n\r") {
+			return nil, fmt.Errorf("update remote: invalid key or value contains \\n or \\r")
+		}
 		// Obscure parameter if necessary
 		if _, ok := needsObscure[k]; ok {
 			_, err := obscure.Reveal(vStr)
@@ -483,7 +486,7 @@ func updateRemote(ctx context.Context, name string, keyValues rc.Params, opt Upd
 				// or we are forced to obscure
 				vStr, err = obscure.Obscure(vStr)
 				if err != nil {
-					return nil, fmt.Errorf("UpdateRemote: obscure failed: %w", err)
+					return nil, fmt.Errorf("update remote: obscure failed: %w", err)
 				}
 			}
 		}
diff --git a/fs/config/configflags/configflags.go b/fs/config/configflags/configflags.go
index 96d728fcc..63f107fd0 100644
--- a/fs/config/configflags/configflags.go
+++ b/fs/config/configflags/configflags.go
@@ -144,6 +144,7 @@ func AddFlags(ci *fs.ConfigInfo, flagSet *pflag.FlagSet) {
 	flags.BoolVarP(flagSet, &ci.Metadata, "metadata", "M", ci.Metadata, "If set, preserve metadata when copying objects")
 	flags.BoolVarP(flagSet, &ci.ServerSideAcrossConfigs, "server-side-across-configs", "", ci.ServerSideAcrossConfigs, "Allow server-side operations (e.g. copy) to work across different configs")
 	flags.FVarP(flagSet, &ci.TerminalColorMode, "color", "", "When to show colors (and other ANSI codes) AUTO|NEVER|ALWAYS")
+	flags.FVarP(flagSet, &ci.DefaultTime, "default-time", "", "Time to show if modtime is unknown for files and directories")
 }
 
 // ParseHeaders converts the strings passed in via the header flags into HTTPOptions
diff --git a/fs/dir.go b/fs/dir.go
index 63c51a310..c55bcea23 100644
--- a/fs/dir.go
+++ b/fs/dir.go
@@ -16,6 +16,8 @@ type Dir struct {
 }
 
 // NewDir creates an unspecialized Directory object
+//
+// If the modTime is unknown pass in time.Time{}
 func NewDir(remote string, modTime time.Time) *Dir {
 	return &Dir{
 		remote:  remote,
@@ -75,12 +77,14 @@ func (d *Dir) SetParentID(parent string) *Dir {
 }
 
 // ModTime returns the modification date of the file
-// It should return a best guess if one isn't available
+//
+// If one isn't available it returns the configured --default-dir-time
 func (d *Dir) ModTime(ctx context.Context) time.Time {
 	if !d.modTime.IsZero() {
 		return d.modTime
 	}
-	return time.Now()
+	ci := GetConfig(ctx)
+	return time.Time(ci.DefaultTime)
 }
 
 // Size returns the size of the file
diff --git a/fs/newfs.go b/fs/newfs.go
index cc921bb65..52375e86e 100644
--- a/fs/newfs.go
+++ b/fs/newfs.go
@@ -9,11 +9,18 @@ import (
 	"os"
 	"path/filepath"
 	"strings"
+	"sync"
 
 	"github.com/rclone/rclone/fs/config/configmap"
 	"github.com/rclone/rclone/fs/fspath"
 )
 
+// Store the hashes of the overridden config
+var (
+	overriddenConfigMu sync.Mutex
+	overriddenConfig   = make(map[string]string)
+)
+
 // NewFs makes a new Fs object from the path
 //
 // The path is of the form remote:path
@@ -37,18 +44,25 @@ func NewFs(ctx context.Context, path string) (Fs, error) {
 		extraConfig := overridden.String()
 		//Debugf(nil, "detected overridden config %q", extraConfig)
 		md5sumBinary := md5.Sum([]byte(extraConfig))
-		suffix := base64.RawURLEncoding.EncodeToString(md5sumBinary[:])
+		configHash := base64.RawURLEncoding.EncodeToString(md5sumBinary[:])
 		// 5 characters length is 5*6 = 30 bits of base64
-		const maxLength = 5
-		if len(suffix) > maxLength {
-			suffix = suffix[:maxLength]
+		overriddenConfigMu.Lock()
+		var suffix string
+		for maxLength := 5; ; maxLength++ {
+			suffix = "{" + configHash[:maxLength] + "}"
+			existingExtraConfig, ok := overriddenConfig[suffix]
+			if !ok || existingExtraConfig == extraConfig {
+				break
+			}
 		}
-		suffix = "{" + suffix + "}"
 		Debugf(configName, "detected overridden config - adding %q suffix to name", suffix)
 		// Add the suffix to the config name
 		//
 		// These need to work as filesystem names as the VFS cache will use them
 		configName += suffix
+		// Store the config suffixes for reversing in ConfigString
+		overriddenConfig[suffix] = extraConfig
+		overriddenConfigMu.Unlock()
 	}
 	f, err := fsInfo.NewFs(ctx, configName, fsPath, config)
 	if f != nil && (err == nil || err == ErrorIsFile) {
@@ -105,6 +119,17 @@ func ParseRemote(path string) (fsInfo *RegInfo, configName, fsPath string, conne
 // to configure the Fs as passed to fs.NewFs
 func ConfigString(f Fs) string {
 	name := f.Name()
+	if open := strings.IndexRune(name, '{'); open >= 0 && strings.HasSuffix(name, "}") {
+		suffix := name[open:]
+		overriddenConfigMu.Lock()
+		config, ok := overriddenConfig[suffix]
+		overriddenConfigMu.Unlock()
+		if ok {
+			name = name[:open] + "," + config
+		} else {
+			Errorf(f, "Failed to find config for suffix %q", suffix)
+		}
+	}
 	root := f.Root()
 	if name == "local" && f.Features().IsLocal {
 		return root
diff --git a/fs/operations/lsjson.go b/fs/operations/lsjson.go
index 176c2cb9f..6c94ee6ee 100644
--- a/fs/operations/lsjson.go
+++ b/fs/operations/lsjson.go
@@ -291,7 +291,7 @@ func StatJSON(ctx context.Context, fsrc fs.Fs, remote string, opt *ListJSONOpt)
 	}
 
 	// Could be a file or a directory here
-	if lj.files {
+	if lj.files && !strings.HasSuffix(remote, "/") {
 		// NewObject can return the sentinel errors ErrorObjectNotFound or ErrorIsDir
 		// ErrorObjectNotFound can mean the source is a directory or not found
 		obj, err := fsrc.NewObject(ctx, remote)
diff --git a/fs/operations/operations.go b/fs/operations/operations.go
index 3f6fb6e73..2752b06c3 100644
--- a/fs/operations/operations.go
+++ b/fs/operations/operations.go
@@ -1259,7 +1259,7 @@ type readCloser struct {
 //
 // if count < 0 then it will be ignored
 // if count >= 0 then only that many characters will be output
-func Cat(ctx context.Context, f fs.Fs, w io.Writer, offset, count int64) error {
+func Cat(ctx context.Context, f fs.Fs, w io.Writer, offset, count int64, sep []byte) error {
 	var mu sync.Mutex
 	ci := fs.GetConfig(ctx)
 	return ListFn(ctx, f, func(o fs.Object) {
@@ -1301,6 +1301,13 @@ func Cat(ctx context.Context, f fs.Fs, w io.Writer, offset, count int64) error {
 			err = fs.CountError(err)
 			fs.Errorf(o, "Failed to send to output: %v", err)
 		}
+		if len(sep) >= 0 {
+			_, err = w.Write(sep)
+			if err != nil {
+				err = fs.CountError(err)
+				fs.Errorf(o, "Failed to send separator to output: %v", err)
+			}
+		}
 	})
 }
 
diff --git a/fs/parsetime.go b/fs/parsetime.go
index 7e762451d..504232b20 100644
--- a/fs/parsetime.go
+++ b/fs/parsetime.go
@@ -81,6 +81,11 @@ func (t *Time) UnmarshalJSON(in []byte) error {
 	return t.Set(s)
 }
 
+// MarshalJSON marshals as a time.Time value
+func (t Time) MarshalJSON() ([]byte, error) {
+	return json.Marshal(time.Time(t))
+}
+
 // Scan implements the fmt.Scanner interface
 func (t *Time) Scan(s fmt.ScanState, ch rune) error {
 	token, err := s.Token(true, func(rune) bool { return true })
diff --git a/go.mod b/go.mod
index da6987c4f..988a2b420 100644
--- a/go.mod
+++ b/go.mod
@@ -28,7 +28,7 @@ require (
 	github.com/gdamore/tcell/v2 v2.6.0
 	github.com/go-chi/chi/v5 v5.0.8
 	github.com/google/uuid v1.3.0
-	github.com/hanwen/go-fuse/v2 v2.2.0
+	github.com/hanwen/go-fuse/v2 v2.2.1-0.20230410213758-80c1c8221982
 	github.com/hirochachacha/go-smb2 v1.1.0
 	github.com/iguanesolutions/go-systemd/v5 v5.1.1
 	github.com/jcmturner/gokrb5/v8 v8.4.4
diff --git a/go.sum b/go.sum
index d7a4e3749..a39ae52de 100644
--- a/go.sum
+++ b/go.sum
@@ -248,8 +248,8 @@ github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyC
 github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
 github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
 github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
-github.com/hanwen/go-fuse/v2 v2.2.0 h1:jo5QZYmBLNcl9ovypWaQ5yXMSSV+Ch68xoC3rtZvvBM=
-github.com/hanwen/go-fuse/v2 v2.2.0/go.mod h1:B1nGE/6RBFyBRC1RRnf23UpwCdyJ31eukw34oAKukAc=
+github.com/hanwen/go-fuse/v2 v2.2.1-0.20230410213758-80c1c8221982 h1:2620K4xZZUW81Nef9jqNJNS1UxAO6Fsb//FVDSC3KHw=
+github.com/hanwen/go-fuse/v2 v2.2.1-0.20230410213758-80c1c8221982/go.mod h1:xKwi1cF7nXAOBCXujD5ie0ZKsxc8GGSA1rlMJc+8IJs=
 github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
 github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
 github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
@@ -345,6 +345,8 @@ github.com/minio/minio-go/v6 v6.0.46/go.mod h1:qD0lajrGW49lKZLtXKtCB4X/qkMf0a5tB
 github.com/minio/sha256-simd v0.1.1/go.mod h1:B5e1o+1/KgNmWrSQK08Y6Z1Vb5pwIktudl0J58iy0KM=
 github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
 github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78=
+github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
diff --git a/vfs/read.go b/vfs/read.go
index 53d621a3c..0fb250699 100644
--- a/vfs/read.go
+++ b/vfs/read.go
@@ -7,6 +7,7 @@ import (
 	"io"
 	"os"
 	"sync"
+	"sync/atomic"
 	"time"
 
 	"github.com/rclone/rclone/fs"
@@ -222,7 +223,7 @@ func waitSequential(what string, remote string, cond *sync.Cond, maxWait time.Du
 	var (
 		timeout = time.NewTimer(maxWait)
 		done    = make(chan struct{})
-		abort   = false
+		abort   = int32(0)
 	)
 	go func() {
 		select {
@@ -231,14 +232,14 @@ func waitSequential(what string, remote string, cond *sync.Cond, maxWait time.Du
 			// cond.Broadcast. NB cond.L == mu
 			cond.L.Lock()
 			// set abort flag and give all the waiting goroutines a kick on timeout
-			abort = true
+			atomic.StoreInt32(&abort, 1)
 			fs.Debugf(remote, "aborting in-sequence %s wait, off=%d", what, off)
 			cond.Broadcast()
 			cond.L.Unlock()
 		case <-done:
 		}
 	}()
-	for *poff != off && !abort {
+	for *poff != off && atomic.LoadInt32(&abort) == 0 {
 		fs.Debugf(remote, "waiting for in-sequence %s to %d for %v", what, off, maxWait)
 		cond.Wait()
 	}


Test patch:
diff --git a/backend/googlecloudstorage/googlecloudstorage_test.go b/backend/googlecloudstorage/googlecloudstorage_test.go
index d54f2b061..f1aa02647 100644
--- a/backend/googlecloudstorage/googlecloudstorage_test.go
+++ b/backend/googlecloudstorage/googlecloudstorage_test.go
@@ -6,6 +6,7 @@ import (
 	"testing"
 
 	"github.com/rclone/rclone/backend/googlecloudstorage"
+	"github.com/rclone/rclone/fstest"
 	"github.com/rclone/rclone/fstest/fstests"
 )
 
@@ -16,3 +17,17 @@ func TestIntegration(t *testing.T) {
 		NilObject:  (*googlecloudstorage.Object)(nil),
 	})
 }
+
+func TestIntegration2(t *testing.T) {
+	if *fstest.RemoteName != "" {
+		t.Skip("Skipping as -remote set")
+	}
+	name := "TestGoogleCloudStorage"
+	fstests.Run(t, &fstests.Opt{
+		RemoteName: name + ":",
+		NilObject:  (*googlecloudstorage.Object)(nil),
+		ExtraConfig: []fstests.ExtraConfigItem{
+			{Name: name, Key: "directory_markers", Value: "true"},
+		},
+	})
+}
diff --git a/backend/s3/s3_internal_test.go b/backend/s3/s3_internal_test.go
index ac67ccc81..e23c68bc3 100644
--- a/backend/s3/s3_internal_test.go
+++ b/backend/s3/s3_internal_test.go
@@ -322,7 +322,8 @@ func (f *Fs) InternalTestVersions(t *testing.T) {
 			confPath := strings.Replace(newPath, ":", ",versions:", 1)
 			fNew, err := cache.Get(ctx, confPath)
 			// This should return pointing to a file
-			assert.Equal(t, fs.ErrorIsFile, err)
+			require.Equal(t, fs.ErrorIsFile, err)
+			require.NotNil(t, fNew)
 			// With the directory the directory above
 			assert.Equal(t, dirName, path.Base(fs.ConfigString(fNew)))
 		})
diff --git a/backend/s3/s3_test.go b/backend/s3/s3_test.go
index e226878a5..2cec88185 100644
--- a/backend/s3/s3_test.go
+++ b/backend/s3/s3_test.go
@@ -5,6 +5,7 @@ import (
 	"testing"
 
 	"github.com/rclone/rclone/fs"
+	"github.com/rclone/rclone/fstest"
 	"github.com/rclone/rclone/fstest/fstests"
 )
 
@@ -20,6 +21,24 @@ func TestIntegration(t *testing.T) {
 	})
 }
 
+func TestIntegration2(t *testing.T) {
+	if *fstest.RemoteName != "" {
+		t.Skip("skipping as -remote is set")
+	}
+	name := "TestS3"
+	fstests.Run(t, &fstests.Opt{
+		RemoteName:  name + ":",
+		NilObject:   (*Object)(nil),
+		TiersToTest: []string{"STANDARD", "STANDARD_IA"},
+		ChunkedUpload: fstests.ChunkedUploadConfig{
+			MinChunkSize: minChunkSize,
+		},
+		ExtraConfig: []fstests.ExtraConfigItem{
+			{Name: name, Key: "directory_markers", Value: "true"},
+		},
+	})
+}
+
 func (f *Fs) SetUploadChunkSize(cs fs.SizeSuffix) (fs.SizeSuffix, error) {
 	return f.setUploadChunkSize(cs)
 }
diff --git a/cmd/bisync/bisync_test.go b/cmd/bisync/bisync_test.go
index 11299f012..7e419c4b9 100644
--- a/cmd/bisync/bisync_test.go
+++ b/cmd/bisync/bisync_test.go
@@ -824,8 +824,9 @@ func touchFiles(ctx context.Context, dateStr string, f fs.Fs, dir, glob string)
 			err = nil
 			buf := new(bytes.Buffer)
 			size := obj.Size()
+			separator := ""
 			if size > 0 {
-				err = operations.Cat(ctx, f, buf, 0, size)
+				err = operations.Cat(ctx, f, buf, 0, size, []byte(separator))
 			}
 			info := object.NewStaticObjectInfo(remote, date, size, true, nil, f)
 			if err == nil {
diff --git a/cmd/mount2/mount_test.go b/cmd/mount2/mount_test.go
index c6572a983..e90632f02 100644
--- a/cmd/mount2/mount_test.go
+++ b/cmd/mount2/mount_test.go
@@ -1,16 +1,14 @@
-//go:build linux || (darwin && amd64)
-// +build linux darwin,amd64
+//go:build linux
+// +build linux
 
 package mount2
 
 import (
 	"testing"
 
-	"github.com/rclone/rclone/fstest/testy"
 	"github.com/rclone/rclone/vfs/vfstest"
 )
 
 func TestMount(t *testing.T) {
-	testy.SkipUnreliable(t)
 	vfstest.RunTests(t, false, mount)
 }
diff --git a/fs/cache/cache_test.go b/fs/cache/cache_test.go
index f4e5eb9c2..210fcddbf 100644
--- a/fs/cache/cache_test.go
+++ b/fs/cache/cache_test.go
@@ -23,9 +23,11 @@ func mockNewFs(t *testing.T) func(ctx context.Context, path string) (fs.Fs, erro
 		called++
 		switch path {
 		case "mock:/":
-			return mockfs.NewFs(ctx, "mock", "/"), nil
+			return mockfs.NewFs(ctx, "mock", "/", nil)
 		case "mock:/file.txt", "mock:file.txt":
-			return mockfs.NewFs(ctx, "mock", "/"), fs.ErrorIsFile
+			fMock, err := mockfs.NewFs(ctx, "mock", "/", nil)
+			require.NoError(t, err)
+			return fMock, fs.ErrorIsFile
 		case "mock:/error":
 			return nil, errSentinel
 		}
@@ -117,7 +119,8 @@ func TestGetError(t *testing.T) {
 func TestPut(t *testing.T) {
 	create := mockNewFs(t)
 
-	f := mockfs.NewFs(context.Background(), "mock", "/alien")
+	f, err := mockfs.NewFs(context.Background(), "mock", "/alien", nil)
+	require.NoError(t, err)
 
 	assert.Equal(t, 0, Entries())
 
@@ -147,7 +150,8 @@ func TestPin(t *testing.T) {
 	create := mockNewFs(t)
 
 	// Test pinning and unpinning nonexistent
-	f := mockfs.NewFs(context.Background(), "mock", "/alien")
+	f, err := mockfs.NewFs(context.Background(), "mock", "/alien", nil)
+	require.NoError(t, err)
 	Pin(f)
 	Unpin(f)
 
diff --git a/fs/fingerprint_test.go b/fs/fingerprint_test.go
index 0a69ceddf..713baf4cb 100644
--- a/fs/fingerprint_test.go
+++ b/fs/fingerprint_test.go
@@ -10,11 +10,14 @@ import (
 	"github.com/rclone/rclone/fstest/mockfs"
 	"github.com/rclone/rclone/fstest/mockobject"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func TestFingerprint(t *testing.T) {
 	ctx := context.Background()
-	f := mockfs.NewFs(ctx, "test", "root")
+	fMock, err := mockfs.NewFs(ctx, "test", "root", nil)
+	require.NoError(t, err)
+	f := fMock.(*mockfs.Fs)
 	f.SetHashes(hash.NewHashSet(hash.MD5))
 
 	for i, test := range []struct {
diff --git a/fs/newfs_test.go b/fs/newfs_test.go
new file mode 100644
index 000000000..3f86cd2d5
--- /dev/null
+++ b/fs/newfs_test.go
@@ -0,0 +1,43 @@
+package fs_test
+
+import (
+	"context"
+	"testing"
+
+	"github.com/rclone/rclone/fs"
+	"github.com/rclone/rclone/fstest/mockfs"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestNewFs(t *testing.T) {
+	ctx := context.Background()
+
+	// Register mockfs temporarily
+	oldRegistry := fs.Registry
+	mockfs.Register()
+	defer func() {
+		fs.Registry = oldRegistry
+	}()
+
+	f1, err := fs.NewFs(ctx, ":mockfs:/tmp")
+	require.NoError(t, err)
+	assert.Equal(t, ":mockfs", f1.Name())
+	assert.Equal(t, "/tmp", f1.Root())
+
+	assert.Equal(t, ":mockfs:/tmp", fs.ConfigString(f1))
+
+	f2, err := fs.NewFs(ctx, ":mockfs,potato:/tmp")
+	require.NoError(t, err)
+	assert.Equal(t, ":mockfs{S_NHG}", f2.Name())
+	assert.Equal(t, "/tmp", f2.Root())
+
+	assert.Equal(t, ":mockfs,potato='true':/tmp", fs.ConfigString(f2))
+
+	f3, err := fs.NewFs(ctx, ":mockfs,potato='true':/tmp")
+	require.NoError(t, err)
+	assert.Equal(t, ":mockfs{S_NHG}", f3.Name())
+	assert.Equal(t, "/tmp", f3.Root())
+
+	assert.Equal(t, ":mockfs,potato='true':/tmp", fs.ConfigString(f3))
+}
diff --git a/fs/operations/multithread_test.go b/fs/operations/multithread_test.go
index a036139e5..fa982cf59 100644
--- a/fs/operations/multithread_test.go
+++ b/fs/operations/multithread_test.go
@@ -19,9 +19,11 @@ import (
 func TestDoMultiThreadCopy(t *testing.T) {
 	ctx := context.Background()
 	ci := fs.GetConfig(ctx)
-	f := mockfs.NewFs(ctx, "potato", "")
+	f, err := mockfs.NewFs(ctx, "potato", "", nil)
+	require.NoError(t, err)
 	src := mockobject.New("file.txt").WithContent([]byte(random.String(100)), mockobject.SeekModeNone)
-	srcFs := mockfs.NewFs(ctx, "sausage", "")
+	srcFs, err := mockfs.NewFs(ctx, "sausage", "", nil)
+	require.NoError(t, err)
 	src.SetFs(srcFs)
 
 	oldStreams := ci.MultiThreadStreams
diff --git a/fs/operations/operations_test.go b/fs/operations/operations_test.go
index 45a2259be..5125e5865 100644
--- a/fs/operations/operations_test.go
+++ b/fs/operations/operations_test.go
@@ -529,23 +529,25 @@ func TestCat(t *testing.T) {
 	r.CheckRemoteItems(t, file1, file2)
 
 	for _, test := range []struct {
-		offset int64
-		count  int64
-		a      string
-		b      string
+		offset    int64
+		count     int64
+		separator string
+		a         string
+		b         string
 	}{
-		{0, -1, "ABCDEFGHIJ", "012345678"},
-		{0, 5, "ABCDE", "01234"},
-		{-3, -1, "HIJ", "678"},
-		{1, 3, "BCD", "123"},
+		{0, -1, "", "ABCDEFGHIJ", "012345678"},
+		{0, 5, "", "ABCDE", "01234"},
+		{-3, -1, "", "HIJ", "678"},
+		{1, 3, "", "BCD", "123"},
+		{0, -1, "\n", "ABCDEFGHIJ", "012345678"},
 	} {
 		var buf bytes.Buffer
-		err := operations.Cat(ctx, r.Fremote, &buf, test.offset, test.count)
+		err := operations.Cat(ctx, r.Fremote, &buf, test.offset, test.count, []byte(test.separator))
 		require.NoError(t, err)
 		res := buf.String()
 
-		if res != test.a+test.b && res != test.b+test.a {
-			t.Errorf("Incorrect output from Cat(%d,%d): %q", test.offset, test.count, res)
+		if res != test.a+test.separator+test.b+test.separator && res != test.b+test.separator+test.a+test.separator {
+			t.Errorf("Incorrect output from Cat(%d,%d,%s): %q", test.offset, test.count, test.separator, res)
 		}
 	}
 }
diff --git a/fs/parsetime_test.go b/fs/parsetime_test.go
index 21d34c1ee..796217b5b 100644
--- a/fs/parsetime_test.go
+++ b/fs/parsetime_test.go
@@ -153,3 +153,23 @@ func TestParseTimeUnmarshalJSON(t *testing.T) {
 		assert.Equal(t, Time(test.want), parsedTime, test.in)
 	}
 }
+
+func TestParseTimeMarshalJSON(t *testing.T) {
+	for _, test := range []struct {
+		in   time.Time
+		want string
+		err  bool
+	}{
+		{time.Time{}, `"0001-01-01T00:00:00Z"`, false},
+		{time.Date(2022, 03, 26, 17, 48, 19, 0, time.UTC), `"2022-03-26T17:48:19Z"`, false},
+	} {
+		gotBytes, err := json.Marshal(test.in)
+		got := string(gotBytes)
+		if test.err {
+			require.Error(t, err, test.in)
+		} else {
+			require.NoError(t, err, test.in)
+		}
+		assert.Equal(t, test.want, got, test.in)
+	}
+}
diff --git a/fs/rc/cache_test.go b/fs/rc/cache_test.go
index bbda7e0b6..6d7c7730e 100644
--- a/fs/rc/cache_test.go
+++ b/fs/rc/cache_test.go
@@ -12,7 +12,8 @@ import (
 )
 
 func mockNewFs(t *testing.T) func() {
-	f := mockfs.NewFs(context.Background(), "mock", "mock")
+	f, err := mockfs.NewFs(context.Background(), "mock", "mock", nil)
+	require.NoError(t, err)
 	cache.Put("/", f)
 	cache.Put("mock:/", f)
 	cache.Put(":mock:/", f)
diff --git a/fs/walk/walk_test.go b/fs/walk/walk_test.go
index cbfa82ce8..d47991486 100644
--- a/fs/walk/walk_test.go
+++ b/fs/walk/walk_test.go
@@ -798,7 +798,8 @@ func TestListR(t *testing.T) {
 		mockobject.Object("dir/b"),
 		mockobject.Object("dir/c"),
 	}
-	f := mockfs.NewFs(ctx, "mock", "/")
+	f, err := mockfs.NewFs(ctx, "mock", "/", nil)
+	require.NoError(t, err)
 	var got []string
 	clearCallback := func() {
 		got = nil
diff --git a/fstest/fstests/fstests.go b/fstest/fstests/fstests.go
index af9c6d704..46a6ff4b5 100644
--- a/fstest/fstests/fstests.go
+++ b/fstest/fstests/fstests.go
@@ -526,23 +526,33 @@ func Run(t *testing.T, opt *Opt) {
 	t.Run("FsName", func(t *testing.T) {
 		skipIfNotOk(t)
 		got := removeConfigID(f.Name())
-		want := remoteName[:strings.LastIndex(remoteName, ":")+1]
+		var want string
 		if isLocalRemote {
-			want = "local:"
+			want = "local"
+		} else {
+			want = remoteName[:strings.LastIndex(remoteName, ":")]
+			comma := strings.IndexRune(remoteName, ',')
+			if comma >= 0 {
+				want = want[:comma]
+			}
 		}
-		require.Equal(t, want, got+":")
+		require.Equal(t, want, got)
 	})
 
 	// TestFsRoot tests the Root method
 	t.Run("FsRoot", func(t *testing.T) {
 		skipIfNotOk(t)
-		name := removeConfigID(f.Name()) + ":"
-		root := f.Root()
+		got := f.Root()
+		want := subRemoteName
+		colon := strings.LastIndex(want, ":")
+		if colon >= 0 {
+			want = want[colon+1:]
+		}
 		if isLocalRemote {
 			// only check last path element on local
-			require.Equal(t, filepath.Base(subRemoteName), filepath.Base(root))
+			require.Equal(t, filepath.Base(subRemoteName), filepath.Base(got))
 		} else {
-			require.Equal(t, subRemoteName, name+root)
+			require.Equal(t, want, got)
 		}
 	})
 
@@ -1512,9 +1522,12 @@ func Run(t *testing.T, opt *Opt) {
 
 				file1.Size = int64(buf.Len())
 				obj := findObject(ctx, t, f, file1.Path)
-				obji := object.NewStaticObjectInfo(file1.Path, file1.ModTime, int64(len(contents)), true, nil, obj.Fs())
+				remoteBefore := obj.Remote()
+				obji := object.NewStaticObjectInfo(file1.Path+"-should-be-ignored.bin", file1.ModTime, int64(len(contents)), true, nil, obj.Fs())
 				err := obj.Update(ctx, in, obji)
 				require.NoError(t, err)
+				remoteAfter := obj.Remote()
+				assert.Equal(t, remoteBefore, remoteAfter, "Remote should not change")
 				file1.Hashes = hash.Sums()
 
 				// check the object has been updated
diff --git a/fstest/mockfs/mockfs.go b/fstest/mockfs/mockfs.go
index 62489c2f6..68df1f6b4 100644
--- a/fstest/mockfs/mockfs.go
+++ b/fstest/mockfs/mockfs.go
@@ -10,9 +10,24 @@ import (
 	"time"
 
 	"github.com/rclone/rclone/fs"
+	"github.com/rclone/rclone/fs/config/configmap"
 	"github.com/rclone/rclone/fs/hash"
 )
 
+// Register with Fs
+func Register() {
+	fs.Register(&fs.RegInfo{
+		Name:        "mockfs",
+		Description: "Mock FS",
+		NewFs:       NewFs,
+		Options: []fs.Option{{
+			Name:     "potato",
+			Help:     "Does it have a potato?.",
+			Required: true,
+		}},
+	})
+}
+
 // Fs is a minimal mock Fs
 type Fs struct {
 	name     string        // the name of the remote
@@ -26,13 +41,13 @@ type Fs struct {
 var ErrNotImplemented = errors.New("not implemented")
 
 // NewFs returns a new mock Fs
-func NewFs(ctx context.Context, name, root string) *Fs {
+func NewFs(ctx context.Context, name string, root string, config configmap.Mapper) (fs.Fs, error) {
 	f := &Fs{
 		name: name,
 		root: root,
 	}
 	f.features = (&fs.Features{}).Fill(ctx, f)
-	return f
+	return f, nil
 }
 
 // AddObject adds an Object for List to return
diff --git a/fstest/test_all/config.yaml b/fstest/test_all/config.yaml
index 637e2a407..a75508914 100644
--- a/fstest/test_all/config.yaml
+++ b/fstest/test_all/config.yaml
@@ -129,6 +129,9 @@ backends:
  - backend:  "googlecloudstorage"
    remote:   "TestGoogleCloudStorage:"
    fastlist: true
+ - backend:  "googlecloudstorage"
+   remote:   "TestGoogleCloudStorage,directory_markers:"
+   fastlist: true
  - backend:  "googlephotos"
    remote:   "TestGooglePhotos:"
    tests:
@@ -167,6 +170,9 @@ backends:
  - backend:  "s3"
    remote:   "TestS3:"
    fastlist: true
+ - backend:  "s3"
+   remote:   "TestS3,directory_markers:"
+   fastlist: true
  - backend:  "s3"
    remote:   "TestS3Minio:"
    fastlist: true
diff --git a/vfs/file_test.go b/vfs/file_test.go
index 728d8c699..6688749d2 100644
--- a/vfs/file_test.go
+++ b/vfs/file_test.go
@@ -193,7 +193,9 @@ func TestFileOpenReadUnknownSize(t *testing.T) {
 	assert.Equal(t, int64(-1), o.Size())
 
 	// add it to a mock fs
-	f := mockfs.NewFs(context.Background(), "test", "root")
+	fMock, err := mockfs.NewFs(context.Background(), "test", "root", nil)
+	require.NoError(t, err)
+	f := fMock.(*mockfs.Fs)
 	f.AddObject(o)
 	testObj, err := f.NewObject(ctx, remote)
 	require.NoError(t, err)