Repository: rclone/rclone
Pull request #6276 (2022-06-24 21:21:08)

Fix missing response when using custom auth handler in http serve
#### What is the purpose of this change?

I was running the staticcheck linter and investigated the following reported issues:

> this value of r is never used (SA4006)
> WithContext is a pure function but its return value is ignored (SA4017)

On the following line:

https://github.com/rclone/rclone/blob/fdd2f8e6d21d1cf95b33fd2bb09f9c1caef0069d/lib/http/auth/basic.go#L114

I'm not sure I understand this line and the `CustomAuth` function it is in, but my impression is that staticcheck is in fact correct here, and that this line does nothing as the implementation currently is.

```
// WithContext returns a shallow copy of r with its context changed
// to ctx. The provided ctx must be non-nil.
//
// For outgoing client request, the context controls the entire
// lifetime of a request and its response: obtaining a connection,
// sending the request, and reading the response headers and body.
//
// To create a new request with a context, use NewRequestWithContext.
// To change the context of a request, such as an incoming request you
// want to modify before sending back out, use Request.Clone. Between
// those two uses, it's rare to need WithContext.
```

If this is true then is it not the case that the CustomAuth function does nothing at all, on success, and that an http serve would not work at all if "custom auth" is enabled (I have no idea how to do that)? Wild guess: It should have called `next.ServeHTTP(w, r)`, using the copy of `r` returned from `WithContext`, just like the handler implementation of the other auth implementations (HtPasswdAuth and SingleAuth).

https://github.com/rclone/rclone/blob/fdd2f8e6d21d1cf95b33fd2bb09f9c1caef0069d/lib/http/auth/basic.go#L73

#### Was the change discussed in an issue or in the forum before?

<!--
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).
- [ ] I have added tests for all changes in this PR if appropriate.
- [ ] 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 :-)


albertony: > This is part of the unfinished http refactor and I think your fix is more or less correct but needs to go down one line!

Yes, that was a blunder. I pushed an update to fix this just for now, and will convert this PR to draft until we can conclude on how to proceed. It will probably be some weeks until I have time to pick up on this again.

Patch:
diff --git a/backend/combine/combine.go b/backend/combine/combine.go
index 934ed76f4..36fdc69c6 100644
--- a/backend/combine/combine.go
+++ b/backend/combine/combine.go
@@ -31,6 +31,9 @@ func init() {
 		Name:        "combine",
 		Description: "Combine several remotes into one",
 		NewFs:       NewFs,
+		MetadataInfo: &fs.MetadataInfo{
+			Help: `Any metadata supported by the underlying remote is read and written.`,
+		},
 		Options: []fs.Option{{
 			Name: "upstreams",
 			Help: `Upstreams for combining
@@ -222,6 +225,9 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (outFs fs
 		BucketBased:             true,
 		SetTier:                 true,
 		GetTier:                 true,
+		ReadMetadata:            true,
+		WriteMetadata:           true,
+		UserMetadata:            true,
 	}).Fill(ctx, f)
 	canMove := true
 	for _, u := range f.upstreams {
@@ -925,6 +931,45 @@ func (o *Object) UnWrap() fs.Object {
 	return o.Object
 }
 
+// GetTier returns storage tier or class of the Object
+func (o *Object) GetTier() string {
+	do, ok := o.Object.(fs.GetTierer)
+	if !ok {
+		return ""
+	}
+	return do.GetTier()
+}
+
+// ID returns the ID of the Object if known, or "" if not
+func (o *Object) ID() string {
+	do, ok := o.Object.(fs.IDer)
+	if !ok {
+		return ""
+	}
+	return do.ID()
+}
+
+// Metadata returns metadata for an object
+//
+// It should return nil if there is no Metadata
+func (o *Object) Metadata(ctx context.Context) (fs.Metadata, error) {
+	do, ok := o.Object.(fs.Metadataer)
+	if !ok {
+		return nil, nil
+	}
+	return do.Metadata(ctx)
+}
+
+// SetTier performs changing storage tier of the Object if
+// multiple storage classes supported
+func (o *Object) SetTier(tier string) error {
+	do, ok := o.Object.(fs.SetTierer)
+	if !ok {
+		return errors.New("underlying remote does not support SetTier")
+	}
+	return do.SetTier(tier)
+}
+
 // Check the interfaces are satisfied
 var (
 	_ fs.Fs              = (*Fs)(nil)
@@ -938,4 +983,5 @@ var (
 	_ fs.Abouter         = (*Fs)(nil)
 	_ fs.ListRer         = (*Fs)(nil)
 	_ fs.Shutdowner      = (*Fs)(nil)
+	_ fs.FullObject      = (*Object)(nil)
 )
diff --git a/backend/compress/compress.go b/backend/compress/compress.go
index dcc54a11c..9033b38ff 100644
--- a/backend/compress/compress.go
+++ b/backend/compress/compress.go
@@ -70,6 +70,9 @@ func init() {
 		Name:        "compress",
 		Description: "Compress a remote",
 		NewFs:       NewFs,
+		MetadataInfo: &fs.MetadataInfo{
+			Help: `Any metadata supported by the underlying remote is read and written.`,
+		},
 		Options: []fs.Option{{
 			Name:     "remote",
 			Help:     "Remote to compress.",
@@ -180,6 +183,9 @@ func NewFs(ctx context.Context, name, rpath string, m configmap.Mapper) (fs.Fs,
 		SetTier:                 true,
 		BucketBased:             true,
 		CanHaveEmptyDirectories: true,
+		ReadMetadata:            true,
+		WriteMetadata:           true,
+		UserMetadata:            true,
 	}).Fill(ctx, f).Mask(ctx, wrappedFs).WrapsFs(f, wrappedFs)
 	// We support reading MIME types no matter the wrapped fs
 	f.features.ReadMimeType = true
@@ -1214,6 +1220,21 @@ func (o *Object) MimeType(ctx context.Context) string {
 	return o.meta.MimeType
 }
 
+// Metadata returns metadata for an object
+//
+// It should return nil if there is no Metadata
+func (o *Object) Metadata(ctx context.Context) (fs.Metadata, error) {
+	err := o.loadMetadataIfNotLoaded(ctx)
+	if err != nil {
+		return nil, err
+	}
+	do, ok := o.mo.(fs.Metadataer)
+	if !ok {
+		return nil, nil
+	}
+	return do.Metadata(ctx)
+}
+
 // Hash returns the selected checksum of the file
 // If no checksum is available it returns ""
 func (o *Object) Hash(ctx context.Context, ht hash.Type) (string, error) {
@@ -1360,6 +1381,51 @@ func (o *ObjectInfo) Hash(ctx context.Context, ht hash.Type) (string, error) {
 	return "", nil // cannot know the checksum
 }
 
+// ID returns the ID of the Object if known, or "" if not
+func (o *ObjectInfo) ID() string {
+	do, ok := o.src.(fs.IDer)
+	if !ok {
+		return ""
+	}
+	return do.ID()
+}
+
+// MimeType returns the content type of the Object if
+// known, or "" if not
+func (o *ObjectInfo) MimeType(ctx context.Context) string {
+	do, ok := o.src.(fs.MimeTyper)
+	if !ok {
+		return ""
+	}
+	return do.MimeType(ctx)
+}
+
+// UnWrap returns the Object that this Object is wrapping or
+// nil if it isn't wrapping anything
+func (o *ObjectInfo) UnWrap() fs.Object {
+	return fs.UnWrapObjectInfo(o.src)
+}
+
+// Metadata returns metadata for an object
+//
+// It should return nil if there is no Metadata
+func (o *ObjectInfo) Metadata(ctx context.Context) (fs.Metadata, error) {
+	do, ok := o.src.(fs.Metadataer)
+	if !ok {
+		return nil, nil
+	}
+	return do.Metadata(ctx)
+}
+
+// GetTier returns storage tier or class of the Object
+func (o *ObjectInfo) GetTier() string {
+	do, ok := o.src.(fs.GetTierer)
+	if !ok {
+		return ""
+	}
+	return do.GetTier()
+}
+
 // ID returns the ID of the Object if known, or "" if not
 func (o *Object) ID() string {
 	do, ok := o.Object.(fs.IDer)
@@ -1412,11 +1478,6 @@ var (
 	_ fs.ChangeNotifier  = (*Fs)(nil)
 	_ fs.PublicLinker    = (*Fs)(nil)
 	_ fs.Shutdowner      = (*Fs)(nil)
-	_ fs.ObjectInfo      = (*ObjectInfo)(nil)
-	_ fs.GetTierer       = (*Object)(nil)
-	_ fs.SetTierer       = (*Object)(nil)
-	_ fs.Object          = (*Object)(nil)
-	_ fs.ObjectUnWrapper = (*Object)(nil)
-	_ fs.IDer            = (*Object)(nil)
-	_ fs.MimeTyper       = (*Object)(nil)
+	_ fs.FullObjectInfo  = (*ObjectInfo)(nil)
+	_ fs.FullObject      = (*Object)(nil)
 )
diff --git a/backend/crypt/crypt.go b/backend/crypt/crypt.go
index 33e57cdb9..6f599cafb 100644
--- a/backend/crypt/crypt.go
+++ b/backend/crypt/crypt.go
@@ -28,6 +28,9 @@ func init() {
 		Description: "Encrypt/Decrypt a remote",
 		NewFs:       NewFs,
 		CommandHelp: commandHelp,
+		MetadataInfo: &fs.MetadataInfo{
+			Help: `Any metadata supported by the underlying remote is read and written.`,
+		},
 		Options: []fs.Option{{
 			Name:     "remote",
 			Help:     "Remote to encrypt/decrypt.\n\nNormally should contain a ':' and a path, e.g. \"myremote:path/to/dir\",\n\"myremote:bucket\" or maybe \"myremote:\" (not recommended).",
@@ -241,6 +244,9 @@ func NewFs(ctx context.Context, name, rpath string, m configmap.Mapper) (fs.Fs,
 		SetTier:                 true,
 		GetTier:                 true,
 		ServerSideAcrossConfigs: opt.ServerSideAcrossConfigs,
+		ReadMetadata:            true,
+		WriteMetadata:           true,
+		UserMetadata:            true,
 	}).Fill(ctx, f).Mask(ctx, wrappedFs).WrapsFs(f, wrappedFs)
 
 	return f, err
@@ -1056,6 +1062,50 @@ func (o *ObjectInfo) Hash(ctx context.Context, hash hash.Type) (string, error) {
 	return "", nil
 }
 
+// GetTier returns storage tier or class of the Object
+func (o *ObjectInfo) GetTier() string {
+	do, ok := o.ObjectInfo.(fs.GetTierer)
+	if !ok {
+		return ""
+	}
+	return do.GetTier()
+}
+
+// ID returns the ID of the Object if known, or "" if not
+func (o *ObjectInfo) ID() string {
+	do, ok := o.ObjectInfo.(fs.IDer)
+	if !ok {
+		return ""
+	}
+	return do.ID()
+}
+
+// Metadata returns metadata for an object
+//
+// It should return nil if there is no Metadata
+func (o *ObjectInfo) Metadata(ctx context.Context) (fs.Metadata, error) {
+	do, ok := o.ObjectInfo.(fs.Metadataer)
+	if !ok {
+		return nil, nil
+	}
+	return do.Metadata(ctx)
+}
+
+// MimeType returns the content type of the Object if
+// known, or "" if not
+//
+// This is deliberately unsupported so we don't leak mime type info by
+// default.
+func (o *ObjectInfo) MimeType(ctx context.Context) string {
+	return ""
+}
+
+// UnWrap returns the Object that this Object is wrapping or
+// nil if it isn't wrapping anything
+func (o *ObjectInfo) UnWrap() fs.Object {
+	return fs.UnWrapObjectInfo(o.ObjectInfo)
+}
+
 // ID returns the ID of the Object if known, or "" if not
 func (o *Object) ID() string {
 	do, ok := o.Object.(fs.IDer)
@@ -1084,6 +1134,26 @@ func (o *Object) GetTier() string {
 	return do.GetTier()
 }
 
+// Metadata returns metadata for an object
+//
+// It should return nil if there is no Metadata
+func (o *Object) Metadata(ctx context.Context) (fs.Metadata, error) {
+	do, ok := o.Object.(fs.Metadataer)
+	if !ok {
+		return nil, nil
+	}
+	return do.Metadata(ctx)
+}
+
+// MimeType returns the content type of the Object if
+// known, or "" if not
+//
+// This is deliberately unsupported so we don't leak mime type info by
+// default.
+func (o *Object) MimeType(ctx context.Context) string {
+	return ""
+}
+
 // Check the interfaces are satisfied
 var (
 	_ fs.Fs              = (*Fs)(nil)
@@ -1106,10 +1176,6 @@ var (
 	_ fs.UserInfoer      = (*Fs)(nil)
 	_ fs.Disconnecter    = (*Fs)(nil)
 	_ fs.Shutdowner      = (*Fs)(nil)
-	_ fs.ObjectInfo      = (*ObjectInfo)(nil)
-	_ fs.Object          = (*Object)(nil)
-	_ fs.ObjectUnWrapper = (*Object)(nil)
-	_ fs.IDer            = (*Object)(nil)
-	_ fs.SetTierer       = (*Object)(nil)
-	_ fs.GetTierer       = (*Object)(nil)
+	_ fs.FullObjectInfo  = (*ObjectInfo)(nil)
+	_ fs.FullObject      = (*Object)(nil)
 )
diff --git a/backend/hasher/hasher.go b/backend/hasher/hasher.go
index 1870983e0..e80daedee 100644
--- a/backend/hasher/hasher.go
+++ b/backend/hasher/hasher.go
@@ -27,6 +27,9 @@ func init() {
 		Name:        "hasher",
 		Description: "Better checksums for other remotes",
 		NewFs:       NewFs,
+		MetadataInfo: &fs.MetadataInfo{
+			Help: `Any metadata supported by the underlying remote is read and written.`,
+		},
 		CommandHelp: commandHelp,
 		Options: []fs.Option{{
 			Name:     "remote",
@@ -158,6 +161,11 @@ func NewFs(ctx context.Context, fsname, rpath string, cmap configmap.Mapper) (fs
 		IsLocal:                 true,
 		ReadMimeType:            true,
 		WriteMimeType:           true,
+		SetTier:                 true,
+		GetTier:                 true,
+		ReadMetadata:            true,
+		WriteMetadata:           true,
+		UserMetadata:            true,
 	}
 	f.features = stubFeatures.Fill(ctx, f).Mask(ctx, f.Fs).WrapsFs(f, f.Fs)
 
@@ -485,6 +493,17 @@ func (o *Object) MimeType(ctx context.Context) string {
 	return ""
 }
 
+// Metadata returns metadata for an object
+//
+// It should return nil if there is no Metadata
+func (o *Object) Metadata(ctx context.Context) (fs.Metadata, error) {
+	do, ok := o.Object.(fs.Metadataer)
+	if !ok {
+		return nil, nil
+	}
+	return do.Metadata(ctx)
+}
+
 // Check the interfaces are satisfied
 var (
 	_ fs.Fs              = (*Fs)(nil)
@@ -507,10 +526,5 @@ var (
 	_ fs.UserInfoer      = (*Fs)(nil)
 	_ fs.Disconnecter    = (*Fs)(nil)
 	_ fs.Shutdowner      = (*Fs)(nil)
-	_ fs.Object          = (*Object)(nil)
-	_ fs.ObjectUnWrapper = (*Object)(nil)
-	_ fs.IDer            = (*Object)(nil)
-	_ fs.SetTierer       = (*Object)(nil)
-	_ fs.GetTierer       = (*Object)(nil)
-	_ fs.MimeTyper       = (*Object)(nil)
+	_ fs.FullObject      = (*Object)(nil)
 )
diff --git a/backend/local/local.go b/backend/local/local.go
index 1a8908720..3a83bd262 100644
--- a/backend/local/local.go
+++ b/backend/local/local.go
@@ -42,6 +42,18 @@ func init() {
 		Description: "Local Disk",
 		NewFs:       NewFs,
 		CommandHelp: commandHelp,
+		MetadataInfo: &fs.MetadataInfo{
+			System: systemMetadataInfo,
+			Help: `Depending on which OS is in use the local backend may return only some
+of the system metadata. Setting system metadata is supported on all
+OSes but setting user metadata is only supported on linux, freebsd,
+netbsd, macOS and Solaris. It is **not** supported on Windows yet
+([see pkg/attrs#47](https://github.com/pkg/xattr/issues/47)).
+
+User metadata is stored as extended attributes (which may not be
+supported by all file systems) under the "user.*" prefix.
+`,
+		},
 		Options: []fs.Option{{
 			Name:     "nounc",
 			Help:     "Disable UNC (long path names) conversion on Windows.",
@@ -280,6 +292,9 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
 		CanHaveEmptyDirectories: true,
 		IsLocal:                 true,
 		SlowHash:                true,
+		ReadMetadata:            true,
+		WriteMetadata:           true,
+		UserMetadata:            xattrSupported, // can only R/W general purpose metadata if xattrs are supported
 	}).Fill(ctx, f)
 	if opt.FollowSymlinks {
 		f.lstat = os.Stat
@@ -938,17 +953,22 @@ func (o *Object) ModTime(ctx context.Context) time.Time {
 	return o.modTime
 }
 
+// Set the atime and ltime of the object
+func (o *Object) setTimes(atime, mtime time.Time) (err error) {
+	if o.translatedLink {
+		err = lChtimes(o.path, atime, mtime)
+	} else {
+		err = os.Chtimes(o.path, atime, mtime)
+	}
+	return err
+}
+
 // SetModTime sets the modification time of the local fs object
 func (o *Object) SetModTime(ctx context.Context, modTime time.Time) error {
 	if o.fs.opt.NoSetModTime {
 		return nil
 	}
-	var err error
-	if o.translatedLink {
-		err = lChtimes(o.path, modTime, modTime)
-	} else {
-		err = os.Chtimes(o.path, modTime, modTime)
-	}
+	err := o.setTimes(modTime, modTime)
 	if err != nil {
 		return err
 	}
@@ -1223,6 +1243,16 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
 		return err
 	}
 
+	// Fetch and set metadata if --metadata is in use
+	meta, err := fs.GetMetadataOptions(ctx, src, options)
+	if err != nil {
+		return fmt.Errorf("failed to read metadata from source object: %w", err)
+	}
+	err = o.writeMetadata(meta)
+	if err != nil {
+		return fmt.Errorf("failed to set metadata: %w", err)
+	}
+
 	// ReRead info now that we have finished
 	return o.lstat()
 }
@@ -1321,6 +1351,34 @@ func (o *Object) Remove(ctx context.Context) error {
 	return remove(o.path)
 }
 
+// Metadata returns metadata for an object
+//
+// It should return nil if there is no Metadata
+func (o *Object) Metadata(ctx context.Context) (metadata fs.Metadata, err error) {
+	metadata, err = o.getXattr()
+	if err != nil {
+		return nil, err
+	}
+	err = o.readMetadataFromFile(&metadata)
+	if err != nil {
+		return nil, err
+	}
+	return metadata, nil
+}
+
+// Write the metadata on the object
+func (o *Object) writeMetadata(metadata fs.Metadata) (err error) {
+	err = o.setXattr(metadata)
+	if err != nil {
+		return err
+	}
+	err = o.writeMetadataToFile(metadata)
+	if err != nil {
+		return err
+	}
+	return err
+}
+
 func cleanRootPath(s string, noUNC bool, enc encoder.MultiEncoder) string {
 	if runtime.GOOS == "windows" {
 		if !filepath.IsAbs(s) && !strings.HasPrefix(s, "\\") {
@@ -1360,4 +1418,5 @@ var (
 	_ fs.Commander      = &Fs{}
 	_ fs.OpenWriterAter = &Fs{}
 	_ fs.Object         = &Object{}
+	_ fs.Metadataer     = &Object{}
 )
diff --git a/backend/local/metadata.go b/backend/local/metadata.go
new file mode 100644
index 000000000..097abedf1
--- /dev/null
+++ b/backend/local/metadata.go
@@ -0,0 +1,138 @@
+package local
+
+import (
+	"fmt"
+	"os"
+	"runtime"
+	"strconv"
+	"time"
+
+	"github.com/rclone/rclone/fs"
+)
+
+const metadataTimeFormat = time.RFC3339Nano
+
+// system metadata keys which this backend owns
+//
+// not all values supported on all OSes
+var systemMetadataInfo = map[string]fs.MetadataHelp{
+	"mode": {
+		Help:    "File type and mode",
+		Type:    "octal, unix style",
+		Example: "0100664",
+	},
+	"uid": {
+		Help:    "User ID of owner",
+		Type:    "decimal number",
+		Example: "500",
+	},
+	"gid": {
+		Help:    "Group ID of owner",
+		Type:    "decimal number",
+		Example: "500",
+	},
+	"rdev": {
+		Help:    "Device ID (if special file)",
+		Type:    "hexadecimal",
+		Example: "1abc",
+	},
+	"atime": {
+		Help:    "Time of last access",
+		Type:    "RFC 3339",
+		Example: "2006-01-02T15:04:05.999999999Z07:00",
+	},
+	"mtime": {
+		Help:    "Time of last modification",
+		Type:    "RFC 3339",
+		Example: "2006-01-02T15:04:05.999999999Z07:00",
+	},
+	"btime": {
+		Help:    "Time of file birth (creation)",
+		Type:    "RFC 3339",
+		Example: "2006-01-02T15:04:05.999999999Z07:00",
+	},
+}
+
+// parse a time string from metadata with key
+func (o *Object) parseMetadataTime(m fs.Metadata, key string) (t time.Time, ok bool) {
+	value, ok := m[key]
+	if ok {
+		var err error
+		t, err = time.Parse(metadataTimeFormat, value)
+		if err != nil {
+			fs.Debugf(o, "failed to parse metadata %s: %q: %v", key, value, err)
+			ok = false
+		}
+	}
+	return t, ok
+}
+
+// parse am int from metadata with key and base
+func (o *Object) parseMetadataInt(m fs.Metadata, key string, base int) (result int, ok bool) {
+	value, ok := m[key]
+	if ok {
+		var err error
+		result64, err := strconv.ParseInt(value, base, 64)
+		if err != nil {
+			fs.Debugf(o, "failed to parse metadata %s: %q: %v", key, value, err)
+			ok = false
+		}
+		result = int(result64)
+	}
+	return result, ok
+}
+
+// Write the metadata into the file
+//
+// It isn't possible to set the ctime and btime under Unix
+func (o *Object) writeMetadataToFile(m fs.Metadata) (outErr error) {
+	var err error
+	atime, atimeOK := o.parseMetadataTime(m, "atime")
+	mtime, mtimeOK := o.parseMetadataTime(m, "mtime")
+	btime, btimeOK := o.parseMetadataTime(m, "btime")
+	if atimeOK || mtimeOK {
+		if atimeOK && !mtimeOK {
+			mtime = atime
+		}
+		if !atimeOK && mtimeOK {
+			atime = mtime
+		}
+		err = o.setTimes(atime, mtime)
+		if err != nil {
+			outErr = fmt.Errorf("failed to set times: %w", err)
+		}
+	}
+	if haveSetBTime {
+		if btimeOK {
+			err = setBTime(o.path, btime)
+			if err != nil {
+				outErr = fmt.Errorf("failed to set birth (creation) time: %w", err)
+			}
+		}
+	}
+	uid, hasUID := o.parseMetadataInt(m, "uid", 10)
+	gid, hasGID := o.parseMetadataInt(m, "gid", 10)
+	if hasUID {
+		// FIXME should read UID and GID of current user and only attempt to set it if different
+		if !hasGID {
+			gid = uid
+		}
+		if runtime.GOOS == "windows" || runtime.GOOS == "plan9" {
+			fs.Debugf(o, "Ignoring request to set ownership %o.%o on this OS", gid, uid)
+		} else {
+			err = os.Chown(o.path, uid, gid)
+			if err != nil {
+				outErr = fmt.Errorf("failed to change ownership: %w", err)
+			}
+		}
+	}
+	mode, hasMode := o.parseMetadataInt(m, "mode", 8)
+	if hasMode {
+		err = os.Chmod(o.path, os.FileMode(mode))
+		if err != nil {
+			outErr = fmt.Errorf("failed to change permissions: %w", err)
+		}
+	}
+	// FIXME not parsing rdev yet
+	return outErr
+}
diff --git a/backend/local/metadata_bsd.go b/backend/local/metadata_bsd.go
new file mode 100644
index 000000000..c4c90d226
--- /dev/null
+++ b/backend/local/metadata_bsd.go
@@ -0,0 +1,38 @@
+//go:build darwin || freebsd || netbsd
+// +build darwin freebsd netbsd
+
+package local
+
+import (
+	"fmt"
+	"syscall"
+	"time"
+
+	"github.com/rclone/rclone/fs"
+)
+
+// Read the metadata from the file into metadata where possible
+func (o *Object) readMetadataFromFile(m *fs.Metadata) (err error) {
+	info, err := o.fs.lstat(o.path)
+	if err != nil {
+		return err
+	}
+	stat, ok := info.Sys().(*syscall.Stat_t)
+	if !ok {
+		fs.Debugf(o, "didn't return Stat_t as expected")
+		return nil
+	}
+	m.Set("mode", fmt.Sprintf("%0o", stat.Mode))
+	m.Set("uid", fmt.Sprintf("%d", stat.Uid))
+	m.Set("gid", fmt.Sprintf("%d", stat.Gid))
+	if stat.Rdev != 0 {
+		m.Set("rdev", fmt.Sprintf("%x", stat.Rdev))
+	}
+	setTime := func(key string, t syscall.Timespec) {
+		m.Set(key, time.Unix(t.Unix()).Format(metadataTimeFormat))
+	}
+	setTime("atime", stat.Atimespec)
+	setTime("mtime", stat.Mtimespec)
+	setTime("btime", stat.Birthtimespec)
+	return nil
+}
diff --git a/backend/local/metadata_linux.go b/backend/local/metadata_linux.go
new file mode 100644
index 000000000..17d32c06b
--- /dev/null
+++ b/backend/local/metadata_linux.go
@@ -0,0 +1,47 @@
+//go:build linux
+// +build linux
+
+package local
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/rclone/rclone/fs"
+	"golang.org/x/sys/unix"
+)
+
+// Read the metadata from the file into metadata where possible
+func (o *Object) readMetadataFromFile(m *fs.Metadata) (err error) {
+	flags := unix.AT_SYMLINK_NOFOLLOW
+	if o.fs.opt.FollowSymlinks {
+		flags = 0
+	}
+	var stat unix.Statx_t
+	err = unix.Statx(unix.AT_FDCWD, o.path, flags, (0 |
+		unix.STATX_TYPE | // Want stx_mode & S_IFMT
+		unix.STATX_MODE | // Want stx_mode & ~S_IFMT
+		unix.STATX_UID | // Want stx_uid
+		unix.STATX_GID | // Want stx_gid
+		unix.STATX_ATIME | // Want stx_atime
+		unix.STATX_MTIME | // Want stx_mtime
+		unix.STATX_CTIME | // Want stx_ctime
+		unix.STATX_BTIME), // Want stx_btime
+		&stat)
+	if err != nil {
+		return err
+	}
+	m.Set("mode", fmt.Sprintf("%0o", stat.Mode))
+	m.Set("uid", fmt.Sprintf("%d", stat.Uid))
+	m.Set("gid", fmt.Sprintf("%d", stat.Gid))
+	if stat.Rdev_major != 0 || stat.Rdev_minor != 0 {
+		m.Set("rdev", fmt.Sprintf("%x", uint64(stat.Rdev_major)<<32|uint64(stat.Rdev_minor)))
+	}
+	setTime := func(key string, t unix.StatxTimestamp) {
+		m.Set(key, time.Unix(t.Sec, int64(t.Nsec)).Format(metadataTimeFormat))
+	}
+	setTime("atime", stat.Atime)
+	setTime("mtime", stat.Mtime)
+	setTime("btime", stat.Btime)
+	return nil
+}
diff --git a/backend/local/metadata_other.go b/backend/local/metadata_other.go
new file mode 100644
index 000000000..f2ce80956
--- /dev/null
+++ b/backend/local/metadata_other.go
@@ -0,0 +1,21 @@
+//go:build plan9 || js
+// +build plan9 js
+
+package local
+
+import (
+	"fmt"
+
+	"github.com/rclone/rclone/fs"
+)
+
+// Read the metadata from the file into metadata where possible
+func (o *Object) readMetadataFromFile(m *fs.Metadata) (err error) {
+	info, err := o.fs.lstat(o.path)
+	if err != nil {
+		return err
+	}
+	m.Set("mode", fmt.Sprintf("%0o", info.Mode()))
+	m.Set("mtime", info.ModTime().Format(metadataTimeFormat))
+	return nil
+}
diff --git a/backend/local/metadata_unix.go b/backend/local/metadata_unix.go
new file mode 100644
index 000000000..48bf7faa3
--- /dev/null
+++ b/backend/local/metadata_unix.go
@@ -0,0 +1,37 @@
+//go:build openbsd || solaris
+// +build openbsd solaris
+
+package local
+
+import (
+	"fmt"
+	"syscall"
+	"time"
+
+	"github.com/rclone/rclone/fs"
+)
+
+// Read the metadata from the file into metadata where possible
+func (o *Object) readMetadataFromFile(m *fs.Metadata) (err error) {
+	info, err := o.fs.lstat(o.path)
+	if err != nil {
+		return err
+	}
+	stat, ok := info.Sys().(*syscall.Stat_t)
+	if !ok {
+		fs.Debugf(o, "didn't return Stat_t as expected")
+		return nil
+	}
+	m.Set("mode", fmt.Sprintf("%0o", stat.Mode))
+	m.Set("uid", fmt.Sprintf("%d", stat.Uid))
+	m.Set("gid", fmt.Sprintf("%d", stat.Gid))
+	if stat.Rdev != 0 {
+		m.Set("rdev", fmt.Sprintf("%x", stat.Rdev))
+	}
+	setTime := func(key string, t syscall.Timespec) {
+		m.Set(key, time.Unix(t.Unix()).Format(metadataTimeFormat))
+	}
+	setTime("atime", stat.Atim)
+	setTime("mtime", stat.Mtim)
+	return nil
+}
diff --git a/backend/local/metadata_windows.go b/backend/local/metadata_windows.go
new file mode 100644
index 000000000..b2588953e
--- /dev/null
+++ b/backend/local/metadata_windows.go
@@ -0,0 +1,34 @@
+//go:build windows
+// +build windows
+
+package local
+
+import (
+	"fmt"
+	"syscall"
+	"time"
+
+	"github.com/rclone/rclone/fs"
+)
+
+// Read the metadata from the file into metadata where possible
+func (o *Object) readMetadataFromFile(m *fs.Metadata) (err error) {
+	info, err := o.fs.lstat(o.path)
+	if err != nil {
+		return err
+	}
+	stat, ok := info.Sys().(*syscall.Win32FileAttributeData)
+	if !ok {
+		fs.Debugf(o, "didn't return Win32FileAttributeData as expected")
+		return nil
+	}
+	// FIXME do something with stat.FileAttributes ?
+	m.Set("mode", fmt.Sprintf("%0o", info.Mode()))
+	setTime := func(key string, t syscall.Filetime) {
+		m.Set(key, time.Unix(0, t.Nanoseconds()).Format(metadataTimeFormat))
+	}
+	setTime("atime", stat.LastAccessTime)
+	setTime("mtime", stat.LastWriteTime)
+	setTime("btime", stat.CreationTime)
+	return nil
+}
diff --git a/backend/local/setbtime.go b/backend/local/setbtime.go
new file mode 100644
index 000000000..20a914bf2
--- /dev/null
+++ b/backend/local/setbtime.go
@@ -0,0 +1,16 @@
+//go:build !windows
+// +build !windows
+
+package local
+
+import (
+	"time"
+)
+
+const haveSetBTime = false
+
+// setBTime changes the the birth time of the file passed in
+func setBTime(name string, btime time.Time) error {
+	// Does nothing
+	return nil
+}
diff --git a/backend/local/setbtime_windows.go b/backend/local/setbtime_windows.go
new file mode 100644
index 000000000..3734c2e7b
--- /dev/null
+++ b/backend/local/setbtime_windows.go
@@ -0,0 +1,28 @@
+//go:build windows
+// +build windows
+
+package local
+
+import (
+	"os"
+	"time"
+	"syscall"
+)
+
+const haveSetBTime = true
+
+// setBTime sets the the birth time of the file passed in
+func setBTime(name string, btime time.Time) (err error) {
+	h, err := syscall.Open(name, os.O_RDWR, 0755)
+	if err != nil {
+		return err
+	}
+	defer func() {
+		closeErr := syscall.Close(h)
+		if err == nil {
+			err = closeErr
+		}
+	}()
+	bFileTime := syscall.NsecToFiletime(btime.UnixNano())
+	return syscall.SetFileTime(h, &bFileTime, nil, nil)
+}
diff --git a/backend/local/xattr.go b/backend/local/xattr.go
new file mode 100644
index 000000000..a2c0cc5e8
--- /dev/null
+++ b/backend/local/xattr.go
@@ -0,0 +1,87 @@
+//go:build !openbsd && !plan9
+// +build !openbsd,!plan9
+
+package local
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/pkg/xattr"
+	"github.com/rclone/rclone/fs"
+)
+
+const (
+	xattrPrefix    = "user." // FIXME is this correct for all unixes?
+	xattrSupported = xattr.XATTR_SUPPORTED
+)
+
+// getXattr returns the extended attributes for an object
+//
+// It doesn't return any attributes owned by this backend in
+// metadataKeys
+func (o *Object) getXattr() (metadata fs.Metadata, err error) {
+	if !xattrSupported {
+		return nil, nil
+	}
+	var list []string
+	if o.fs.opt.FollowSymlinks {
+		list, err = xattr.List(o.path)
+	} else {
+		list, err = xattr.LList(o.path)
+	}
+	if err != nil {
+		return nil, fmt.Errorf("failed to read xattr: %w", err)
+	}
+	if len(list) == 0 {
+		return nil, nil
+	}
+	metadata = make(fs.Metadata, len(list))
+	for _, k := range list {
+		var v []byte
+		if o.fs.opt.FollowSymlinks {
+			v, err = xattr.Get(o.path, k)
+		} else {
+			v, err = xattr.LGet(o.path, k)
+		}
+		if err != nil {
+			return nil, fmt.Errorf("failed to read xattr key %q: %w", k, err)
+		}
+		k = strings.ToLower(k)
+		if !strings.HasPrefix(k, xattrPrefix) {
+			continue
+		}
+		k = k[len(xattrPrefix):]
+		if _, found := systemMetadataInfo[k]; found {
+			continue
+		}
+		metadata[k] = string(v)
+	}
+	return metadata, nil
+}
+
+// setXattr sets the metadata on the file Xattrs
+//
+// It doesn't set any attributes owned by this backend in metadataKeys
+func (o *Object) setXattr(metadata fs.Metadata) (err error) {
+	if !xattrSupported {
+		return nil
+	}
+	for k, value := range metadata {
+		k = strings.ToLower(k)
+		if _, found := systemMetadataInfo[k]; found {
+			continue
+		}
+		k = xattrPrefix + k
+		v := []byte(value)
+		if o.fs.opt.FollowSymlinks {
+			err = xattr.Set(o.path, k, v)
+		} else {
+			err = xattr.LSet(o.path, k, v)
+		}
+		if err != nil {
+			return fmt.Errorf("failed to set xattr key %q: %w", k, err)
+		}
+	}
+	return nil
+}
diff --git a/backend/local/xattr_unsupported.go b/backend/local/xattr_unsupported.go
new file mode 100644
index 000000000..88455939a
--- /dev/null
+++ b/backend/local/xattr_unsupported.go
@@ -0,0 +1,21 @@
+//go:build openbsd || plan9
+// +build openbsd plan9
+
+// The pkg/xattr module doesn't compile for openbsd or plan9
+package local
+
+import "github.com/rclone/rclone/fs"
+
+const (
+	xattrSupported = false
+)
+
+// getXattr returns the extended attributes for an object
+func (o *Object) getXattr() (metadata fs.Metadata, err error) {
+	return nil, nil
+}
+
+// setXattr sets the metadata on the file Xattrs
+func (o *Object) setXattr(metadata fs.Metadata) (err error) {
+	return nil
+}
diff --git a/backend/s3/s3.go b/backend/s3/s3.go
index c81024ce3..1693446b0 100644
--- a/backend/s3/s3.go
+++ b/backend/s3/s3.go
@@ -8,6 +8,7 @@ import (
 	"crypto/tls"
 	"encoding/base64"
 	"encoding/hex"
+	"encoding/json"
 	"encoding/xml"
 	"errors"
 	"fmt"
@@ -60,9 +61,20 @@ import (
 func init() {
 	fs.Register(&fs.RegInfo{
 		Name:        "s3",
-		Description: "Amazon S3 Compliant Storage Providers including AWS, Alibaba, Ceph, China Mobile, Cloudflare, ArvanCloud, Digital Ocean, Dreamhost, Huawei OBS, IBM COS, Lyve Cloud, Minio, Netease, RackCorp, Scaleway, SeaweedFS, StackPath, Storj, Tencent COS and Wasabi",
+		Description: "Amazon S3 Compliant Storage Providers including AWS, Alibaba, Ceph, China Mobile, Cloudflare, ArvanCloud, Digital Ocean, Dreamhost, Huawei OBS, IBM COS, IDrive e2, Lyve Cloud, Minio, Netease, RackCorp, Scaleway, SeaweedFS, StackPath, Storj, Tencent COS and Wasabi",
 		NewFs:       NewFs,
 		CommandHelp: commandHelp,
+		Config: func(ctx context.Context, name string, m configmap.Mapper, config fs.ConfigIn) (*fs.ConfigOut, error) {
+			switch config.State {
+			case "":
+				return nil, setEndpointValueForIDriveE2(m)
+			}
+			return nil, fmt.Errorf("unknown state %q", config.State)
+		},
+		MetadataInfo: &fs.MetadataInfo{
+			System: systemMetadataInfo,
+			Help:   `User metadata is stored as x-amz-meta- keys. S3 metadata keys are case insensitive and are always returned in lower case.`,
+		},
 		Options: []fs.Option{{
 			Name: fs.ConfigProvider,
 			Help: "Choose your S3 provider.",
@@ -98,6 +110,9 @@ func init() {
 			}, {
 				Value: "IBMCOS",
 				Help:  "IBM COS S3",
+			}, {
+				Value: "IDrive",
+				Help:  "IDrive e2",
 			}, {
 				Value: "LyveCloud",
 				Help:  "Seagate Lyve Cloud",
@@ -369,7 +384,7 @@ func init() {
 		}, {
 			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,ArvanCloud,RackCorp,Scaleway,Storj,TencentCOS,HuaweiOBS",
+			Provider: "!AWS,Alibaba,ChinaMobile,Cloudflare,ArvanCloud,RackCorp,Scaleway,Storj,TencentCOS,HuaweiOBS,IDrive",
 			Examples: []fs.OptionExample{{
 				Value: "",
 				Help:  "Use this if unsure.\nWill use v4 signatures and an empty region.",
@@ -983,7 +998,7 @@ func init() {
 		}, {
 			Name:     "endpoint",
 			Help:     "Endpoint for S3 API.\n\nRequired when using an S3 clone.",
-			Provider: "!AWS,IBMCOS,TencentCOS,HuaweiOBS,Alibaba,ChinaMobile,ArvanCloud,Scaleway,StackPath,Storj,RackCorp",
+			Provider: "!AWS,IBMCOS,IDrive,TencentCOS,HuaweiOBS,Alibaba,ChinaMobile,ArvanCloud,Scaleway,StackPath,Storj,RackCorp",
 			Examples: []fs.OptionExample{{
 				Value:    "objects-us-east-1.dream.io",
 				Help:     "Dream Objects endpoint",
@@ -1393,7 +1408,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,IBMCOS,Alibaba,HuaweiOBS,ChinaMobile,Cloudflare,ArvanCloud,RackCorp,Scaleway,StackPath,Storj,TencentCOS",
+			Provider: "!AWS,IBMCOS,IDrive,Alibaba,HuaweiOBS,ChinaMobile,Cloudflare,ArvanCloud,RackCorp,Scaleway,StackPath,Storj,TencentCOS",
 		}, {
 			Name: "acl",
 			Help: `Canned ACL used when creating buckets and storing or copying objects.
@@ -1973,8 +1988,8 @@ circumstances or for testing.
 
 // Constants
 const (
-	metaMtime   = "Mtime"     // the meta key to store mtime in - e.g. X-Amz-Meta-Mtime
-	metaMD5Hash = "Md5chksum" // the meta key to store md5hash in
+	metaMtime   = "mtime"     // the meta key to store mtime in - e.g. X-Amz-Meta-Mtime
+	metaMD5Hash = "md5chksum" // the meta key to store md5hash in
 	// The maximum size of object we can COPY - this should be 5 GiB but is < 5 GB for b2 compatibility
 	// See https://forum.rclone.org/t/copying-files-within-a-b2-bucket/16680/76
 	maxSizeForCopy      = 4768 * 1024 * 1024
@@ -1989,6 +2004,57 @@ const (
 	maxExpireDuration   = fs.Duration(7 * 24 * time.Hour) // max expiry is 1 week
 )
 
+// system metadata keys which this backend owns
+var systemMetadataInfo = map[string]fs.MetadataHelp{
+	"cache-control": {
+		Help:    "Cache-Control header",
+		Type:    "string",
+		Example: "no-cache",
+	},
+	"content-disposition": {
+		Help:    "Content-Disposition header",
+		Type:    "string",
+		Example: "inline",
+	},
+	"content-encoding": {
+		Help:    "Content-Encoding header",
+		Type:    "string",
+		Example: "gzip",
+	},
+	"content-language": {
+		Help:    "Content-Language header",
+		Type:    "string",
+		Example: "en-US",
+	},
+	"content-type": {
+		Help:    "Content-Type header",
+		Type:    "string",
+		Example: "text/plain",
+	},
+	// "tagging": {
+	// 	Help:    "x-amz-tagging header",
+	// 	Type:    "string",
+	// 	Example: "tag1=value1&tag2=value2",
+	// },
+	"tier": {
+		Help:     "Tier of the object",
+		Type:     "string",
+		Example:  "GLACIER",
+		ReadOnly: true,
+	},
+	"mtime": {
+		Help:    "Time of last modification, read from rclone metadata",
+		Type:    "RFC 3339",
+		Example: "2006-01-02T15:04:05.999999999Z07:00",
+	},
+	"btime": {
+		Help:     "Time of file birth (creation) read from Last-Modified header",
+		Type:     "RFC 3339",
+		Example:  "2006-01-02T15:04:05.999999999Z07:00",
+		ReadOnly: true,
+	},
+}
+
 // Options defines the configuration for this backend
 type Options struct {
 	Provider              string               `config:"provider"`
@@ -2061,14 +2127,20 @@ type Object struct {
 	//
 	// List will read everything but meta & mimeType - to fill
 	// that in you need to call readMetaData
-	fs           *Fs                // what this object is part of
-	remote       string             // The remote path
-	md5          string             // md5sum of the object
-	bytes        int64              // size of the object
-	lastModified time.Time          // Last modified
-	meta         map[string]*string // The object metadata if known - may be nil
-	mimeType     string             // MimeType of object - may be ""
-	storageClass string             // e.g. GLACIER
+	fs           *Fs               // what this object is part of
+	remote       string            // The remote path
+	md5          string            // md5sum of the object
+	bytes        int64             // size of the object
+	lastModified time.Time         // Last modified
+	meta         map[string]string // The object metadata if known - may be nil - with lower case keys
+	mimeType     string            // MimeType of object - may be ""
+
+	// Metadata as pointers to strings as they often won't be present
+	storageClass       *string // e.g. GLACIER
+	cacheControl       *string // Cache-Control: header
+	contentDisposition *string // Content-Disposition: header
+	contentEncoding    *string // Content-Encoding: header
+	contentLanguage    *string // Content-Language: header
 }
 
 // ------------------------------------------------------------
@@ -2329,6 +2401,37 @@ func (f *Fs) setUploadCutoff(cs fs.SizeSuffix) (old fs.SizeSuffix, err error) {
 	return
 }
 
+// setEndpointValueForIDriveE2 gets user region endpoint against the Access Key details by calling the API
+func setEndpointValueForIDriveE2(m configmap.Mapper) (err error) {
+	value, ok := m.Get(fs.ConfigProvider)
+	if !ok || value != "IDrive" {
+		return
+	}
+	value, ok = m.Get("access_key_id")
+	if !ok || value == "" {
+		return
+	}
+	client := &http.Client{Timeout: time.Second * 3}
+	// API to get user region endpoint against the Access Key details: https://www.idrive.com/e2/guides/get_region_endpoint
+	resp, err := client.Post("https://api.idrivee2.com/api/service/get_region_end_point",
+		"application/json",
+		strings.NewReader(`{"access_key": "`+value+`"}`))
+	if err != nil {
+		return
+	}
+	defer fs.CheckClose(resp.Body, &err)
+	decoder := json.NewDecoder(resp.Body)
+	var data = &struct {
+		RespCode   int    `json:"resp_code"`
+		RespMsg    string `json:"resp_msg"`
+		DomainName string `json:"domain_name"`
+	}{}
+	if err = decoder.Decode(data); err == nil && data.RespCode == 0 {
+		m.Set("endpoint", data.DomainName)
+	}
+	return
+}
+
 // Set the provider quirks
 //
 // There should be no testing against opt.Provider anywhere in the
@@ -2375,6 +2478,8 @@ func setQuirks(opt *Options) {
 		virtualHostStyle = false
 		urlEncodeListings = false
 		useMultipartEtag = false // untested
+	case "IDrive":
+		virtualHostStyle = false
 	case "LyveCloud":
 		useMultipartEtag = false // LyveCloud seems to calculate multipart Etags differently from AWS
 	case "Minio":
@@ -2529,6 +2634,9 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
 	f.features = (&fs.Features{
 		ReadMimeType:      true,
 		WriteMimeType:     true,
+		ReadMetadata:      true,
+		WriteMetadata:     true,
+		UserMetadata:      true,
 		BucketBased:       true,
 		BucketBasedRootOK: true,
 		SetTier:           true,
@@ -2554,6 +2662,9 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
 		f.features.SetTier = false
 		f.features.GetTier = false
 	}
+	if opt.Provider == "IDrive" {
+		f.features.SetTier = false
+	}
 	// f.listMultipartUploads()
 	return f, nil
 }
@@ -2576,7 +2687,7 @@ func (f *Fs) newObjectWithInfo(ctx context.Context, remote string, info *s3.Obje
 		}
 		o.setMD5FromEtag(aws.StringValue(info.ETag))
 		o.bytes = aws.Int64Value(info.Size)
-		o.storageClass = aws.StringValue(info.StorageClass)
+		o.storageClass = info.StorageClass
 	} else if !o.fs.opt.NoHeadObject {
 		err := o.readMetaData(ctx) // reads info and meta, returning an error
 		if err != nil {
@@ -3444,7 +3555,7 @@ func (f *Fs) Command(ctx context.Context, name string, arg []string, opt map[str
 				st.Status = "Not an S3 object"
 				return
 			}
-			if o.storageClass != "GLACIER" && o.storageClass != "DEEP_ARCHIVE" {
+			if o.storageClass == nil || (*o.storageClass != "GLACIER" && *o.storageClass != "DEEP_ARCHIVE") {
 				st.Status = "Not GLACIER or DEEP_ARCHIVE storage class"
 				return
 			}
@@ -3702,40 +3813,65 @@ func (o *Object) readMetaData(ctx context.Context) (err error) {
 	if err != nil {
 		return err
 	}
-	o.setMetaData(resp.ETag, resp.ContentLength, resp.LastModified, resp.Metadata, resp.ContentType, resp.StorageClass)
+	o.setMetaData(resp)
+	// resp.ETag, resp.ContentLength, resp.LastModified, resp.Metadata, resp.ContentType, resp.StorageClass)
 	return nil
 }
 
-func (o *Object) setMetaData(etag *string, contentLength *int64, lastModified *time.Time, meta map[string]*string, mimeType *string, storageClass *string) {
+// Convert S3 metadata with pointers into a map[string]string
+// while lowercasing the keys
+func s3MetadataToMap(s3Meta map[string]*string) map[string]string {
+	meta := make(map[string]string, len(s3Meta))
+	for k, v := range s3Meta {
+		if v != nil {
+			meta[strings.ToLower(k)] = *v
+		}
+	}
+	return meta
+}
+
+// Convert our metadata back into S3 metadata
+func mapToS3Metadata(meta map[string]string) map[string]*string {
+	s3Meta := make(map[string]*string, len(meta))
+	for k, v := range meta {
+		s3Meta[k] = aws.String(v)
+	}
+	return s3Meta
+}
+
+func (o *Object) setMetaData(resp *s3.HeadObjectOutput) {
 	// Ignore missing Content-Length assuming it is 0
 	// Some versions of ceph do this due their apache proxies
-	if contentLength != nil {
-		o.bytes = *contentLength
-	}
-	o.setMD5FromEtag(aws.StringValue(etag))
-	o.meta = meta
-	if o.meta == nil {
-		o.meta = map[string]*string{}
+	if resp.ContentLength != nil {
+		o.bytes = *resp.ContentLength
 	}
+	o.setMD5FromEtag(aws.StringValue(resp.ETag))
+	o.meta = s3MetadataToMap(resp.Metadata)
 	// Read MD5 from metadata if present
 	if md5sumBase64, ok := o.meta[metaMD5Hash]; ok {
-		md5sumBytes, err := base64.StdEncoding.DecodeString(*md5sumBase64)
+		md5sumBytes, err := base64.StdEncoding.DecodeString(md5sumBase64)
 		if err != nil {
-			fs.Debugf(o, "Failed to read md5sum from metadata %q: %v", *md5sumBase64, err)
+			fs.Debugf(o, "Failed to read md5sum from metadata %q: %v", md5sumBase64, err)
 		} else if len(md5sumBytes) != 16 {
-			fs.Debugf(o, "Failed to read md5sum from metadata %q: wrong length", *md5sumBase64)
+			fs.Debugf(o, "Failed to read md5sum from metadata %q: wrong length", md5sumBase64)
 		} else {
 			o.md5 = hex.EncodeToString(md5sumBytes)
 		}
 	}
-	o.storageClass = aws.StringValue(storageClass)
-	if lastModified == nil {
+	if resp.LastModified == nil {
 		o.lastModified = time.Now()
 		fs.Logf(o, "Failed to read last modified")
 	} else {
-		o.lastModified = *lastModified
+		o.lastModified = *resp.LastModified
 	}
-	o.mimeType = aws.StringValue(mimeType)
+	o.mimeType = aws.StringValue(resp.ContentType)
+
+	// Set system metadata
+	o.storageClass = resp.StorageClass
+	o.cacheControl = resp.CacheControl
+	o.contentDisposition = resp.ContentDisposition
+	o.contentEncoding = resp.ContentEncoding
+	o.contentLanguage = resp.ContentLanguage
 }
 
 // ModTime returns the modification time of the object
@@ -3753,11 +3889,11 @@ func (o *Object) ModTime(ctx context.Context) time.Time {
 	}
 	// read mtime out of metadata if available
 	d, ok := o.meta[metaMtime]
-	if !ok || d == nil {
+	if !ok {
 		// fs.Debugf(o, "No metadata")
 		return o.lastModified
 	}
-	modTime, err := swift.FloatStringToTime(*d)
+	modTime, err := swift.FloatStringToTime(d)
 	if err != nil {
 		fs.Logf(o, "Failed to read mtime from object: %v", err)
 		return o.lastModified
@@ -3771,10 +3907,10 @@ func (o *Object) SetModTime(ctx context.Context, modTime time.Time) error {
 	if err != nil {
 		return err
 	}
-	o.meta[metaMtime] = aws.String(swift.TimeToFloatString(modTime))
+	o.meta[metaMtime] = swift.TimeToFloatString(modTime)
 
 	// Can't update metadata here, so return this error to force a recopy
-	if o.storageClass == "GLACIER" || o.storageClass == "DEEP_ARCHIVE" {
+	if o.storageClass != nil && (*o.storageClass == "GLACIER" || *o.storageClass == "DEEP_ARCHIVE") {
 		return fs.ErrorCantSetModTime
 	}
 
@@ -3782,7 +3918,7 @@ func (o *Object) SetModTime(ctx context.Context, modTime time.Time) error {
 	bucket, bucketPath := o.split()
 	req := s3.CopyObjectInput{
 		ContentType:       aws.String(fs.MimeType(ctx, o)), // Guess the content type
-		Metadata:          o.meta,
+		Metadata:          mapToS3Metadata(o.meta),
 		MetadataDirective: aws.String(s3.MetadataDirectiveReplace), // replace metadata with that passed in
 	}
 	if o.fs.opt.RequesterPays {
@@ -3835,17 +3971,34 @@ func (o *Object) downloadFromURL(ctx context.Context, bucketPath string, options
 
 	metaData := make(map[string]*string)
 	for key, value := range resp.Header {
-		if strings.HasPrefix(key, "x-amz-meta") {
+		key = strings.ToLower(key)
+		if strings.HasPrefix(key, "x-amz-meta-") {
 			metaKey := strings.TrimPrefix(key, "x-amz-meta-")
-			metaData[strings.Title(metaKey)] = &value[0]
+			metaData[metaKey] = &value[0]
 		}
 	}
 
-	storageClass := resp.Header.Get("X-Amz-Storage-Class")
-	contentType := resp.Header.Get("Content-Type")
-	etag := resp.Header.Get("Etag")
-
-	o.setMetaData(&etag, contentLength, &lastModified, metaData, &contentType, &storageClass)
+	header := func(k string) *string {
+		v := resp.Header.Get(k)
+		if v == "" {
+			return nil
+		}
+		return &v
+	}
+
+	var head = s3.HeadObjectOutput{
+		ETag:               header("Etag"),
+		ContentLength:      contentLength,
+		LastModified:       &lastModified,
+		Metadata:           metaData,
+		CacheControl:       header("Cache-Control"),
+		ContentDisposition: header("Content-Disposition"),
+		ContentEncoding:    header("Content-Encoding"),
+		ContentLanguage:    header("Content-Language"),
+		ContentType:        header("Content-Type"),
+		StorageClass:       header("X-Amz-Storage-Class"),
+	}
+	o.setMetaData(&head)
 	return resp.Body, err
 }
 
@@ -3920,7 +4073,10 @@ func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (in io.Read
 			fs.Debugf(o, "Failed to find length in %q", contentRange)
 		}
 	}
-	o.setMetaData(resp.ETag, size, resp.LastModified, resp.Metadata, resp.ContentType, resp.StorageClass)
+	var head s3.HeadObjectOutput
+	structs.SetFrom(&head, resp)
+	head.ContentLength = size
+	o.setMetaData(&head)
 	return resp.Body, nil
 }
 
@@ -4257,11 +4413,56 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
 
 	multipart := size < 0 || size >= int64(o.fs.opt.UploadCutoff)
 
-	// Set the mtime in the meta data
-	metadata := map[string]*string{
-		metaMtime: aws.String(swift.TimeToFloatString(modTime)),
+	req := s3.PutObjectInput{
+		Bucket: &bucket,
+		ACL:    &o.fs.opt.ACL,
+		Key:    &bucketPath,
+	}
+
+	// Fetch metadata if --metadata is in use
+	meta, err := fs.GetMetadataOptions(ctx, src, options)
+	if err != nil {
+		return fmt.Errorf("failed to read metadata from source object: %w", err)
+	}
+	req.Metadata = make(map[string]*string, len(meta)+2)
+	// merge metadata into request and user metadata
+	for k, v := range meta {
+		pv := aws.String(v)
+		k = strings.ToLower(k)
+		switch k {
+		case "cache-control":
+			req.CacheControl = pv
+		case "content-disposition":
+			req.ContentDisposition = pv
+		case "content-encoding":
+			req.ContentEncoding = pv
+		case "content-language":
+			req.ContentLanguage = pv
+		case "content-type":
+			req.ContentType = pv
+		case "x-amz-tagging":
+			req.Tagging = pv
+		case "tier":
+			// ignore
+		case "mtime":
+			// mtime in meta overrides source ModTime
+			metaModTime, err := time.Parse(time.RFC3339Nano, v)
+			if err != nil {
+				fs.Debugf(o, "failed to parse metadata %s: %q: %v", k, v, err)
+			} else {
+				modTime = metaModTime
+			}
+		case "btime":
+			// write as metadata since we can't set it
+			req.Metadata[k] = pv
+		default:
+			req.Metadata[k] = pv
+		}
 	}
 
+	// Set the mtime in the meta data
+	req.Metadata[metaMtime] = aws.String(swift.TimeToFloatString(modTime))
+
 	// read the md5sum if available
 	// - for non multipart
 	//    - so we can add a ContentMD5
@@ -4281,20 +4482,15 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
 					// - a multipart upload
 					// - the Etag is not an MD5, eg when using SSE/SSE-C
 					// provided checksums aren't disabled
-					metadata[metaMD5Hash] = &md5sumBase64
+					req.Metadata[metaMD5Hash] = &md5sumBase64
 				}
 			}
 		}
 	}
 
-	// Guess the content type
-	mimeType := fs.MimeType(ctx, src)
-	req := s3.PutObjectInput{
-		Bucket:      &bucket,
-		ACL:         &o.fs.opt.ACL,
-		Key:         &bucketPath,
-		ContentType: &mimeType,
-		Metadata:    metadata,
+	// Set the content type it it isn't set already
+	if req.ContentType == nil {
+		req.ContentType = aws.String(fs.MimeType(ctx, src))
 	}
 	if size >= 0 {
 		req.ContentLength = &size
@@ -4373,19 +4569,19 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
 	// so make up the object as best we can assuming it got
 	// uploaded properly. If size < 0 then we need to do the HEAD.
 	if o.fs.opt.NoHead && size >= 0 {
-		o.md5 = md5sumHex
-		o.bytes = size
-		o.lastModified = time.Now()
-		o.meta = req.Metadata
-		o.mimeType = aws.StringValue(req.ContentType)
-		o.storageClass = aws.StringValue(req.StorageClass)
+		var head s3.HeadObjectOutput
+		structs.SetFrom(&head, req)
+		head.ETag = &md5sumHex // doesn't matter quotes are misssing
+		head.ContentLength = &size
 		// If we have done a single part PUT request then we can read these
 		if gotEtag != "" {
-			o.setMD5FromEtag(gotEtag)
+			head.ETag = &gotEtag
 		}
-		if !o.lastModified.IsZero() {
-			o.lastModified = lastModified
+		if lastModified.IsZero() {
+			lastModified = time.Now()
 		}
+		head.LastModified = &lastModified
+		o.setMetaData(&head)
 		return nil
 	}
 
@@ -4395,7 +4591,7 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
 	if err != nil {
 		return err
 	}
-	o.setMetaData(head.ETag, head.ContentLength, head.LastModified, head.Metadata, head.ContentType, head.StorageClass)
+	o.setMetaData(head)
 	if o.fs.opt.UseMultipartEtag.Value && !o.fs.etagIsNotMD5 && wantETag != "" && head.ETag != nil && *head.ETag != "" {
 		gotETag := strings.Trim(strings.ToLower(*head.ETag), `"`)
 		if wantETag != gotETag {
@@ -4446,16 +4642,61 @@ func (o *Object) SetTier(tier string) (err error) {
 	if err != nil {
 		return err
 	}
-	o.storageClass = tier
+	o.storageClass = &tier
 	return err
 }
 
 // GetTier returns storage class as string
 func (o *Object) GetTier() string {
-	if o.storageClass == "" {
+	if o.storageClass == nil || *o.storageClass == "" {
 		return "STANDARD"
 	}
-	return o.storageClass
+	return *o.storageClass
+}
+
+// Metadata returns metadata for an object
+//
+// It should return nil if there is no Metadata
+func (o *Object) Metadata(ctx context.Context) (metadata fs.Metadata, err error) {
+	err = o.readMetaData(ctx)
+	if err != nil {
+		return nil, err
+	}
+	metadata = make(fs.Metadata, len(o.meta)+7)
+	for k, v := range o.meta {
+		switch k {
+		case metaMtime:
+			if modTime, err := swift.FloatStringToTime(v); err == nil {
+				metadata["mtime"] = modTime.Format(time.RFC3339Nano)
+			}
+		case metaMD5Hash:
+			// don't write hash metadata
+		default:
+			metadata[k] = v
+		}
+	}
+	if o.mimeType != "" {
+		metadata["content-type"] = o.mimeType
+	}
+	// metadata["x-amz-tagging"] = ""
+	if !o.lastModified.IsZero() {
+		metadata["btime"] = o.lastModified.Format(time.RFC3339Nano)
+	}
+
+	// Set system metadata
+	setMetadata := func(k string, v *string) {
+		if v == nil || *v == "" {
+			return
+		}
+		metadata[k] = *v
+	}
+	setMetadata("cache-control", o.cacheControl)
+	setMetadata("content-disposition", o.contentDisposition)
+	setMetadata("content-encoding", o.contentEncoding)
+	setMetadata("content-language", o.contentLanguage)
+	setMetadata("tier", o.storageClass)
+
+	return metadata, nil
 }
 
 // Check the interfaces are satisfied
@@ -4470,4 +4711,5 @@ var (
 	_ fs.MimeTyper   = &Object{}
 	_ fs.GetTierer   = &Object{}
 	_ fs.SetTierer   = &Object{}
+	_ fs.Metadataer  = &Object{}
 )
diff --git a/backend/union/entry.go b/backend/union/entry.go
index efd2cbaef..023f3bb45 100644
--- a/backend/union/entry.go
+++ b/backend/union/entry.go
@@ -2,6 +2,7 @@ package union
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"io"
 	"io/ioutil"
@@ -34,9 +35,8 @@ type entry interface {
 	candidates() []upstream.Entry
 }
 
-// UnWrap returns the Object that this Object is wrapping or
-// nil if it isn't wrapping anything
-func (o *Object) UnWrap() *upstream.Object {
+// UnWrapUpstream returns the upstream Object that this Object is wrapping
+func (o *Object) UnWrapUpstream() *upstream.Object {
 	return o.Object
 }
 
@@ -140,6 +140,42 @@ func (o *Object) SetModTime(ctx context.Context, t time.Time) error {
 	return errs.Err()
 }
 
+// GetTier returns storage tier or class of the Object
+func (o *Object) GetTier() string {
+	do, ok := o.Object.Object.(fs.GetTierer)
+	if !ok {
+		return ""
+	}
+	return do.GetTier()
+}
+
+// ID returns the ID of the Object if known, or "" if not
+func (o *Object) ID() string {
+	do, ok := o.Object.Object.(fs.IDer)
+	if !ok {
+		return ""
+	}
+	return do.ID()
+}
+
+// MimeType returns the content type of the Object if known
+func (o *Object) MimeType(ctx context.Context) (mimeType string) {
+	if do, ok := o.Object.Object.(fs.MimeTyper); ok {
+		mimeType = do.MimeType(ctx)
+	}
+	return mimeType
+}
+
+// SetTier performs changing storage tier of the Object if
+// multiple storage classes supported
+func (o *Object) SetTier(tier string) error {
+	do, ok := o.Object.Object.(fs.SetTierer)
+	if !ok {
+		return errors.New("underlying remote does not support SetTier")
+	}
+	return do.SetTier(tier)
+}
+
 // ModTime returns the modification date of the directory
 // It returns the latest ModTime of all candidates
 func (d *Directory) ModTime(ctx context.Context) (t time.Time) {
@@ -164,3 +200,8 @@ func (d *Directory) Size() (s int64) {
 	}
 	return s
 }
+
+// Check the interfaces are satisfied
+var (
+	_ fs.FullObject = (*Object)(nil)
+)
diff --git a/backend/union/union.go b/backend/union/union.go
index 9776c55d2..e1ec53e85 100644
--- a/backend/union/union.go
+++ b/backend/union/union.go
@@ -30,6 +30,9 @@ func init() {
 		Name:        "union",
 		Description: "Union merges the contents of several upstream fs",
 		NewFs:       NewFs,
+		MetadataInfo: &fs.MetadataInfo{
+			Help: `Any metadata supported by the underlying remote is read and written.`,
+		},
 		Options: []fs.Option{{
 			Name:     "upstreams",
 			Help:     "List of space separated upstreams.\n\nCan be 'upstreama:test/dir upstreamb:', '\"upstreama:test/space:ro dir\" upstreamb:', etc.",
@@ -219,7 +222,7 @@ func (f *Fs) Copy(ctx context.Context, src fs.Object, remote string) (fs.Object,
 		fs.Debugf(src, "Can't copy - not same remote type")
 		return nil, fs.ErrorCantCopy
 	}
-	o := srcObj.UnWrap()
+	o := srcObj.UnWrapUpstream()
 	su := o.UpstreamFs()
 	if su.Features().Copy == nil {
 		return nil, fs.ErrorCantCopy
@@ -881,6 +884,9 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
 		BucketBased:             true,
 		SetTier:                 true,
 		GetTier:                 true,
+		ReadMetadata:            true,
+		WriteMetadata:           true,
+		UserMetadata:            true,
 	}).Fill(ctx, f)
 	canMove := true
 	for _, f := range upstreams {
diff --git a/backend/union/upstream/upstream.go b/backend/union/upstream/upstream.go
index 1a85d783f..c6e906107 100644
--- a/backend/union/upstream/upstream.go
+++ b/backend/union/upstream/upstream.go
@@ -245,6 +245,53 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
 	return nil
 }
 
+// GetTier returns storage tier or class of the Object
+func (o *Object) GetTier() string {
+	do, ok := o.Object.(fs.GetTierer)
+	if !ok {
+		return ""
+	}
+	return do.GetTier()
+}
+
+// ID returns the ID of the Object if known, or "" if not
+func (o *Object) ID() string {
+	do, ok := o.Object.(fs.IDer)
+	if !ok {
+		return ""
+	}
+	return do.ID()
+}
+
+// MimeType returns the content type of the Object if known
+func (o *Object) MimeType(ctx context.Context) (mimeType string) {
+	if do, ok := o.Object.(fs.MimeTyper); ok {
+		mimeType = do.MimeType(ctx)
+	}
+	return mimeType
+}
+
+// SetTier performs changing storage tier of the Object if
+// multiple storage classes supported
+func (o *Object) SetTier(tier string) error {
+	do, ok := o.Object.(fs.SetTierer)
+	if !ok {
+		return errors.New("underlying remote does not support SetTier")
+	}
+	return do.SetTier(tier)
+}
+
+// Metadata returns metadata for an object
+//
+// It should return nil if there is no Metadata
+func (o *Object) Metadata(ctx context.Context) (fs.Metadata, error) {
+	do, ok := o.Object.(fs.Metadataer)
+	if !ok {
+		return nil, nil
+	}
+	return do.Metadata(ctx)
+}
+
 // About gets quota information from the Fs
 func (f *Fs) About(ctx context.Context) (*fs.Usage, error) {
 	if atomic.LoadInt64(&f.cacheExpiry) <= time.Now().Unix() {
@@ -363,3 +410,8 @@ func (f *Fs) updateUsageCore(lock bool) error {
 	f.usage = usage
 	return nil
 }
+
+// Check the interfaces are satisfied
+var (
+	_ fs.FullObject = (*Object)(nil)
+)
diff --git a/cmd/cmd.go b/cmd/cmd.go
index 5dd120be7..6209fad55 100644
--- a/cmd/cmd.go
+++ b/cmd/cmd.go
@@ -321,6 +321,12 @@ func Run(Retry bool, showStats bool, cmd *cobra.Command, f func() error) {
 		}
 	}
 
+	// clear cache and shutdown backends
+	cache.Clear()
+	if lastErr := accounting.GlobalStats().GetLastError(); cmdErr == nil {
+		cmdErr = lastErr
+	}
+
 	// Log the final error message and exit
 	if cmdErr != nil {
 		nerrs := accounting.GlobalStats().GetErrors()
diff --git a/cmd/help.go b/cmd/help.go
index db7e95da4..b5f1c2227 100644
--- a/cmd/help.go
+++ b/cmd/help.go
@@ -6,6 +6,7 @@ import (
 	"log"
 	"os"
 	"regexp"
+	"sort"
 	"strings"
 
 	"github.com/rclone/rclone/fs"
@@ -362,4 +363,28 @@ func showBackend(name string) {
 			fmt.Printf("\n")
 		}
 	}
+	if backend.MetadataInfo != nil {
+		fmt.Printf("### Metadata\n\n")
+		fmt.Printf("%s\n\n", strings.TrimSpace(backend.MetadataInfo.Help))
+		if len(backend.MetadataInfo.System) > 0 {
+			fmt.Printf("Here are the possible system metadata items for the %s backend.\n\n", backend.Name)
+			keys := []string{}
+			for k := range backend.MetadataInfo.System {
+				keys = append(keys, k)
+			}
+			sort.Strings(keys)
+			fmt.Printf("| Name | Help | Type | Example | Read Only |\n")
+			fmt.Printf("|------|------|------|---------|-----------|\n")
+			for _, k := range keys {
+				v := backend.MetadataInfo.System[k]
+				ro := "N"
+				if v.ReadOnly {
+					ro = "**Y**"
+				}
+				fmt.Printf("| %s | %s | %s | %s | %s |\n", k, v.Help, v.Type, v.Example, ro)
+			}
+			fmt.Printf("\n")
+		}
+		fmt.Printf("See the [metadata](/docs/#metadata) docs for more info.\n\n")
+	}
 }
diff --git a/cmd/lsf/lsf.go b/cmd/lsf/lsf.go
index 732d2b5e8..e370f930c 100644
--- a/cmd/lsf/lsf.go
+++ b/cmd/lsf/lsf.go
@@ -72,6 +72,7 @@ output:
     m - MimeType of object if known
     e - encrypted name
     T - tier of storage if known, e.g. "Hot" or "Cool"
+    M - Metadata of object in JSON blob format, eg {"key":"value"}
 
 So if you wanted the path, size and modification time, you would use
 ` + "`--format \"pst\"`, or maybe `--format \"tsp\"`" + ` to put the path last.
@@ -198,6 +199,9 @@ func Lsf(ctx context.Context, fsrc fs.Fs, out io.Writer) error {
 			opt.ShowOrigIDs = true
 		case 'T':
 			list.AddTier()
+		case 'M':
+			list.AddMetadata()
+			opt.Metadata = true
 		default:
 			return fmt.Errorf("unknown format character %q", char)
 		}
diff --git a/cmd/lsjson/lsjson.go b/cmd/lsjson/lsjson.go
index 70b96a0f7..8e4e343da 100644
--- a/cmd/lsjson/lsjson.go
+++ b/cmd/lsjson/lsjson.go
@@ -26,10 +26,11 @@ func init() {
 	flags.BoolVarP(cmdFlags, &opt.ShowHash, "hash", "", false, "Include hashes in the output (may take longer)")
 	flags.BoolVarP(cmdFlags, &opt.NoModTime, "no-modtime", "", false, "Don't read the modification time (can speed things up)")
 	flags.BoolVarP(cmdFlags, &opt.NoMimeType, "no-mimetype", "", false, "Don't read the mime type (can speed things up)")
-	flags.BoolVarP(cmdFlags, &opt.ShowEncrypted, "encrypted", "M", false, "Show the encrypted names")
+	flags.BoolVarP(cmdFlags, &opt.ShowEncrypted, "encrypted", "", false, "Show the encrypted names")
 	flags.BoolVarP(cmdFlags, &opt.ShowOrigIDs, "original", "", false, "Show the ID of the underlying Object")
 	flags.BoolVarP(cmdFlags, &opt.FilesOnly, "files-only", "", false, "Show only files in the listing")
 	flags.BoolVarP(cmdFlags, &opt.DirsOnly, "dirs-only", "", false, "Show only directories in the listing")
+	flags.BoolVarP(cmdFlags, &opt.Metadata, "metadata", "M", false, "Add metadata to the listing")
 	flags.StringArrayVarP(cmdFlags, &opt.HashTypes, "hash-type", "", nil, "Show only this hash type (may be repeated)")
 	flags.BoolVarP(cmdFlags, &statOnly, "stat", "", false, "Just return the info for the pointed to file")
 }
@@ -81,6 +82,9 @@ returned
 If ` + "`--files-only`" + ` is not specified directories in addition to the files
 will be returned.
 
+If ` + "`--metadata`" + ` is set then an additional Metadata key will be returned.
+This will have metdata in rclone standard format as a JSON object.
+
 if ` + "`--stat`" + ` is set then a single JSON blob will be returned about the
 item pointed to. This will return an error if the item isn't found.
 However on bucket based backends (like s3, gcs, b2, azureblob etc) if
diff --git a/docs/content/_index.md b/docs/content/_index.md
index dcf51e23f..58410d6d4 100644
--- a/docs/content/_index.md
+++ b/docs/content/_index.md
@@ -132,6 +132,7 @@ WebDAV or S3, that work out of the box.)
 {{< provider name="Internet Archive" home="https://archive.org/" config="/internetarchive/" >}}
 {{< provider name="Jottacloud" home="https://www.jottacloud.com/en/" config="/jottacloud/" >}}
 {{< provider name="IBM COS S3" home="http://www.ibm.com/cloud/object-storage" config="/s3/#ibm-cos-s3" >}}
+{{< provider name="IDrive e2" home="https://www.idrive.com/e2/" config="/s3/#idrive-e2" >}}
 {{< provider name="Koofr" home="https://koofr.eu/" config="/koofr/" >}}
 {{< provider name="Mail.ru Cloud" home="https://cloud.mail.ru/" config="/mailru/" >}}
 {{< provider name="Memset Memstore" home="https://www.memset.com/cloud/storage/" config="/swift/" >}}
diff --git a/docs/content/authors.md b/docs/content/authors.md
index 90ea4b9fb..66f33c154 100644
--- a/docs/content/authors.md
+++ b/docs/content/authors.md
@@ -617,3 +617,6 @@ put them back in again.` >}}
   * Caleb <inventor96@users.noreply.github.com>
   * J-P Treen <jp@wraptious.com>
   * Martin Czygan <53705+miku@users.noreply.github.com>
+  * buda <sandrojijavadze@protonmail.com>
+  * mirekphd <36706320+mirekphd@users.noreply.github.com>
+  * vyloy <vyloy@qq.com>
diff --git a/docs/content/docs.md b/docs/content/docs.md
index 66777e1b8..a2bc38b07 100644
--- a/docs/content/docs.md
+++ b/docs/content/docs.md
@@ -425,6 +425,126 @@ This can be used when scripting to make aged backups efficiently, e.g.
     rclone sync -i remote:current-backup remote:previous-backup
     rclone sync -i /path/to/files remote:current-backup
 
+## Metadata support {#metadata}
+
+Metadata is data about a file which isn't the contents of the file.
+Normally rclone only preserves the modification time and the content
+(MIME) type where possible.
+
+Rclone supports preserving all the available metadata on files (not
+directories) when using the `--metadata` or `-M` flag.
+
+Exactly what metadata is supported and what that support means depends
+on the backend. Backends that support metadata have a metadata section
+in their docs and are listed in the [features table](/overview/#features)
+(Eg [local](/local/#metadata), [s3](/s3/#metadata))
+
+Rclone only supports a one-time sync of metadata. This means that
+metadata will be synced from the source object to the destination
+object only when the source object has changed and needs to be
+re-uploaded. If the metadata subsequently changes on the source object
+without changing the object itself then it won't be synced to the
+destination object. This is in line with the way rclone syncs
+`Content-Type` without the `--metadata` flag.
+
+Using `--metadata` when syncing from local to local will preserve file
+attributes such as file mode, owner, extended attributes (not
+Windows).
+
+Note that arbitrary metadata may be added to objects using the
+`--metadata-set key=value` flag when the object is first uploaded.
+This flag can be repeated as many times as necessary.
+
+### Types of metadata
+
+Metadata is divided into two type. System metadata and User metadata.
+
+Metadata which the backend uses itself is called system metadata. For
+example on the local backend the system metadata `uid` will store the
+user ID of the file when used on a unix based platform.
+
+Arbitrary metadata is called user metadata and this can be set however
+is desired.
+
+When objects are copied from backend to backend, they will attempt to
+interpret system metadata if it is supplied. Metadata may change from
+being user metadata to system metadata as objects are copied between
+different backends. For example copying an object from s3 sets the
+`content-type` metadata. In a backend which understands this (like
+`azureblob`) this will become the Content-Type of the object. In a
+backend which doesn't understand this (like the `local` backend) this
+will become user metadata. However should the local object be copied
+back to s3, the Content-Type will be set correctly.
+
+### Metadata framework
+
+Rclone implements a metadata framework which can read metadata from an
+object and write it to the object when (and only when) it is being
+uploaded.
+
+This metadata is stored as a dictionary with string keys and string
+values.
+
+There are some limits on the names of the keys (these may be clarified
+further in the future).
+
+- must be lower case
+- may be `a-z` `0-9` containing `.` `-` or `_`
+- length is backend dependent
+
+Each backend can provide system metadata that it understands. Some
+backends can also store arbitrary user metadata.
+
+Where possible the key names are standardized, so, for example, it is
+possible to copy object metadata from s3 to azureblob for example and
+metadata will be translated apropriately.
+
+Some backends have limits on the size of the metadata and rclone will
+give errors on upload if they are exceeded.
+
+### Metadata preservation
+
+The goal of the implementation is to
+
+1. Preserve metadata if at all possible
+2. Interpret metadata if at all possible
+
+The consequences of 1 is that you can copy an S3 object to a local
+disk then back to S3 losslessly. Likewise you can copy a local file
+with file attributes and xattrs from local disk to s3 and back again
+losslessly.
+
+The consequence of 2 is that you can copy an S3 object with metadata
+to Azureblob (say) and have the metadata appear on the Azureblob
+object also.
+
+### Standard system metadata
+
+Here is a table of standard system metadata which, if appropriate, a
+backend may implement.
+
+| key                 | description | example |
+|---------------------|-------------|---------|
+| mode                | File type and mode: octal, unix style | 0100664 |
+| uid                 | User ID of owner: decimal number | 500 |
+| gid                 | Group ID of owner: decimal number | 500 |
+| rdev                | Device ID (if special file)  => hexadecimal | 0 |
+| atime               | Time of last access:  RFC 3339 | 2006-01-02T15:04:05.999999999Z07:00 |
+| mtime               | Time of last modification:  RFC 3339 | 2006-01-02T15:04:05.999999999Z07:00 |
+| btime               | Time of file creation (birth):  RFC 3339 | 2006-01-02T15:04:05.999999999Z07:00 |
+| cache-control       | Cache-Control header | no-cache |
+| content-disposition | Content-Disposition header | inline |
+| content-encoding    | Content-Encoding header | gzip |
+| content-language    | Content-Language header | en-US |
+| content-type        | Content-Type header | text/plain |
+
+The metadata keys `mtime` and `content-type` will take precedence if
+supplied in the metadata over reading the `Content-Type` or
+modification time of the source object.
+
+Hashes are not included in system metadata as there is a well defined
+way of reading those already.
+
 Options
 -------
 
@@ -1206,6 +1326,18 @@ When the limit is reached all transfers will stop immediately.
 
 Rclone will exit with exit code 8 if the transfer limit is reached.
 
+## --metadata / -M
+
+Setting this flag enables rclone to copy the metadata from the source
+to the destination. For local backends this is ownership, permissions,
+xattr etc. See the [#metadata](metadata section) for more info.
+
+### --metadata-set key=value
+
+Add metadata `key` = `value` when uploading. This can be repeated as
+many times as required. See the [#metadata](metadata section) for more
+info.
+
 ### --cutoff-mode=hard|soft|cautious ###
 
 This modifies the behavior of `--max-transfer`
diff --git a/docs/content/local.md b/docs/content/local.md
index a5004108f..c17a22c1a 100644
--- a/docs/content/local.md
+++ b/docs/content/local.md
@@ -563,6 +563,32 @@ Properties:
 - Type:        MultiEncoder
 - Default:     Slash,Dot
 
+### Metadata
+
+Depending on which OS is in use the local backend may return only some
+of the system metadata. Setting system metadata is supported on all
+OSes but setting user metadata is only supported on linux, freebsd,
+netbsd, macOS and Solaris. It is **not** supported on Windows yet
+([see pkg/attrs#47](https://github.com/pkg/xattr/issues/47)).
+
+User metadata is stored as extended attributes (which may not be
+supported by all file systems) under the "user.*" prefix.
+
+Here are the possible system metadata items for the local backend.
+
+| Name | Help | Type | Example | Read Only |
+|------|------|------|---------|-----------|
+| atime | Time of last access | RFC 3339 | 2006-01-02T15:04:05.999999999Z07:00 | N |
+| btime | Time of file birth (creation) | RFC 3339 | 2006-01-02T15:04:05.999999999Z07:00 | N |
+| gid | Group ID of owner | decimal number | 500 | N |
+| mode | File type and mode | octal, unix style | 0100664 | N |
+| mtime | Time of last modification | RFC 3339 | 2006-01-02T15:04:05.999999999Z07:00 | N |
+| rdev | Device ID (if special file) | hexadecimal | 1abc | N |
+| uid | User ID of owner | decimal number | 500 | N |
+
+
+See the [metadata](/docs/#metadata) docs for more info.
+
 ## Backend commands
 
 Here are the commands specific to the local backend.
diff --git a/docs/content/overview.md b/docs/content/overview.md
index c107723ea..74194f28f 100644
--- a/docs/content/overview.md
+++ b/docs/content/overview.md
@@ -14,48 +14,48 @@ show through.
 
 Here is an overview of the major features of each cloud storage system.
 
-| Name                         | Hash             | ModTime | Case Insensitive | Duplicate Files | MIME Type |
-| ---------------------------- |:----------------:|:-------:|:----------------:|:---------------:|:---------:|
-| 1Fichier                     | Whirlpool        | -       | No               | Yes             | R         |
-| Akamai Netstorage            | MD5, SHA256      | R/W     | No               | No              | R         |
-| Amazon Drive                 | MD5              | -       | Yes              | No              | R         |
-| Amazon S3 (or S3 compatible) | MD5              | R/W     | No               | No              | R/W       |
-| Backblaze B2                 | SHA1             | R/W     | No               | No              | R/W       |
-| Box                          | SHA1             | R/W     | Yes              | No              | -         |
-| Citrix ShareFile             | MD5              | R/W     | Yes              | No              | -         |
-| Dropbox                      | DBHASH ¹         | R       | Yes              | No              | -         |
-| Enterprise File Fabric       | -                | R/W     | Yes              | No              | R/W       |
-| FTP                          | -                | R/W ¹⁰  | No               | No              | -         |
-| Google Cloud Storage         | MD5              | R/W     | No               | No              | R/W       |
-| Google Drive                 | MD5              | R/W     | No               | Yes             | R/W       |
-| Google Photos                | -                | -       | No               | Yes             | R         |
-| HDFS                         | -                | R/W     | No               | No              | -         |
-| HTTP                         | -                | R       | No               | No              | R         |
-| Hubic                        | MD5              | R/W     | No               | No              | R/W       |
-| Internet Archive             | MD5, SHA1, CRC32 | R/W ¹¹  | No               | No              | -         |
-| Jottacloud                   | MD5              | R/W     | Yes              | No              | R         |
-| Koofr                        | MD5              | -       | Yes              | No              | -         |
-| Mail.ru Cloud                | Mailru ⁶         | R/W     | Yes              | No              | -         |
-| Mega                         | -                | -       | No               | Yes             | -         |
-| Memory                       | MD5              | R/W     | No               | No              | -         |
-| Microsoft Azure Blob Storage | MD5              | R/W     | No               | No              | R/W       |
-| Microsoft OneDrive           | SHA1 ⁵           | R/W     | Yes              | No              | R         |
-| OpenDrive                    | MD5              | R/W     | Yes              | Partial ⁸       | -         |
-| OpenStack Swift              | MD5              | R/W     | No               | No              | R/W       |
-| pCloud                       | MD5, SHA1 ⁷      | R       | No               | No              | W         |
-| premiumize.me                | -                | -       | Yes              | No              | R         |
-| put.io                       | CRC-32           | R/W     | No               | Yes             | R         |
-| QingStor                     | MD5              | - ⁹     | No               | No              | R/W       |
-| Seafile                      | -                | -       | No               | No              | -         |
-| SFTP                         | MD5, SHA1 ²      | R/W     | Depends          | No              | -         |
-| Sia                          | -                | -       | No               | No              | -         |
-| SugarSync                    | -                | -       | No               | No              | -         |
-| Storj                        | -                | R       | No               | No              | -         |
-| Uptobox                      | -                | -       | No               | Yes             | -         |
-| WebDAV                       | MD5, SHA1 ³      | R ⁴     | Depends          | No              | -         |
-| Yandex Disk                  | MD5              | R/W     | No               | No              | R         |
-| Zoho WorkDrive               | -                | -       | No               | No              | -         |
-| The local filesystem         | All              | R/W     | Depends          | No              | -         |
+| Name                         | Hash             | ModTime | Case Insensitive | Duplicate Files | MIME Type | Metadata |
+| ---------------------------- |:----------------:|:-------:|:----------------:|:---------------:|:---------:|:--------:|
+| 1Fichier                     | Whirlpool        | -       | No               | Yes             | R         | -        |
+| Akamai Netstorage            | MD5, SHA256      | R/W     | No               | No              | R         | -        |
+| Amazon Drive                 | MD5              | -       | Yes              | No              | R         | -        |
+| Amazon S3 (or S3 compatible) | MD5              | R/W     | No               | No              | R/W       | RWU      |
+| Backblaze B2                 | SHA1             | R/W     | No               | No              | R/W       | -        |
+| Box                          | SHA1             | R/W     | Yes              | No              | -         | -        |
+| Citrix ShareFile             | MD5              | R/W     | Yes              | No              | -         | -        |
+| Dropbox                      | DBHASH ¹         | R       | Yes              | No              | -         | -        |
+| Enterprise File Fabric       | -                | R/W     | Yes              | No              | R/W       | -        |
+| FTP                          | -                | R/W ¹⁰  | No               | No              | -         | -        |
+| Google Cloud Storage         | MD5              | R/W     | No               | No              | R/W       | -        |
+| Google Drive                 | MD5              | R/W     | No               | Yes             | R/W       | -        |
+| Google Photos                | -                | -       | No               | Yes             | R         | -        |
+| HDFS                         | -                | R/W     | No               | No              | -         | -        |
+| HTTP                         | -                | R       | No               | No              | R         | -        |
+| Hubic                        | MD5              | R/W     | No               | No              | R/W       | -        |
+| Internet Archive             | MD5, SHA1, CRC32 | R/W ¹¹  | No               | No              | -         | -        |
+| Jottacloud                   | MD5              | R/W     | Yes              | No              | R         | -        |
+| Koofr                        | MD5              | -       | Yes              | No              | -         | -        |
+| Mail.ru Cloud                | Mailru ⁶         | R/W     | Yes              | No              | -         | -        |
+| Mega                         | -                | -       | No               | Yes             | -         | -        |
+| Memory                       | MD5              | R/W     | No               | No              | -         | -        |
+| Microsoft Azure Blob Storage | MD5              | R/W     | No               | No              | R/W       | -        |
+| Microsoft OneDrive           | SHA1 ⁵           | R/W     | Yes              | No              | R         | -        |
+| OpenDrive                    | MD5              | R/W     | Yes              | Partial ⁸       | -         | -        |
+| OpenStack Swift              | MD5              | R/W     | No               | No              | R/W       | -        |
+| pCloud                       | MD5, SHA1 ⁷      | R       | No               | No              | W         | -        |
+| premiumize.me                | -                | -       | Yes              | No              | R         | -        |
+| put.io                       | CRC-32           | R/W     | No               | Yes             | R         | -        |
+| QingStor                     | MD5              | - ⁹     | No               | No              | R/W       | -        |
+| Seafile                      | -                | -       | No               | No              | -         | -        |
+| SFTP                         | MD5, SHA1 ²      | R/W     | Depends          | No              | -         | -        |
+| Sia                          | -                | -       | No               | No              | -         | -        |
+| SugarSync                    | -                | -       | No               | No              | -         | -        |
+| Storj                        | -                | R       | No               | No              | -         | -        |
+| Uptobox                      | -                | -       | No               | Yes             | -         | -        |
+| WebDAV                       | MD5, SHA1 ³      | R ⁴     | Depends          | No              | -         | -        |
+| Yandex Disk                  | MD5              | R/W     | No               | No              | R         | -        |
+| Zoho WorkDrive               | -                | -       | No               | No              | -         | -        |
+| The local filesystem         | All              | R/W     | Depends          | No              | -         | RWU      |
 
 ### Notes
 
@@ -438,6 +438,22 @@ remote which supports writing (`W`) then rclone will preserve the MIME
 types.  Otherwise they will be guessed from the extension, or the
 remote itself may assign the MIME type.
 
+### Metadata
+
+Backends may or may support reading or writing metadata. They may
+support reading and writing system metadata (metadata intrinsic to
+that backend) and/or user metadata (general purpose metadata).
+
+The levels of metadata support are
+
+| Key | Explanation |
+|-----|-------------|
+| `R` | Read only System Metadata |
+| `RW` | Read and write System Metadata |
+| `RWU` | Read and write System Metadata and read and write User Metadata |
+
+See [the metadata docs](/docs/#metadata) for more info.
+
 ## Optional Features ##
 
 All rclone remotes support a base command set. Other features depend
diff --git a/docs/content/s3.md b/docs/content/s3.md
index fbd47c763..ddf9fd23d 100644
--- a/docs/content/s3.md
+++ b/docs/content/s3.md
@@ -18,6 +18,7 @@ The S3 backend can be used with a number of different providers:
 {{< provider name="Dreamhost" home="https://www.dreamhost.com/cloud/storage/" config="/s3/#dreamhost" >}}
 {{< provider name="Huawei OBS" home="https://www.huaweicloud.com/intl/en-us/product/obs.html" config="/s3/#huawei-obs" >}}
 {{< provider name="IBM COS S3" home="http://www.ibm.com/cloud/object-storage" config="/s3/#ibm-cos-s3" >}}
+{{< provider name="IDrive e2" home="https://www.idrive.com/e2/" config="/s3/#idrive-e2" >}}
 {{< provider name="Minio" home="https://www.minio.io/" config="/s3/#minio" >}}
 {{< 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" >}}
@@ -570,7 +571,7 @@ A simple solution is to set the `--s3-upload-cutoff 0` and force all the files t
 {{< rem autogenerated options start" - DO NOT EDIT - instead edit fs.RegInfo in backend/s3/s3.go then run make backenddocs" >}}
 ### Standard options
 
-Here are the standard options specific to s3 (Amazon S3 Compliant Storage Providers including AWS, Alibaba, Ceph, ChinaMobile, ArvanCloud, Digital Ocean, Dreamhost, Huawei OBS, IBM COS, Lyve Cloud, Minio, RackCorp, SeaweedFS, and Tencent COS).
+Here are the standard options specific to s3 (Amazon S3 Compliant Storage Providers including AWS, Alibaba, Ceph, ChinaMobile, ArvanCloud, Digital Ocean, Dreamhost, Huawei OBS, IBM COS, IDrive e2, Lyve Cloud, Minio, RackCorp, SeaweedFS, and Tencent COS).
 
 #### --s3-provider
 
@@ -601,6 +602,8 @@ Properties:
         - Huawei Object Storage Service
     - "IBMCOS"
         - IBM COS S3
+    - "IDrive"
+        - IDrive e2
     - "LyveCloud"
         - Seagate Lyve Cloud
     - "Minio"
@@ -836,7 +839,7 @@ Properties:
 
 - Config:      region
 - Env Var:     RCLONE_S3_REGION
-- Provider:    !AWS,Alibaba,ChinaMobile,ArvanCloud,RackCorp,Scaleway,Storj,TencentCOS,HuaweiOBS
+- Provider:    !AWS,Alibaba,ChinaMobile,ArvanCloud,RackCorp,Scaleway,Storj,TencentCOS,HuaweiOBS,IDrive
 - Type:        string
 - Required:    false
 - Examples:
@@ -1362,7 +1365,7 @@ Properties:
 
 - Config:      endpoint
 - Env Var:     RCLONE_S3_ENDPOINT
-- Provider:    !AWS,IBMCOS,TencentCOS,Alibaba,ChinaMobile,ArvanCloud,Scaleway,StackPath,Storj,RackCorp,HuaweiOBS
+- Provider:    !AWS,IBMCOS,IDrive,TencentCOS,Alibaba,ChinaMobile,ArvanCloud,Scaleway,StackPath,Storj,RackCorp,HuaweiOBS
 - Type:        string
 - Required:    false
 - Examples:
@@ -1601,7 +1604,7 @@ Properties:
 
 - Config:      location_constraint
 - Env Var:     RCLONE_S3_LOCATION_CONSTRAINT
-- Provider:    !AWS,IBMCOS,Alibaba,ChinaMobile,ArvanCloud,RackCorp,Scaleway,StackPath,Storj,TencentCOS,HuaweiOBS
+- Provider:    !AWS,IBMCOS,IDrive,Alibaba,ChinaMobile,ArvanCloud,RackCorp,Scaleway,StackPath,Storj,TencentCOS,HuaweiOBS
 - Type:        string
 - Required:    false
 
@@ -2366,6 +2369,26 @@ Properties:
 - Type:        Tristate
 - Default:     unset
 
+### Metadata
+
+User metadata is stored as x-amz-meta- keys. S3 metadata keys are case insensitive and are always returned in lower case.
+
+Here are the possible system metadata items for the s3 backend.
+
+| Name | Help | Type | Example | Read Only |
+|------|------|------|---------|-----------|
+| btime | Time of file birth (creation) read from Last-Modified header | RFC 3339 | 2006-01-02T15:04:05.999999999Z07:00 | **Y** |
+| cache-control | Cache-Control header | string | no-cache | N |
+| content-disposition | Content-Disposition header | string | inline | N |
+| content-encoding | Content-Encoding header | string | gzip | N |
+| content-language | Content-Language header | string | en-US | N |
+| content-type | Content-Type header | string | text/plain | N |
+| mtime | Time of last modification, read from rclone metadata | RFC 3339 | 2006-01-02T15:04:05.999999999Z07:00 | N |
+| tier | Tier of the object | string | GLACIER | **Y** |
+
+
+See the [metadata](/docs/#metadata) docs for more info.
+
 ## Backend commands
 
 Here are the commands specific to the s3 backend.
@@ -3076,6 +3099,116 @@ acl> 1
 		rclone delete IBM-COS-XREGION:newbucket/file.txt
 ```
 
+### IDrive e2 {#idrive-e2}
+
+Here is an example of making an [IDrive e2](https://www.idrive.com/e2/)
+configuration.  First run:
+
+    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
+q) Quit config
+n/s/q> n
+
+Enter name for new remote.
+name> e2
+
+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, Alibaba, Ceph, China Mobile, Cloudflare, ArvanCloud, Digital Ocean, Dreamhost, Huawei OBS, IBM COS, IDrive e2, Lyve Cloud, Minio, Netease, RackCorp, Scaleway, SeaweedFS, StackPath, Storj, Tencent COS and Wasabi
+   \ (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 / IDrive e2
+   \ (IDrive)
+[snip]
+provider> IDrive
+
+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> 
+
+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
+
+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_KEY
+
+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.
+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> 
+
+Edit advanced config?
+y) Yes
+n) No (default)
+y/n> 
+
+Configuration complete.
+Options:
+- type: s3
+- provider: IDrive
+- access_key_id: YOUR_ACCESS_KEY
+- secret_access_key: YOUR_SECRET_KEY
+- endpoint: q9d9.la12.idrivee2-5.com
+Keep this "e2" remote?
+y) Yes this is OK (default)
+e) Edit this remote
+d) Delete this remote
+y/e/d> y
+```
+
 ### Minio
 
 [Minio](https://minio.io/) is an object storage server built for cloud application developers and devops.
diff --git a/fs/cache/cache.go b/fs/cache/cache.go
index 3bdc8d37f..4e2c4638e 100644
--- a/fs/cache/cache.go
+++ b/fs/cache/cache.go
@@ -25,6 +25,11 @@ func createOnFirstUse() {
 		c = cache.New()
 		c.SetExpireDuration(ci.FsCacheExpireDuration)
 		c.SetExpireInterval(ci.FsCacheExpireInterval)
+		c.SetFinalizer(func(value interface{}) {
+			if s, ok := value.(fs.Shutdowner); ok {
+				_ = fs.CountError(s.Shutdown(context.Background()))
+			}
+		})
 	})
 }
 
diff --git a/fs/config.go b/fs/config.go
index e96be37d0..a39779bae 100644
--- a/fs/config.go
+++ b/fs/config.go
@@ -124,6 +124,7 @@ type ConfigInfo struct {
 	UploadHeaders          []*HTTPOption
 	DownloadHeaders        []*HTTPOption
 	Headers                []*HTTPOption
+	MetadataSet            Metadata // extra metadata to write when uploading
 	RefreshTimes           bool
 	NoConsole              bool
 	TrafficClass           uint8
@@ -133,6 +134,7 @@ type ConfigInfo struct {
 	HumanReadable          bool
 	KvLockTime             time.Duration // maximum time to keep key-value database locked by process
 	DisableHTTPKeepAlives  bool
+	Metadata               bool
 }
 
 // NewConfig creates a new config with everything set to the default
diff --git a/fs/config/configflags/configflags.go b/fs/config/configflags/configflags.go
index 8e3e8d558..61aa65f91 100644
--- a/fs/config/configflags/configflags.go
+++ b/fs/config/configflags/configflags.go
@@ -37,6 +37,7 @@ var (
 	uploadHeaders   []string
 	downloadHeaders []string
 	headers         []string
+	metadataSet     []string
 )
 
 // AddFlags adds the non filing system specific flags to the command
@@ -129,6 +130,7 @@ func AddFlags(ci *fs.ConfigInfo, flagSet *pflag.FlagSet) {
 	flags.StringArrayVarP(flagSet, &uploadHeaders, "header-upload", "", nil, "Set HTTP header for upload transactions")
 	flags.StringArrayVarP(flagSet, &downloadHeaders, "header-download", "", nil, "Set HTTP header for download transactions")
 	flags.StringArrayVarP(flagSet, &headers, "header", "", nil, "Set HTTP header for all transactions")
+	flags.StringArrayVarP(flagSet, &metadataSet, "metadata-set", "", nil, "Add metadata key=value when uploading")
 	flags.BoolVarP(flagSet, &ci.RefreshTimes, "refresh-times", "", ci.RefreshTimes, "Refresh the modtime of remote files")
 	flags.BoolVarP(flagSet, &ci.NoConsole, "no-console", "", ci.NoConsole, "Hide console window (supported on Windows only)")
 	flags.StringVarP(flagSet, &dscp, "dscp", "", "", "Set DSCP value to connections, value or name, e.g. CS1, LE, DF, AF21")
@@ -138,6 +140,7 @@ func AddFlags(ci *fs.ConfigInfo, flagSet *pflag.FlagSet) {
 	flags.BoolVarP(flagSet, &ci.HumanReadable, "human-readable", "", ci.HumanReadable, "Print numbers in a human-readable format, sizes with suffix Ki|Mi|Gi|Ti|Pi")
 	flags.DurationVarP(flagSet, &ci.KvLockTime, "kv-lock-time", "", ci.KvLockTime, "Maximum time to keep key-value database locked by process")
 	flags.BoolVarP(flagSet, &ci.DisableHTTPKeepAlives, "disable-http-keep-alives", "", ci.DisableHTTPKeepAlives, "Disable HTTP keep-alives and use each connection once.")
+	flags.BoolVarP(flagSet, &ci.Metadata, "metadata", "M", ci.Metadata, "If set, preserve metadata when copying objects")
 }
 
 // ParseHeaders converts the strings passed in via the header flags into HTTPOptions
@@ -271,6 +274,20 @@ func SetFlags(ci *fs.ConfigInfo) {
 	if len(headers) != 0 {
 		ci.Headers = ParseHeaders(headers)
 	}
+	if len(headers) != 0 {
+		ci.Headers = ParseHeaders(headers)
+	}
+	if len(metadataSet) != 0 {
+		ci.MetadataSet = make(fs.Metadata, len(metadataSet))
+		for _, kv := range metadataSet {
+			equal := strings.IndexRune(kv, '=')
+			if equal < 0 {
+				log.Fatalf("Failed to parse '%s' as metadata key=value.", kv)
+			}
+			ci.MetadataSet[strings.ToLower(kv[:equal])] = kv[equal+1:]
+		}
+		fs.Debugf(nil, "MetadataUpload %v", ci.MetadataSet)
+	}
 	if len(dscp) != 0 {
 		if value, ok := parseDSCP(dscp); ok {
 			ci.TrafficClass = value << 2
diff --git a/fs/features.go b/fs/features.go
index 6db6bd548..6fce02d60 100644
--- a/fs/features.go
+++ b/fs/features.go
@@ -26,6 +26,9 @@ type Features struct {
 	IsLocal                 bool // is the local backend
 	SlowModTime             bool // if calling ModTime() generally takes an extra transaction
 	SlowHash                bool // if calling Hash() generally takes an extra transaction
+	ReadMetadata            bool // can read metadata from objects
+	WriteMetadata           bool // can write metadata to objects
+	UserMetadata            bool // can read/write general purpose metadata
 
 	// Purge all files in the directory specified
 	//
@@ -305,6 +308,9 @@ func (ft *Features) Mask(ctx context.Context, f Fs) *Features {
 	ft.DuplicateFiles = ft.DuplicateFiles && mask.DuplicateFiles
 	ft.ReadMimeType = ft.ReadMimeType && mask.ReadMimeType
 	ft.WriteMimeType = ft.WriteMimeType && mask.WriteMimeType
+	ft.ReadMetadata = ft.ReadMetadata && mask.ReadMetadata
+	ft.WriteMetadata = ft.WriteMetadata && mask.WriteMetadata
+	ft.UserMetadata = ft.UserMetadata && mask.UserMetadata
 	ft.CanHaveEmptyDirectories = ft.CanHaveEmptyDirectories && mask.CanHaveEmptyDirectories
 	ft.BucketBased = ft.BucketBased && mask.BucketBased
 	ft.BucketBasedRootOK = ft.BucketBasedRootOK && mask.BucketBasedRootOK
diff --git a/fs/metadata.go b/fs/metadata.go
new file mode 100644
index 000000000..1fa48dc77
--- /dev/null
+++ b/fs/metadata.go
@@ -0,0 +1,85 @@
+package fs
+
+import "context"
+
+// Metadata represents Object metadata in a standardised form
+//
+// See docs/content/metadata.md for the interpretation of the keys
+type Metadata map[string]string
+
+// MetadataHelp represents help for a bit of system metadata
+type MetadataHelp struct {
+	Help     string
+	Type     string
+	Example  string
+	ReadOnly bool
+}
+
+// MetadataInfo is help for the whole metadata for this backend.
+type MetadataInfo struct {
+	System map[string]MetadataHelp
+	Help   string
+}
+
+// Set k to v on m
+//
+// If m is nil, then it will get made
+func (m *Metadata) Set(k, v string) {
+	if *m == nil {
+		*m = make(Metadata, 1)
+	}
+	(*m)[k] = v
+}
+
+// Merge other into m
+//
+// If m is nil, then it will get made
+func (m *Metadata) Merge(other Metadata) {
+	for k, v := range other {
+		if *m == nil {
+			*m = make(Metadata, len(other))
+		}
+		(*m)[k] = v
+	}
+}
+
+// MergeOptions gets any Metadata from the options passed in and
+// stores it in m (which may be nil).
+//
+// If there is no m then metadata will be nil
+func (m *Metadata) MergeOptions(options []OpenOption) {
+	for _, opt := range options {
+		if metadataOption, ok := opt.(MetadataOption); ok {
+			m.Merge(Metadata(metadataOption))
+		}
+	}
+}
+
+// GetMetadata from an ObjectInfo
+//
+// If the object has no metadata then metadata will be nil
+func GetMetadata(ctx context.Context, o ObjectInfo) (metadata Metadata, err error) {
+	do, ok := o.(Metadataer)
+	if !ok {
+		return nil, nil
+	}
+	return do.Metadata(ctx)
+}
+
+// GetMetadataOptions from an ObjectInfo and merge it with any in options
+//
+// If --metadata isn't in use it will return nil
+//
+// If the object has no metadata then metadata will be nil
+func GetMetadataOptions(ctx context.Context, o ObjectInfo, options []OpenOption) (metadata Metadata, err error) {
+	ci := GetConfig(ctx)
+	if !ci.Metadata {
+		return nil, nil
+	}
+	metadata, err = GetMetadata(ctx, o)
+	if err != nil {
+		return nil, err
+	}
+	metadata.MergeOptions(options)
+	return metadata, nil
+}
diff --git a/fs/open_options.go b/fs/open_options.go
index e357a9a69..57daa132a 100644
--- a/fs/open_options.go
+++ b/fs/open_options.go
@@ -258,6 +258,24 @@ func (o NullOption) Mandatory() bool {
 	return false
 }
 
+// MetadataOption defines an Option which does nothing
+type MetadataOption Metadata
+
+// Header formats the option as an http header
+func (o MetadataOption) Header() (key string, value string) {
+	return "", ""
+}
+
+// String formats the option into human-readable form
+func (o MetadataOption) String() string {
+	return fmt.Sprintf("MetadataOption(%v)", Metadata(o))
+}
+
+// Mandatory returns whether the option must be parsed or can be ignored
+func (o MetadataOption) Mandatory() bool {
+	return false
+}
+
 // OpenOptionAddHeaders adds each header found in options to the
 // headers map provided the key was non empty.
 func OpenOptionAddHeaders(options []OpenOption, headers map[string]string) {
diff --git a/fs/operations/lsjson.go b/fs/operations/lsjson.go
index 144a47bb6..067681956 100644
--- a/fs/operations/lsjson.go
+++ b/fs/operations/lsjson.go
@@ -29,6 +29,7 @@ type ListJSONItem struct {
 	OrigID        string            `json:",omitempty"`
 	Tier          string            `json:",omitempty"`
 	IsBucket      bool              `json:",omitempty"`
+	Metadata      fs.Metadata       `json:",omitempty"`
 }
 
 // Timestamp a time in the provided format
@@ -80,6 +81,7 @@ type ListJSONOpt struct {
 	ShowHash      bool     `json:"showHash"`
 	DirsOnly      bool     `json:"dirsOnly"`
 	FilesOnly     bool     `json:"filesOnly"`
+	Metadata      bool     `json:"metadata"`
 	HashTypes     []string `json:"hashTypes"` // hash types to show if ShowHash is set, e.g. "MD5", "SHA-1"
 }
 
@@ -222,6 +224,14 @@ func (lj *listJSON) entry(ctx context.Context, entry fs.DirEntry) (*ListJSONItem
 				item.Tier = do.GetTier()
 			}
 		}
+		if lj.opt.Metadata {
+			metadata, err := fs.GetMetadata(ctx, x)
+			if err != nil {
+				fs.Errorf(x, "Failed to read metadata: %v", err)
+			} else if metadata != nil {
+				item.Metadata = metadata
+			}
+		}
 	default:
 		fs.Errorf(nil, "Unknown type %T in listing in ListJSON", entry)
 	}
diff --git a/fs/operations/operations.go b/fs/operations/operations.go
index bdeeaf036..1ea41853f 100644
--- a/fs/operations/operations.go
+++ b/fs/operations/operations.go
@@ -7,6 +7,7 @@ import (
 	"encoding/base64"
 	"encoding/csv"
 	"encoding/hex"
+	"encoding/json"
 	"errors"
 	"fmt"
 	"io"
@@ -352,6 +353,16 @@ func (o *OverrideRemote) GetTier() string {
 	return ""
 }
 
+// Metadata returns metadata for an object
+//
+// It should return nil if there is no Metadata
+func (o *OverrideRemote) Metadata(ctx context.Context) (fs.Metadata, error) {
+	if do, ok := o.ObjectInfo.(fs.Metadataer); ok {
+		return do.Metadata(ctx)
+	}
+	return nil, nil
+}
+
 // Check all optional interfaces satisfied
 var _ fs.FullObjectInfo = (*OverrideRemote)(nil)
 
@@ -479,6 +490,9 @@ func Copy(ctx context.Context, f fs.Fs, dst fs.Object, remote string, src fs.Obj
 						for _, option := range ci.UploadHeaders {
 							options = append(options, option)
 						}
+						if ci.MetadataSet != nil {
+							options = append(options, fs.MetadataOption(ci.MetadataSet))
+						}
 						if doUpdate {
 							actionTaken = "Copied (replaced existing)"
 							err = dst.Update(ctx, in, wrappedSrc, options...)
@@ -1371,6 +1385,9 @@ func Rcat(ctx context.Context, fdst fs.Fs, dstFileName string, in io.ReadCloser,
 	for _, option := range ci.UploadHeaders {
 		options = append(options, option)
 	}
+	if ci.MetadataSet != nil {
+		options = append(options, fs.MetadataOption(ci.MetadataSet))
+	}
 
 	compare := func(dst fs.Object) error {
 		var sums map[hash.Type]string
@@ -2138,6 +2155,21 @@ func (l *ListFormat) AddMimeType() {
 	})
 }
 
+// AddMetadata adds file's Metadata to the output if known
+func (l *ListFormat) AddMetadata() {
+	l.AppendOutput(func(entry *ListJSONItem) string {
+		metadata := entry.Metadata
+		if metadata == nil {
+			metadata = make(fs.Metadata)
+		}
+		out, err := json.Marshal(metadata)
+		if err != nil {
+			return fmt.Sprintf("Failed to read metadata: %v", err.Error())
+		}
+		return string(out)
+	})
+}
+
 // AppendOutput adds string generated by specific function to printed output
 func (l *ListFormat) AppendOutput(functionToAppend func(item *ListJSONItem) string) {
 	l.output = append(l.output, functionToAppend)
@@ -2268,21 +2300,30 @@ type FsInfo struct {
 
 	// Features returns the optional features of this Fs
 	Features map[string]bool
+
+	// MetadataInfo returns info about the metadata for this backend
+	MetadataInfo *fs.MetadataInfo
 }
 
 // GetFsInfo gets the information (FsInfo) about a given Fs
 func GetFsInfo(f fs.Fs) *FsInfo {
+	features := f.Features()
 	info := &FsInfo{
-		Name:      f.Name(),
-		Root:      f.Root(),
-		String:    f.String(),
-		Precision: f.Precision(),
-		Hashes:    make([]string, 0, 4),
-		Features:  f.Features().Enabled(),
+		Name:         f.Name(),
+		Root:         f.Root(),
+		String:       f.String(),
+		Precision:    f.Precision(),
+		Hashes:       make([]string, 0, 4),
+		Features:     features.Enabled(),
+		MetadataInfo: nil,
 	}
 	for _, hashType := range f.Hashes().Array() {
 		info.Hashes = append(info.Hashes, hashType.String())
 	}
+	fsInfo, _, _, _, err := fs.ParseRemote(fs.ConfigString(f))
+	if err == nil && fsInfo != nil && fsInfo.MetadataInfo != nil {
+		info.MetadataInfo = fsInfo.MetadataInfo
+	}
 	return info
 }
 
diff --git a/fs/operations/rc.go b/fs/operations/rc.go
index b8c59ce2d..39705b119 100644
--- a/fs/operations/rc.go
+++ b/fs/operations/rc.go
@@ -34,6 +34,7 @@ func init() {
     - noMimeType - If set don't show mime types
     - dirsOnly - If set only show directories
     - filesOnly - If set only show files
+    - metadata - If set return metadata of objects also
     - hashTypes - array of strings of hash types to show if showHash set
 
 Returns:
@@ -413,46 +414,103 @@ This returns info about the remote passed in;
 
 ` + "```" + `
 {
-	// optional features and whether they are available or not
-	"Features": {
-		"About": true,
-		"BucketBased": false,
-		"CanHaveEmptyDirectories": true,
-		"CaseInsensitive": false,
-		"ChangeNotify": false,
-		"CleanUp": false,
-		"Copy": false,
-		"DirCacheFlush": false,
-		"DirMove": true,
-		"DuplicateFiles": false,
-		"GetTier": false,
-		"ListR": false,
-		"MergeDirs": false,
-		"Move": true,
-		"OpenWriterAt": true,
-		"PublicLink": false,
-		"Purge": true,
-		"PutStream": true,
-		"PutUnchecked": false,
-		"ReadMimeType": false,
-		"ServerSideAcrossConfigs": false,
-		"SetTier": false,
-		"SetWrapper": false,
-		"UnWrap": false,
-		"WrapFs": false,
-		"WriteMimeType": false
-	},
-	// Names of hashes available
-	"Hashes": [
-		"MD5",
-		"SHA-1",
-		"DropboxHash",
-		"QuickXorHash"
-	],
-	"Name": "local",	// Name as created
-	"Precision": 1,		// Precision of timestamps in ns
-	"Root": "/",		// Path as created
-	"String": "Local file system at /" // how the remote will appear in logs
+        // optional features and whether they are available or not
+        "Features": {
+                "About": true,
+                "BucketBased": false,
+                "BucketBasedRootOK": false,
+                "CanHaveEmptyDirectories": true,
+                "CaseInsensitive": false,
+                "ChangeNotify": false,
+                "CleanUp": false,
+                "Command": true,
+                "Copy": false,
+                "DirCacheFlush": false,
+                "DirMove": true,
+                "Disconnect": false,
+                "DuplicateFiles": false,
+                "GetTier": false,
+                "IsLocal": true,
+                "ListR": false,
+                "MergeDirs": false,
+                "MetadataInfo": true,
+                "Move": true,
+                "OpenWriterAt": true,
+                "PublicLink": false,
+                "Purge": true,
+                "PutStream": true,
+                "PutUnchecked": false,
+                "ReadMetadata": true,
+                "ReadMimeType": false,
+                "ServerSideAcrossConfigs": false,
+                "SetTier": false,
+                "SetWrapper": false,
+                "Shutdown": false,
+                "SlowHash": true,
+                "SlowModTime": false,
+                "UnWrap": false,
+                "UserInfo": false,
+                "UserMetadata": true,
+                "WrapFs": false,
+                "WriteMetadata": true,
+                "WriteMimeType": false
+        },
+        // Names of hashes available
+        "Hashes": [
+                "md5",
+                "sha1",
+                "whirlpool",
+                "crc32",
+                "sha256",
+                "dropbox",
+                "mailru",
+                "quickxor"
+        ],
+        "Name": "local",        // Name as created
+        "Precision": 1,         // Precision of timestamps in ns
+        "Root": "/",            // Path as created
+        "String": "Local file system at /", // how the remote will appear in logs
+        // Information about the system metadata for this backend
+        "MetadataInfo": {
+                "System": {
+                        "atime": {
+                                "Help": "Time of last access",
+                                "Type": "RFC 3339",
+                                "Example": "2006-01-02T15:04:05.999999999Z07:00"
+                        },
+                        "btime": {
+                                "Help": "Time of file birth (creation)",
+                                "Type": "RFC 3339",
+                                "Example": "2006-01-02T15:04:05.999999999Z07:00"
+                        },
+                        "gid": {
+                                "Help": "Group ID of owner",
+                                "Type": "decimal number",
+                                "Example": "500"
+                        },
+                        "mode": {
+                                "Help": "File type and mode",
+                                "Type": "octal, unix style",
+                                "Example": "0100664"
+                        },
+                        "mtime": {
+                                "Help": "Time of last modification",
+                                "Type": "RFC 3339",
+                                "Example": "2006-01-02T15:04:05.999999999Z07:00"
+                        },
+                        "rdev": {
+                                "Help": "Device ID (if special file)",
+                                "Type": "hexadecimal",
+                                "Example": "1abc"
+                        },
+                        "uid": {
+                                "Help": "User ID of owner",
+                                "Type": "decimal number",
+                                "Example": "500"
+                        }
+                },
+                "Help": "Textual help string\n"
+        }
 }
 ` + "```" + `
 
diff --git a/fs/registry.go b/fs/registry.go
index 2f4afc7fe..d380c7e0c 100644
--- a/fs/registry.go
+++ b/fs/registry.go
@@ -40,6 +40,8 @@ type RegInfo struct {
 	Aliases []string
 	// Hide - if set don't show in the configurator
 	Hide bool
+	// MetadataInfo help about the metadata in use in this backend
+	MetadataInfo *MetadataInfo
 }
 
 // FileName returns the on disk file name for this backend
diff --git a/fs/types.go b/fs/types.go
index b340ce67b..88ff5e8cd 100644
--- a/fs/types.go
+++ b/fs/types.go
@@ -183,6 +183,14 @@ type GetTierer interface {
 	GetTier() string
 }
 
+// Metadataer is an optional interface for Object
+type Metadataer interface {
+	// Metadata returns metadata for an object
+	//
+	// It should return nil if there is no Metadata
+	Metadata(ctx context.Context) (Metadata, error)
+}
+
 // FullObjectInfo contains all the read-only optional interfaces
 //
 // Use for checking making wrapping ObjectInfos implement everything
@@ -192,6 +200,7 @@ type FullObjectInfo interface {
 	IDer
 	ObjectUnWrapper
 	GetTierer
+	Metadataer
 }
 
 // FullObject contains all the optional interfaces for Object
@@ -204,6 +213,7 @@ type FullObject interface {
 	ObjectUnWrapper
 	GetTierer
 	SetTierer
+	Metadataer
 }
 
 // ObjectOptionalInterfaces returns the names of supported and
@@ -232,6 +242,9 @@ func ObjectOptionalInterfaces(o Object) (supported, unsupported []string) {
 	_, ok = o.(GetTierer)
 	store(ok, "GetTier")
 
+	_, ok = o.(Metadataer)
+	store(ok, "Metadata")
+
 	return supported, unsupported
 }
 
diff --git a/go.mod b/go.mod
index 1fe102fd6..e61cdce97 100644
--- a/go.mod
+++ b/go.mod
@@ -76,6 +76,7 @@ require (
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
 	github.com/google/go-querystring v1.1.0 // indirect
 	github.com/jlaffaye/ftp v0.0.0-20220524001917-dfa1e758f3af
+	github.com/pkg/xattr v0.4.7 // indirect
 	golang.org/x/mobile v0.0.0-20220518205345-8578da9835fd
 	golang.org/x/term v0.0.0-20220526004731-065cf7ba2467
 )
diff --git a/go.sum b/go.sum
index e957043f8..9cc774dd3 100644
--- a/go.sum
+++ b/go.sum
@@ -478,6 +478,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/sftp v1.13.5-0.20211228200725-31aac3e1878d h1:7cHNeARnMq3icpbMdvyUELykWM4zOj5NRhH2Y3sfgBc=
 github.com/pkg/sftp v1.13.5-0.20211228200725-31aac3e1878d/go.mod h1:wHDZ0IZX6JcBYRK1TH9bcVq8G7TLpVHYIGJRFnmPfxg=
+github.com/pkg/xattr v0.4.7 h1:XoA3KzmFvyPlH4RwX5eMcgtzcaGBaSvgt3IoFQfbrmQ=
+github.com/pkg/xattr v0.4.7/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
@@ -882,6 +884,7 @@ golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220318055525-2edf467146b5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k=
diff --git a/lib/cache/cache.go b/lib/cache/cache.go
index f9533c137..b8d323ff1 100644
--- a/lib/cache/cache.go
+++ b/lib/cache/cache.go
@@ -16,6 +16,7 @@ type Cache struct {
 	expireRunning  bool
 	expireDuration time.Duration // expire the cache entry when it is older than this
 	expireInterval time.Duration // interval to run the cache expire
+	finalize       func(value interface{})
 }
 
 // New creates a new cache with the default expire duration and interval
@@ -25,6 +26,7 @@ func New() *Cache {
 		expireRunning:  false,
 		expireDuration: 300 * time.Second,
 		expireInterval: 60 * time.Second,
+		finalize:       func(_ interface{}) {},
 	}
 }
 
@@ -154,7 +156,10 @@ func (c *Cache) GetMaybe(key string) (value interface{}, found bool) {
 // Returns true if the entry was found
 func (c *Cache) Delete(key string) bool {
 	c.mu.Lock()
-	_, found := c.cache[key]
+	entry, found := c.cache[key]
+	if found {
+		c.finalize(entry.value)
+	}
 	delete(c.cache, key)
 	c.mu.Unlock()
 	return found
@@ -165,11 +170,13 @@ func (c *Cache) Delete(key string) bool {
 // Returns number of entries deleted
 func (c *Cache) DeletePrefix(prefix string) (deleted int) {
 	c.mu.Lock()
-	for k := range c.cache {
-		if strings.HasPrefix(k, prefix) {
-			delete(c.cache, k)
-			deleted++
+	for key, entry := range c.cache {
+		if !strings.HasPrefix(key, prefix) {
+			continue
 		}
+		c.finalize(entry.value)
+		delete(c.cache, key)
+		deleted++
 	}
 	c.mu.Unlock()
 	return deleted
@@ -183,12 +190,17 @@ func (c *Cache) Rename(oldKey, newKey string) (value interface{}, found bool) {
 	c.mu.Lock()
 	if newEntry, newFound := c.cache[newKey]; newFound {
 		// If new entry is found use that
+		if _, oldFound := c.cache[oldKey]; oldFound {
+			// If there's an old entry, we drop it and also try shutdown.
+			c.finalize(c.cache[oldKey].value)
+		}
 		delete(c.cache, oldKey)
 		value, found = newEntry.value, newFound
 		c.used(newEntry)
 	} else if oldEntry, oldFound := c.cache[oldKey]; oldFound {
 		// If old entry is found rename it to new and use that
 		c.cache[newKey] = oldEntry
+		// No need to shutdown here, as value lives on under newKey
 		delete(c.cache, oldKey)
 		c.used(oldEntry)
 		value, found = oldEntry.value, oldFound
@@ -204,6 +216,7 @@ func (c *Cache) cacheExpire() {
 	now := time.Now()
 	for key, entry := range c.cache {
 		if entry.pinCount <= 0 && now.Sub(entry.lastUsed) > c.expireDuration {
+			c.finalize(entry.value)
 			delete(c.cache, key)
 		}
 	}
@@ -218,10 +231,12 @@ func (c *Cache) cacheExpire() {
 // Clear removes everything from the cache
 func (c *Cache) Clear() {
 	c.mu.Lock()
-	for k := range c.cache {
-		delete(c.cache, k)
+	for key, entry := range c.cache {
+		c.finalize(entry.value)
+		delete(c.cache, key)
 	}
 	c.mu.Unlock()
+	return
 }
 
 // Entries returns the number of entries in the cache
@@ -231,3 +246,10 @@ func (c *Cache) Entries() int {
 	c.mu.Unlock()
 	return entries
 }
+
+// SetFinalizer sets a function to be called when a value drops out of the cache
+func (c *Cache) SetFinalizer(finalize func(interface{})) {
+	c.mu.Lock()
+	c.finalize = finalize
+	c.mu.Unlock()
+}
diff --git a/lib/http/auth/basic.go b/lib/http/auth/basic.go
index b2a2c658b..69907c009 100644
--- a/lib/http/auth/basic.go
+++ b/lib/http/auth/basic.go
@@ -113,6 +113,7 @@ func CustomAuth(fn CustomAuthFn, realm string) httplib.Middleware {
 				if value != nil {
 					r = r.WithContext(context.WithValue(r.Context(), ContextAuthKey, value))
 				}
+				next.ServeHTTP(w, r)
 			}
 		})
 	}


Test patch:
diff --git a/backend/cache/cache_test.go b/backend/cache/cache_test.go
index 44307ea6d..9ad4e31e5 100644
--- a/backend/cache/cache_test.go
+++ b/backend/cache/cache_test.go
@@ -19,7 +19,7 @@ func TestIntegration(t *testing.T) {
 		RemoteName:                   "TestCache:",
 		NilObject:                    (*cache.Object)(nil),
 		UnimplementableFsMethods:     []string{"PublicLink", "OpenWriterAt"},
-		UnimplementableObjectMethods: []string{"MimeType", "ID", "GetTier", "SetTier"},
+		UnimplementableObjectMethods: []string{"MimeType", "ID", "GetTier", "SetTier", "Metadata"},
 		SkipInvalidUTF8:              true, // invalid UTF-8 confuses the cache
 	})
 }
diff --git a/backend/chunker/chunker_internal_test.go b/backend/chunker/chunker_internal_test.go
index 9299305dc..c875c6762 100644
--- a/backend/chunker/chunker_internal_test.go
+++ b/backend/chunker/chunker_internal_test.go
@@ -59,7 +59,7 @@ var mtime1 = fstest.Time("2001-02-03T04:05:06.499999999Z")
 
 func testPutFile(ctx context.Context, t *testing.T, f fs.Fs, name, contents, message string, check bool) fs.Object {
 	item := fstest.Item{Path: name, ModTime: mtime1}
-	_, obj := fstests.PutTestContents(ctx, t, f, &item, contents, check)
+	obj := fstests.PutTestContents(ctx, t, f, &item, contents, check)
 	assert.NotNil(t, obj, message)
 	return obj
 }
@@ -440,7 +440,7 @@ func testSmallFileInternals(t *testing.T, f *Fs) {
 	checkSmallFile := func(name, contents string) {
 		filename := path.Join(dir, name)
 		item := fstest.Item{Path: filename, ModTime: modTime}
-		_, put := fstests.PutTestContents(ctx, t, f, &item, contents, false)
+		put := fstests.PutTestContents(ctx, t, f, &item, contents, false)
 		assert.NotNil(t, put)
 		checkSmallFileInternals(put)
 		checkContents(put, contents)
@@ -489,7 +489,7 @@ func testPreventCorruption(t *testing.T, f *Fs) {
 
 	newFile := func(name string) fs.Object {
 		item := fstest.Item{Path: path.Join(dir, name), ModTime: modTime}
-		_, obj := fstests.PutTestContents(ctx, t, f, &item, contents, true)
+		obj := fstests.PutTestContents(ctx, t, f, &item, contents, true)
 		require.NotNil(t, obj)
 		return obj
 	}
@@ -599,7 +599,7 @@ func testChunkNumberOverflow(t *testing.T, f *Fs) {
 	newFile := func(f fs.Fs, name string) (obj fs.Object, filename string, txnID string) {
 		filename = path.Join(dir, name)
 		item := fstest.Item{Path: filename, ModTime: modTime}
-		_, obj = fstests.PutTestContents(ctx, t, f, &item, contents, true)
+		obj = fstests.PutTestContents(ctx, t, f, &item, contents, true)
 		require.NotNil(t, obj)
 		if chunkObj, isChunkObj := obj.(*Object); isChunkObj {
 			txnID = chunkObj.xactID
@@ -716,7 +716,7 @@ func testFutureProof(t *testing.T, f *Fs) {
 			name = f.makeChunkName(name, part-1, "", "")
 		}
 		item := fstest.Item{Path: name, ModTime: modTime}
-		_, obj := fstests.PutTestContents(ctx, t, f.base, &item, data, true)
+		obj := fstests.PutTestContents(ctx, t, f.base, &item, data, true)
 		assert.NotNil(t, obj, msg)
 	}
 
@@ -790,7 +790,7 @@ func testBackwardsCompatibility(t *testing.T, f *Fs) {
 	newFile := func(f fs.Fs, name string) (fs.Object, string) {
 		filename := path.Join(dir, name)
 		item := fstest.Item{Path: filename, ModTime: modTime}
-		_, obj := fstests.PutTestContents(ctx, t, f, &item, contents, true)
+		obj := fstests.PutTestContents(ctx, t, f, &item, contents, true)
 		require.NotNil(t, obj)
 		return obj, filename
 	}
@@ -844,7 +844,7 @@ func testChunkerServerSideMove(t *testing.T, f *Fs) {
 	modTime := fstest.Time("2001-02-03T04:05:06.499999999Z")
 	item := fstest.Item{Path: "movefile", ModTime: modTime}
 	contents := "abcdef"
-	_, file := fstests.PutTestContents(ctx, t, fs1, &item, contents, true)
+	file := fstests.PutTestContents(ctx, t, fs1, &item, contents, true)
 
 	dstOverwritten, _ := fs2.NewObject(ctx, "movefile")
 	dstFile, err := operations.Move(ctx, fs2, dstOverwritten, "movefile", file)
diff --git a/backend/chunker/chunker_test.go b/backend/chunker/chunker_test.go
index 4acdf5b5a..7f0c0310d 100644
--- a/backend/chunker/chunker_test.go
+++ b/backend/chunker/chunker_test.go
@@ -35,6 +35,7 @@ func TestIntegration(t *testing.T) {
 			"MimeType",
 			"GetTier",
 			"SetTier",
+			"Metadata",
 		},
 		UnimplementableFsMethods: []string{
 			"PublicLink",
@@ -53,6 +54,7 @@ func TestIntegration(t *testing.T) {
 			{Name: name, Key: "type", Value: "chunker"},
 			{Name: name, Key: "remote", Value: tempDir},
 		}
+		opt.QuickTestOK = true
 	}
 	fstests.Run(t, &opt)
 }
diff --git a/backend/combine/combine_test.go b/backend/combine/combine_test.go
index c6274c562..46b49bcf0 100644
--- a/backend/combine/combine_test.go
+++ b/backend/combine/combine_test.go
@@ -35,6 +35,7 @@ func TestLocal(t *testing.T) {
 			{Name: name, Key: "type", Value: "combine"},
 			{Name: name, Key: "upstreams", Value: upstreams},
 		},
+		QuickTestOK: true,
 	})
 }
 
@@ -50,6 +51,7 @@ func TestMemory(t *testing.T) {
 			{Name: name, Key: "type", Value: "combine"},
 			{Name: name, Key: "upstreams", Value: upstreams},
 		},
+		QuickTestOK: true,
 	})
 }
 
diff --git a/backend/compress/compress_test.go b/backend/compress/compress_test.go
index 76baf22aa..356e9dd43 100644
--- a/backend/compress/compress_test.go
+++ b/backend/compress/compress_test.go
@@ -61,5 +61,6 @@ func TestRemoteGzip(t *testing.T) {
 			{Name: name, Key: "remote", Value: tempdir},
 			{Name: name, Key: "compression_mode", Value: "gzip"},
 		},
+		QuickTestOK: true,
 	})
 }
diff --git a/backend/crypt/crypt_internal_test.go b/backend/crypt/crypt_internal_test.go
index 2f7f1f8b3..825877630 100644
--- a/backend/crypt/crypt_internal_test.go
+++ b/backend/crypt/crypt_internal_test.go
@@ -91,7 +91,9 @@ func testObjectInfo(t *testing.T, f *Fs, wrap bool) {
 	src := f.newObjectInfo(oi, nonce)
 
 	// Test ObjectInfo methods
-	assert.Equal(t, int64(outBuf.Len()), src.Size())
+	if !f.opt.NoDataEncryption {
+		assert.Equal(t, int64(outBuf.Len()), src.Size())
+	}
 	assert.Equal(t, f, src.Fs())
 	assert.NotEqual(t, path, src.Remote())
 
diff --git a/backend/crypt/crypt_test.go b/backend/crypt/crypt_test.go
index 8369a60f7..d4b9dc1e4 100644
--- a/backend/crypt/crypt_test.go
+++ b/backend/crypt/crypt_test.go
@@ -4,6 +4,7 @@ package crypt_test
 import (
 	"os"
 	"path/filepath"
+	"runtime"
 	"testing"
 
 	"github.com/rclone/rclone/backend/crypt"
@@ -46,6 +47,7 @@ func TestStandardBase32(t *testing.T) {
 		},
 		UnimplementableFsMethods:     []string{"OpenWriterAt"},
 		UnimplementableObjectMethods: []string{"MimeType"},
+		QuickTestOK:                  true,
 	})
 }
 
@@ -67,6 +69,7 @@ func TestStandardBase64(t *testing.T) {
 		},
 		UnimplementableFsMethods:     []string{"OpenWriterAt"},
 		UnimplementableObjectMethods: []string{"MimeType"},
+		QuickTestOK:                  true,
 	})
 }
 
@@ -88,6 +91,7 @@ func TestStandardBase32768(t *testing.T) {
 		},
 		UnimplementableFsMethods:     []string{"OpenWriterAt"},
 		UnimplementableObjectMethods: []string{"MimeType"},
+		QuickTestOK:                  true,
 	})
 }
 
@@ -109,6 +113,7 @@ func TestOff(t *testing.T) {
 		},
 		UnimplementableFsMethods:     []string{"OpenWriterAt"},
 		UnimplementableObjectMethods: []string{"MimeType"},
+		QuickTestOK:                  true,
 	})
 }
 
@@ -117,6 +122,9 @@ func TestObfuscate(t *testing.T) {
 	if *fstest.RemoteName != "" {
 		t.Skip("Skipping as -remote set")
 	}
+	if runtime.GOOS == "darwin" {
+		t.Skip("Skipping on macOS as obfuscating control characters makes filenames macOS can't cope with")
+	}
 	tempdir := filepath.Join(os.TempDir(), "rclone-crypt-test-obfuscate")
 	name := "TestCrypt3"
 	fstests.Run(t, &fstests.Opt{
@@ -131,6 +139,7 @@ func TestObfuscate(t *testing.T) {
 		SkipBadWindowsCharacters:     true,
 		UnimplementableFsMethods:     []string{"OpenWriterAt"},
 		UnimplementableObjectMethods: []string{"MimeType"},
+		QuickTestOK:                  true,
 	})
 }
 
@@ -139,6 +148,9 @@ func TestNoDataObfuscate(t *testing.T) {
 	if *fstest.RemoteName != "" {
 		t.Skip("Skipping as -remote set")
 	}
+	if runtime.GOOS == "darwin" {
+		t.Skip("Skipping on macOS as obfuscating control characters makes filenames macOS can't cope with")
+	}
 	tempdir := filepath.Join(os.TempDir(), "rclone-crypt-test-obfuscate")
 	name := "TestCrypt4"
 	fstests.Run(t, &fstests.Opt{
@@ -154,5 +166,6 @@ func TestNoDataObfuscate(t *testing.T) {
 		SkipBadWindowsCharacters:     true,
 		UnimplementableFsMethods:     []string{"OpenWriterAt"},
 		UnimplementableObjectMethods: []string{"MimeType"},
+		QuickTestOK:                  true,
 	})
 }
diff --git a/backend/drive/drive_internal_test.go b/backend/drive/drive_internal_test.go
index 2a21fd574..8df52716c 100644
--- a/backend/drive/drive_internal_test.go
+++ b/backend/drive/drive_internal_test.go
@@ -378,9 +378,9 @@ func (f *Fs) InternalTestUnTrash(t *testing.T) {
 	// Make some objects, one in a subdir
 	contents := random.String(100)
 	file1 := fstest.NewItem("trashDir/toBeTrashed", contents, time.Now())
-	_, obj1 := fstests.PutTestContents(ctx, t, f, &file1, contents, false)
+	obj1 := fstests.PutTestContents(ctx, t, f, &file1, contents, false)
 	file2 := fstest.NewItem("trashDir/subdir/toBeTrashed", contents, time.Now())
-	_, _ = fstests.PutTestContents(ctx, t, f, &file2, contents, false)
+	_ = fstests.PutTestContents(ctx, t, f, &file2, contents, false)
 
 	// Check objects
 	checkObjects := func() {
@@ -496,7 +496,7 @@ func (f *Fs) InternalTestAgeQuery(t *testing.T) {
 	require.NoError(t, err)
 
 	file1 := fstest.Item{ModTime: time.Now(), Path: "agequery.txt"}
-	_, _ = fstests.PutTestContents(defCtx, t, tempFs1, &file1, "abcxyz", true)
+	_ = fstests.PutTestContents(defCtx, t, tempFs1, &file1, "abcxyz", true)
 
 	// validate sync/copy
 	const timeQuery = "(modifiedTime >= '"
diff --git a/backend/hasher/hasher_internal_test.go b/backend/hasher/hasher_internal_test.go
index 3ec7a11f9..0ca418e20 100644
--- a/backend/hasher/hasher_internal_test.go
+++ b/backend/hasher/hasher_internal_test.go
@@ -19,7 +19,7 @@ import (
 func putFile(ctx context.Context, t *testing.T, f fs.Fs, name, data string) fs.Object {
 	mtime1 := fstest.Time("2001-02-03T04:05:06.499999999Z")
 	item := fstest.Item{Path: name, ModTime: mtime1}
-	_, o := fstests.PutTestContents(ctx, t, f, &item, data, true)
+	o := fstests.PutTestContents(ctx, t, f, &item, data, true)
 	require.NotNil(t, o)
 	return o
 }
@@ -35,7 +35,7 @@ func (f *Fs) testUploadFromCrypt(t *testing.T) {
 	// make a temporary crypt remote
 	ctx := context.Background()
 	pass := obscure.MustObscure("crypt")
-	remote := fmt.Sprintf(":crypt,remote=%s,password=%s:", tempRoot, pass)
+	remote := fmt.Sprintf(`:crypt,remote="%s",password="%s":`, tempRoot, pass)
 	cryptFs, err := fs.NewFs(ctx, remote)
 	require.NoError(t, err)
 
diff --git a/backend/hasher/hasher_test.go b/backend/hasher/hasher_test.go
index 09d60f33d..23feb9b96 100644
--- a/backend/hasher/hasher_test.go
+++ b/backend/hasher/hasher_test.go
@@ -33,6 +33,7 @@ func TestIntegration(t *testing.T) {
 			{Name: "TestHasher", Key: "remote", Value: tempDir},
 		}
 		opt.RemoteName = "TestHasher:"
+		opt.QuickTestOK = true
 	}
 	fstests.Run(t, &opt)
 }
diff --git a/backend/local/local_internal_test.go b/backend/local/local_internal_test.go
index 2957dcd1c..55b98bb53 100644
--- a/backend/local/local_internal_test.go
+++ b/backend/local/local_internal_test.go
@@ -3,10 +3,12 @@ package local
 import (
 	"bytes"
 	"context"
+	"fmt"
 	"io/ioutil"
 	"os"
 	"path"
 	"path/filepath"
+	"runtime"
 	"testing"
 	"time"
 
@@ -229,3 +231,138 @@ func TestHashOnDelete(t *testing.T) {
 	_, err = o.Hash(ctx, hash.MD5)
 	require.Error(t, err)
 }
+
+func TestMetadata(t *testing.T) {
+	ctx := context.Background()
+	r := fstest.NewRun(t)
+	defer r.Finalise()
+	const filePath = "metafile.txt"
+	when := time.Now()
+	const dayLength = len("2001-01-01")
+	whenRFC := when.Format(time.RFC3339Nano)
+	r.WriteFile(filePath, "metadata file contents", when)
+	f := r.Flocal.(*Fs)
+
+	// Get the object
+	obj, err := f.NewObject(ctx, filePath)
+	require.NoError(t, err)
+	o := obj.(*Object)
+
+	features := f.Features()
+
+	var hasXID, hasAtime, hasBtime bool
+	switch runtime.GOOS {
+	case "darwin", "freebsd", "netbsd", "linux":
+		hasXID, hasAtime, hasBtime = true, true, true
+	case "openbsd", "solaris":
+		hasXID, hasAtime = true, true
+	case "windows":
+		hasAtime, hasBtime = true, true
+	case "plan9", "js":
+		// nada
+	default:
+		t.Errorf("No test cases for OS %q", runtime.GOOS)
+	}
+
+	assert.True(t, features.ReadMetadata)
+	assert.True(t, features.WriteMetadata)
+	assert.Equal(t, xattrSupported, features.UserMetadata)
+
+	t.Run("Xattr", func(t *testing.T) {
+		if !xattrSupported {
+			t.Skip()
+		}
+		m, err := o.getXattr()
+		require.NoError(t, err)
+		assert.Nil(t, m)
+
+		inM := fs.Metadata{
+			"potato":  "chips",
+			"cabbage": "soup",
+		}
+		err = o.setXattr(inM)
+		require.NoError(t, err)
+
+		m, err = o.getXattr()
+		require.NoError(t, err)
+		assert.NotNil(t, m)
+		assert.Equal(t, inM, m)
+	})
+
+	checkTime := func(m fs.Metadata, key string, when time.Time) {
+		mt, ok := o.parseMetadataTime(m, key)
+		assert.True(t, ok)
+		dt := mt.Sub(when)
+		precision := time.Second
+		assert.True(t, dt >= -precision && dt <= precision, fmt.Sprintf("%s: dt %v outside +/- precision %v", key, dt, precision))
+	}
+
+	checkInt := func(m fs.Metadata, key string, base int) int {
+		value, ok := o.parseMetadataInt(m, key, base)
+		assert.True(t, ok)
+		return value
+	}
+	t.Run("Read", func(t *testing.T) {
+		m, err := o.Metadata(ctx)
+		require.NoError(t, err)
+		assert.NotNil(t, m)
+
+		// All OSes have these
+		checkInt(m, "mode", 8)
+		checkTime(m, "mtime", when)
+
+		assert.Equal(t, len(whenRFC), len(m["mtime"]))
+		assert.Equal(t, whenRFC[:dayLength], m["mtime"][:dayLength])
+
+		if hasAtime {
+			checkTime(m, "atime", when)
+		}
+		if hasBtime {
+			checkTime(m, "btime", when)
+		}
+		if hasXID {
+			checkInt(m, "uid", 10)
+			checkInt(m, "gid", 10)
+		}
+	})
+
+	t.Run("Write", func(t *testing.T) {
+		newAtimeString := "2011-12-13T14:15:16.999999999Z"
+		newAtime := fstest.Time(newAtimeString)
+		newMtimeString := "2011-12-12T14:15:16.999999999Z"
+		newMtime := fstest.Time(newMtimeString)
+		newBtimeString := "2011-12-11T14:15:16.999999999Z"
+		newBtime := fstest.Time(newBtimeString)
+		newM := fs.Metadata{
+			"mtime": newMtimeString,
+			"atime": newAtimeString,
+			"btime": newBtimeString,
+			// Can't test uid, gid without being root
+			"mode":   "0767",
+			"potato": "wedges",
+		}
+		err := o.writeMetadata(newM)
+		require.NoError(t, err)
+
+		m, err := o.Metadata(ctx)
+		require.NoError(t, err)
+		assert.NotNil(t, m)
+
+		mode := checkInt(m, "mode", 8)
+		if runtime.GOOS != "windows" {
+			assert.Equal(t, 0767, mode&0777, fmt.Sprintf("mode wrong - expecting 0767 got 0%o", mode&0777))
+		}
+
+		checkTime(m, "mtime", newMtime)
+		if hasAtime {
+			checkTime(m, "atime", newAtime)
+		}
+		if haveSetBTime {
+			checkTime(m, "btime", newBtime)
+		}
+		if xattrSupported {
+			assert.Equal(t, "wedges", m["potato"])
+		}
+	})
+
+}
diff --git a/backend/local/local_test.go b/backend/local/local_test.go
index 8ecab2dbd..d0d54ec26 100644
--- a/backend/local/local_test.go
+++ b/backend/local/local_test.go
@@ -11,7 +11,8 @@ import (
 // TestIntegration runs integration tests against the remote
 func TestIntegration(t *testing.T) {
 	fstests.Run(t, &fstests.Opt{
-		RemoteName: "",
-		NilObject:  (*local.Object)(nil),
+		RemoteName:  "",
+		NilObject:   (*local.Object)(nil),
+		QuickTestOK: true,
 	})
 }
diff --git a/backend/memory/memory_test.go b/backend/memory/memory_test.go
index 3d768fec0..9989bc60d 100644
--- a/backend/memory/memory_test.go
+++ b/backend/memory/memory_test.go
@@ -10,7 +10,8 @@ import (
 // TestIntegration runs integration tests against the remote
 func TestIntegration(t *testing.T) {
 	fstests.Run(t, &fstests.Opt{
-		RemoteName: ":memory:",
-		NilObject:  (*Object)(nil),
+		RemoteName:  ":memory:",
+		NilObject:   (*Object)(nil),
+		QuickTestOK: true,
 	})
 }
diff --git a/backend/s3/s3_internal_test.go b/backend/s3/s3_internal_test.go
new file mode 100644
index 000000000..ecd39f41f
--- /dev/null
+++ b/backend/s3/s3_internal_test.go
@@ -0,0 +1,61 @@
+package s3
+
+import (
+	"context"
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/rclone/rclone/fs"
+	"github.com/rclone/rclone/fstest"
+	"github.com/rclone/rclone/fstest/fstests"
+	"github.com/rclone/rclone/lib/random"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func (f *Fs) InternalTestMetadata(t *testing.T) {
+	ctx := context.Background()
+	contents := random.String(100)
+	item := fstest.NewItem("test-metadata", contents, fstest.Time("2001-05-06T04:05:06.499999999Z"))
+	btime := time.Now()
+	metadata := fs.Metadata{
+		"cache-control":       "no-cache",
+		"content-disposition": "inline",
+		"content-encoding":    "gzip",
+		"content-language":    "en-US",
+		"content-type":        "text/plain",
+		"mtime":               "2009-05-06T04:05:06.499999999Z",
+		// "tier" - read only
+		// "btime" - read only
+	}
+	obj := fstests.PutTestContentsMetadata(ctx, t, f, &item, contents, true, "text/html", metadata)
+	defer func() {
+		assert.NoError(t, obj.Remove(ctx))
+	}()
+	o := obj.(*Object)
+	gotMetadata, err := o.Metadata(ctx)
+	require.NoError(t, err)
+	for k, v := range metadata {
+		got := gotMetadata[k]
+		switch k {
+		case "mtime":
+			assert.True(t, fstest.Time(v).Equal(fstest.Time(got)))
+		case "btime":
+			gotBtime := fstest.Time(got)
+			dt := gotBtime.Sub(btime)
+			assert.True(t, dt < time.Minute && dt > -time.Minute, fmt.Sprintf("btime more than 1 minute out want %v got %v delta %v", btime, gotBtime, dt))
+			assert.True(t, fstest.Time(v).Equal(fstest.Time(got)))
+		case "tier":
+			assert.NotEqual(t, "", got)
+		default:
+			assert.Equal(t, v, got, k)
+		}
+	}
+}
+
+func (f *Fs) InternalTest(t *testing.T) {
+	t.Run("Metadata", f.InternalTestMetadata)
+}
+
+var _ fstests.InternalTester = (*Fs)(nil)
diff --git a/backend/union/union_internal_test.go b/backend/union/union_internal_test.go
index 22443dd05..b6f78ec8d 100644
--- a/backend/union/union_internal_test.go
+++ b/backend/union/union_internal_test.go
@@ -38,7 +38,7 @@ func (f *Fs) TestInternalReadOnly(t *testing.T) {
 	// Put a file onto the read only fs
 	contents := random.String(50)
 	file1 := fstest.NewItem(dir+"/file.txt", contents, time.Now())
-	_, obj1 := fstests.PutTestContents(ctx, t, rofs, &file1, contents, true)
+	obj1 := fstests.PutTestContents(ctx, t, rofs, &file1, contents, true)
 
 	// Check read from readonly fs via union
 	o, err := f.NewObject(ctx, file1.Path)
@@ -109,14 +109,14 @@ func TestMoveCopy(t *testing.T) {
 	// 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)
+	_ = 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)
+	_ = fstests.PutTestContents(ctx, t, fMemory, &fileMemory, contentsMemory, true)
 	objMemory, err := f.NewObject(ctx, fileMemory.Path)
 	require.NoError(t, err)
 
diff --git a/backend/union/union_test.go b/backend/union/union_test.go
index 8b69c4c27..f5a9948f6 100644
--- a/backend/union/union_test.go
+++ b/backend/union/union_test.go
@@ -41,6 +41,7 @@ func TestStandard(t *testing.T) {
 		},
 		UnimplementableFsMethods:     []string{"OpenWriterAt", "DuplicateFiles"},
 		UnimplementableObjectMethods: []string{"MimeType"},
+		QuickTestOK:                  true,
 	})
 }
 
@@ -62,6 +63,7 @@ func TestRO(t *testing.T) {
 		},
 		UnimplementableFsMethods:     []string{"OpenWriterAt", "DuplicateFiles"},
 		UnimplementableObjectMethods: []string{"MimeType"},
+		QuickTestOK:                  true,
 	})
 }
 
@@ -83,6 +85,7 @@ func TestNC(t *testing.T) {
 		},
 		UnimplementableFsMethods:     []string{"OpenWriterAt", "DuplicateFiles"},
 		UnimplementableObjectMethods: []string{"MimeType"},
+		QuickTestOK:                  true,
 	})
 }
 
@@ -104,6 +107,7 @@ func TestPolicy1(t *testing.T) {
 		},
 		UnimplementableFsMethods:     []string{"OpenWriterAt", "DuplicateFiles"},
 		UnimplementableObjectMethods: []string{"MimeType"},
+		QuickTestOK:                  true,
 	})
 }
 
@@ -125,6 +129,7 @@ func TestPolicy2(t *testing.T) {
 		},
 		UnimplementableFsMethods:     []string{"OpenWriterAt", "DuplicateFiles"},
 		UnimplementableObjectMethods: []string{"MimeType"},
+		QuickTestOK:                  true,
 	})
 }
 
@@ -146,5 +151,6 @@ func TestPolicy3(t *testing.T) {
 		},
 		UnimplementableFsMethods:     []string{"OpenWriterAt", "DuplicateFiles"},
 		UnimplementableObjectMethods: []string{"MimeType"},
+		QuickTestOK:                  true,
 	})
 }
diff --git a/fs/metadata_test.go b/fs/metadata_test.go
new file mode 100644
index 000000000..ba982cbc1
--- /dev/null
+++ b/fs/metadata_test.go
@@ -0,0 +1,96 @@
+package fs
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestMetadataSet(t *testing.T) {
+	var m Metadata
+	assert.Nil(t, m)
+	m.Set("key", "value")
+	assert.NotNil(t, m)
+	assert.Equal(t, "value", m["key"])
+	m.Set("key", "value2")
+	assert.Equal(t, "value2", m["key"])
+}
+
+func TestMetadataMerge(t *testing.T) {
+	for _, test := range []struct {
+		in    Metadata
+		merge Metadata
+		want  Metadata
+	}{
+		{
+			in:    Metadata{},
+			merge: Metadata{},
+			want:  Metadata{},
+		}, {
+			in:    nil,
+			merge: nil,
+			want:  nil,
+		}, {
+			in:    nil,
+			merge: Metadata{},
+			want:  nil,
+		}, {
+			in:    nil,
+			merge: Metadata{"a": "1", "b": "2"},
+			want:  Metadata{"a": "1", "b": "2"},
+		}, {
+			in:    Metadata{"a": "1", "b": "2"},
+			merge: nil,
+			want:  Metadata{"a": "1", "b": "2"},
+		}, {
+			in:    Metadata{"a": "1", "b": "2"},
+			merge: Metadata{"b": "B", "c": "3"},
+			want:  Metadata{"a": "1", "b": "B", "c": "3"},
+		},
+	} {
+		what := fmt.Sprintf("in=%v, merge=%v", test.in, test.merge)
+		test.in.Merge(test.merge)
+		assert.Equal(t, test.want, test.in, what)
+	}
+}
+
+func TestMetadataMergeOptions(t *testing.T) {
+	for _, test := range []struct {
+		in   Metadata
+		opts []OpenOption
+		want Metadata
+	}{
+		{
+			opts: []OpenOption{},
+			want: nil,
+		}, {
+			opts: []OpenOption{&HTTPOption{}},
+			want: nil,
+		}, {
+			opts: []OpenOption{MetadataOption{"a": "1", "b": "2"}},
+			want: Metadata{"a": "1", "b": "2"},
+		}, {
+			opts: []OpenOption{
+				&HTTPOption{},
+				MetadataOption{"a": "1", "b": "2"},
+				MetadataOption{"b": "B", "c": "3"},
+				&HTTPOption{},
+			},
+			want: Metadata{"a": "1", "b": "B", "c": "3"},
+		}, {
+			in: Metadata{"a": "first", "z": "OK"},
+			opts: []OpenOption{
+				&HTTPOption{},
+				MetadataOption{"a": "1", "b": "2"},
+				MetadataOption{"b": "B", "c": "3"},
+				&HTTPOption{},
+			},
+			want: Metadata{"a": "1", "b": "B", "c": "3", "z": "OK"},
+		},
+	} {
+		what := fmt.Sprintf("in=%v, opts=%v", test.in, test.opts)
+		test.in.MergeOptions(test.opts)
+		assert.Equal(t, test.want, test.in, what)
+	}
+}
diff --git a/fs/open_options_test.go b/fs/open_options_test.go
index 829f45c19..37463adef 100644
--- a/fs/open_options_test.go
+++ b/fs/open_options_test.go
@@ -132,6 +132,16 @@ func TestNullOption(t *testing.T) {
 	assert.Equal(t, false, opt.Mandatory())
 }
 
+func TestMetadataOption(t *testing.T) {
+	opt := MetadataOption{"onion": "ice cream"}
+	var _ OpenOption = opt // check interface
+	assert.Equal(t, "MetadataOption(map[onion:ice cream])", opt.String())
+	key, value := opt.Header()
+	assert.Equal(t, "", key)
+	assert.Equal(t, "", value)
+	assert.Equal(t, false, opt.Mandatory())
+}
+
 func TestFixRangeOptions(t *testing.T) {
 	for _, test := range []struct {
 		name string
diff --git a/fs/operations/lsjson_test.go b/fs/operations/lsjson_test.go
index ebc2c3f47..d5d7ca319 100644
--- a/fs/operations/lsjson_test.go
+++ b/fs/operations/lsjson_test.go
@@ -172,6 +172,19 @@ func TestListJSON(t *testing.T) {
 				ModTime: operations.Timestamp{When: t1},
 				IsDir:   false,
 			}},
+		}, {
+			name: "Metadata",
+			opt: operations.ListJSONOpt{
+				FilesOnly: true,
+				Metadata:  true,
+			},
+			want: []*operations.ListJSONItem{{
+				Path:    "file1",
+				Name:    "file1",
+				Size:    5,
+				ModTime: operations.Timestamp{When: t1},
+				IsDir:   false,
+			}},
 		},
 	} {
 		t.Run(test.name, func(t *testing.T) {
diff --git a/fs/operations/operations_test.go b/fs/operations/operations_test.go
index 02a44d385..8cd7296e4 100644
--- a/fs/operations/operations_test.go
+++ b/fs/operations/operations_test.go
@@ -297,7 +297,7 @@ func TestHashSumsWithErrors(t *testing.T) {
 	// Make a test file
 	content := "-"
 	item1 := fstest.NewItem("file1", content, t1)
-	_, _ = fstests.PutTestContents(ctx, t, memFs, &item1, content, true)
+	_ = fstests.PutTestContents(ctx, t, memFs, &item1, content, true)
 
 	// MemoryFS supports MD5
 	buf := &bytes.Buffer{}
@@ -1428,6 +1428,11 @@ func TestListFormat(t *testing.T) {
 	assert.Contains(t, list.Format(item0), "/")
 	assert.Equal(t, "inode/directory", list.Format(item1))
 
+	list.SetOutput(nil)
+	list.AddMetadata()
+	assert.Equal(t, "{}", list.Format(item0))
+	assert.Equal(t, "{}", list.Format(item1))
+
 	list.SetOutput(nil)
 	list.AddPath()
 	list.SetAbsolute(true)
diff --git a/fstest/fstests/fstests.go b/fstest/fstests/fstests.go
index 58481416a..7a72ef5f5 100644
--- a/fstest/fstests/fstests.go
+++ b/fstest/fstests/fstests.go
@@ -157,13 +157,15 @@ func retry(t *testing.T, what string, f func() error) {
 type objectInfoWithMimeType struct {
 	fs.ObjectInfo
 	mimeType string
+	metadata fs.Metadata
 }
 
 // Return a wrapped fs.ObjectInfo which returns the mime type given
-func overrideMimeType(o fs.ObjectInfo, mimeType string) fs.ObjectInfo {
+func overrideMimeType(o fs.ObjectInfo, mimeType string, metadata fs.Metadata) fs.ObjectInfo {
 	return &objectInfoWithMimeType{
 		ObjectInfo: o,
 		mimeType:   mimeType,
+		metadata:   metadata,
 	}
 }
 
@@ -172,13 +174,25 @@ func (o *objectInfoWithMimeType) MimeType(ctx context.Context) string {
 	return o.mimeType
 }
 
+// Metadata that was overridden
+func (o *objectInfoWithMimeType) Metadata(ctx context.Context) (fs.Metadata, error) {
+	return o.metadata, nil
+}
+
+// check interfaces
+var (
+	_ fs.MimeTyper  = (*objectInfoWithMimeType)(nil)
+	_ fs.Metadataer = (*objectInfoWithMimeType)(nil)
+)
+
 // check interface
-var _ fs.MimeTyper = (*objectInfoWithMimeType)(nil)
 
-// putTestContentsMimeType puts file with given contents to the remote and checks it but unlike TestPutLarge doesn't remove
+// PutTestContentsMetadata puts file with given contents to the remote and checks it but unlike TestPutLarge doesn't remove
 //
-// it uploads the object with the mimeType passed in if set
-func putTestContentsMimeType(ctx context.Context, t *testing.T, f fs.Fs, file *fstest.Item, contents string, check bool, mimeType string) (string, fs.Object) {
+// It uploads the object with the mimeType and metadata passed in if set
+//
+// It returns the object which will have been checked if check is set
+func PutTestContentsMetadata(ctx context.Context, t *testing.T, f fs.Fs, file *fstest.Item, contents string, check bool, mimeType string, metadata fs.Metadata) fs.Object {
 	var (
 		err        error
 		obj        fs.Object
@@ -191,35 +205,53 @@ func putTestContentsMimeType(ctx context.Context, t *testing.T, f fs.Fs, file *f
 
 		file.Size = int64(buf.Len())
 		obji := object.NewStaticObjectInfo(file.Path, file.ModTime, file.Size, true, nil, nil)
-		if mimeType != "" {
-			obji = overrideMimeType(obji, mimeType)
+		if mimeType != "" || metadata != nil {
+			// force the --metadata flag on temporarily
+			if metadata != nil {
+				ci := fs.GetConfig(ctx)
+				previousMetadata := ci.Metadata
+				ci.Metadata = true
+				defer func() {
+					ci.Metadata = previousMetadata
+				}()
+			}
+			obji = overrideMimeType(obji, mimeType, metadata)
 		}
 		obj, err = f.Put(ctx, in, obji)
 		return err
 	})
 	file.Hashes = uploadHash.Sums()
 	if check {
+		// Overwrite time with that in metadata if it is already specified
+		mtime, ok := metadata["mtime"]
+		if ok {
+			modTime, err := time.Parse(time.RFC3339Nano, mtime)
+			require.NoError(t, err)
+			file.ModTime = modTime
+		}
 		file.Check(t, obj, f.Precision())
 		// Re-read the object and check again
 		obj = findObject(ctx, t, f, file.Path)
 		file.Check(t, obj, f.Precision())
 	}
-	return contents, obj
+	return obj
 }
 
 // PutTestContents puts file with given contents to the remote and checks it but unlike TestPutLarge doesn't remove
-func PutTestContents(ctx context.Context, t *testing.T, f fs.Fs, file *fstest.Item, contents string, check bool) (string, fs.Object) {
-	return putTestContentsMimeType(ctx, t, f, file, contents, check, "")
+func PutTestContents(ctx context.Context, t *testing.T, f fs.Fs, file *fstest.Item, contents string, check bool) fs.Object {
+	return PutTestContentsMetadata(ctx, t, f, file, contents, check, "", nil)
 }
 
 // testPut puts file with random contents to the remote
 func testPut(ctx context.Context, t *testing.T, f fs.Fs, file *fstest.Item) (string, fs.Object) {
-	return PutTestContents(ctx, t, f, file, random.String(100), true)
+	contents := random.String(100)
+	return contents, PutTestContents(ctx, t, f, file, contents, true)
 }
 
 // testPutMimeType puts file with random contents to the remote and the mime type given
-func testPutMimeType(ctx context.Context, t *testing.T, f fs.Fs, file *fstest.Item, mimeType string) (string, fs.Object) {
-	return putTestContentsMimeType(ctx, t, f, file, random.String(100), true, mimeType)
+func testPutMimeType(ctx context.Context, t *testing.T, f fs.Fs, file *fstest.Item, mimeType string, metadata fs.Metadata) (string, fs.Object) {
+	contents := random.String(100)
+	return contents, PutTestContentsMetadata(ctx, t, f, file, contents, true, mimeType, metadata)
 }
 
 // TestPutLarge puts file to the remote, checks it and removes it on success.
@@ -295,6 +327,7 @@ type Opt struct {
 	SkipFsCheckWrap              bool     // if set skip FsCheckWrap
 	SkipObjectCheckWrap          bool     // if set skip ObjectCheckWrap
 	SkipInvalidUTF8              bool     // if set skip invalid UTF-8 checks
+	QuickTestOK                  bool     // if set, run this test with make quicktest
 }
 
 // returns true if x is found in ss
@@ -348,6 +381,7 @@ func Run(t *testing.T, opt *Opt) {
 		}
 		file1Contents string
 		file1MimeType = "text/csv"
+		file1Metadata = fs.Metadata{"rclone-test": "potato"}
 		file2         = fstest.Item{
 			ModTime: fstest.Time("2001-02-03T04:05:10.123123123Z"),
 			Path:    `hello? sausage/êé/Hello, 世界/ " ' @ < > & ? + ≠/z.txt`,
@@ -359,7 +393,7 @@ func Run(t *testing.T, opt *Opt) {
 		unwrappableFsMethods = []string{"Command"} // these Fs methods don't need to be wrapped ever
 	)
 
-	if strings.HasSuffix(os.Getenv("RCLONE_CONFIG"), "/notfound") && *fstest.RemoteName == "" {
+	if strings.HasSuffix(os.Getenv("RCLONE_CONFIG"), "/notfound") && *fstest.RemoteName == "" && !opt.QuickTestOK {
 		t.Skip("quicktest only")
 	}
 
@@ -866,7 +900,7 @@ func Run(t *testing.T, opt *Opt) {
 			skipIfNotOk(t)
 			file1Contents, _ = testPut(ctx, t, f, &file1)
 			/* file2Contents = */ testPut(ctx, t, f, &file2)
-			file1Contents, _ = testPutMimeType(ctx, t, f, &file1, file1MimeType)
+			file1Contents, _ = testPutMimeType(ctx, t, f, &file1, file1MimeType, file1Metadata)
 			// Note that the next test will check there are no duplicated file names
 
 			// TestFsListDirFile2 tests the files are correctly uploaded by doing
@@ -1355,6 +1389,81 @@ func Run(t *testing.T, opt *Opt) {
 				}
 			})
 
+			// TestObjectMetadata tests the Metadata of the object is correct
+			t.Run("ObjectMetadata", func(t *testing.T) {
+				skipIfNotOk(t)
+				features := f.Features()
+				obj := findObject(ctx, t, f, file1.Path)
+				do, objectHasMetadata := obj.(fs.Metadataer)
+				if objectHasMetadata || features.ReadMetadata || features.WriteMetadata || features.UserMetadata {
+					fsInfo, _, _, _, err := fs.ParseRemote(fs.ConfigString(f))
+					require.NoError(t, err)
+					require.NotNil(t, fsInfo.MetadataInfo, "Object declares metadata support but no MetadataInfo in RegInfo")
+				}
+				if !objectHasMetadata {
+					require.False(t, features.ReadMetadata, "Features.ReadMetadata is set but Object.Metadata method not found")
+					t.Skip("Metadata method not supported")
+				}
+				metadata, err := do.Metadata(ctx)
+				require.NoError(t, err)
+				// check standard metadata
+				for k, v := range metadata {
+					switch k {
+					case "atime", "btime", "mtime":
+						mtime, err := time.Parse(time.RFC3339Nano, v)
+						require.NoError(t, err)
+						if k == "mtime" {
+							fstest.AssertTimeEqualWithPrecision(t, file1.Path, file1.ModTime, mtime, f.Precision())
+						}
+					}
+				}
+				if !features.ReadMetadata {
+					if metadata != nil {
+						require.Equal(t, "", metadata, "Features.ReadMetadata is not set but Object.Metadata returned a non nil Metadata")
+					}
+				} else if features.WriteMetadata {
+					require.NotNil(t, metadata)
+					if features.UserMetadata {
+						// check all the metadata bits we uploaded are present - there may be more we didn't write
+						for k, v := range file1Metadata {
+							assert.Equal(t, v, metadata[k], "can read and write metadata but failed on key %q", k)
+						}
+					}
+					// Now test we can set the mtime and content-type via the metadata and these take precedence
+					t.Run("mtime", func(t *testing.T) {
+						path := "metadatatest"
+						mtimeModTime := fstest.Time("2002-02-03T04:05:06.499999999Z")
+						modTime := fstest.Time("2003-02-03T04:05:06.499999999Z")
+						item := fstest.NewItem(path, path, modTime)
+						metaMimeType := "application/zip"
+						mimeType := "application/gzip"
+						metadata := fs.Metadata{
+							"mtime":        mtimeModTime.Format(time.RFC3339Nano),
+							"content-type": metaMimeType,
+						}
+						// This checks the mtime is correct also and returns the re-read object
+						_, obj := testPutMimeType(ctx, t, f, &item, mimeType, metadata)
+						defer func() {
+							assert.NoError(t, obj.Remove(ctx))
+						}()
+						// Check content-type got updated too
+						if features.ReadMimeType && features.WriteMimeType {
+							// read the object from scratch
+							o, err := f.NewObject(ctx, path)
+							require.NoError(t, err)
+
+							// Check the mimetype is correct
+							do, ok := o.(fs.MimeTyper)
+							require.True(t, ok)
+							gotMimeType := do.MimeType(ctx)
+							assert.Equal(t, metaMimeType, gotMimeType)
+						}
+					})
+				} else {
+					// Have some metadata here we didn't write - can't really check it!
+				}
+			})
+
 			// TestObjectSetModTime tests that SetModTime works
 			t.Run("ObjectSetModTime", func(t *testing.T) {
 				skipIfNotOk(t)
diff --git a/lib/cache/cache_test.go b/lib/cache/cache_test.go
index 089db07ef..415e167c3 100644
--- a/lib/cache/cache_test.go
+++ b/lib/cache/cache_test.go
@@ -333,3 +333,37 @@ func TestCacheRename(t *testing.T) {
 
 	assert.Equal(t, 1, c.Entries())
 }
+
+func TestCacheFinalize(t *testing.T) {
+	c := New()
+	numCalled := 0
+	c.SetFinalizer(func(v interface{}) {
+		numCalled++
+	})
+	create := func(path string) (interface{}, bool, error) {
+		return path, true, nil
+	}
+	_, _ = c.Get("ok", create)
+	assert.Equal(t, 0, numCalled)
+	c.Clear()
+	assert.Equal(t, 1, numCalled)
+
+	_, _ = c.Get("ok", create)
+	c.Delete("ok")
+	assert.Equal(t, 2, numCalled)
+
+	_, _ = c.Get("ok", create)
+	c.DeletePrefix("ok")
+	assert.Equal(t, 3, numCalled)
+
+	_, _ = c.Get("old", create)
+	_, _ = c.Get("new", create)
+	c.Rename("old", "new")
+	assert.Equal(t, 4, numCalled)
+
+	c.expireDuration = 1 * time.Millisecond
+	_, _ = c.Get("ok", create)
+	time.Sleep(2 * time.Millisecond)
+	c.cacheExpire() // "ok" and "new" fall out of cache
+	assert.Equal(t, 6, numCalled)
+}