Repository: rclone/rclone
Pull request #6359 (2022-08-04 20:50:49)

Jottacloud: Implement reading and writing of metadata (file created timestamp)
#### What is the purpose of this change?

Introduce support for the new Metadata feature in rclone.

There is not much support in the Jottacloud API, but I have added the few that I found that could be useful... 

The most useful addition is the `created` timestamp, as especially on Windows this is a property often presented to user, and by having this in the metadata feature users can copy files from local to Jottacloud and back, and preserve timestamps - not only the `modified` timestamp rclone already preserves, but also the `created` timestamp.

Public links (share urls) have also been added. The existing rclone link command enables share and returns the share link, but there is no easy way to list shared files or check if a file is currently shared or not. By including this as read-only meta data it is now possible to extract this information from file lists.

The full set of supported metadata items:
- `name` (read-only) shows the full path to file on backend, within the user account, i.e. unambiguous path regardless of Jottacloud user/account name and regardless of rclone remote/path setting.
- `btime` is a standard LastCreated timestamp.
- `mtime` is a standard LastModified timestamp, when read it returns the same as existing ModTime.
- `utime` (read-only) is a Jottacloud-specific timestamp which is called "updated" in the api response. It seems to be set at current time whenever the api creates a new revision of a file, i.e. whenever there is an upload or another operation changing the state or content of a file. So similar to mtime but entirely backend-controlled.
- `content-type` (read-only) is MIME type information.
- `public-link` is the full direct download url for a shared file, as returned by rclone link command.
- `public-link-web` is the alternative web download url for a shared file, as previously returned by rclone link command but now only shown in debug log.

Notes:
- Is it necessary with both `public-link` and `public-link-web`? And should it be the complete url, or only the identifier returned by the API?
- ListR implementation does not have the `updated` timestamp available, but does have `created` as well as `modified`. The `updated` is the least important one, and is read-only, so don't think it should matter... Also, ListR does not include mime information already, and this is documented in the backend docs, so I'm just extending that description to also indicate that not all metadata items are present when using `--fast-list`.
- The path manipulation and utility functions are getting a bit messy. I'm working on a refactoring commit on these, but moved it out to a separate PR following this one.

#### 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).
- [X] I have added tests for all changes in this PR if appropriate.
- [X] I have added documentation for the changes if appropriate.
- [X] All commit messages are in [house style](https://github.com/rclone/rclone/blob/master/CONTRIBUTING.md#commit-messages).
- [x] I'm done, this Pull Request is ready for review :-)


albertony: One question, probably to @ncw: Looking at existing implementations, i.e. local, s3 and internetarchive, I'm unsure if I should include mtime as system metadata for the backend or not, duplicating the existing ModTime function in rclone... Seems they do that, so I've done the same in Jottacloud. But what is the benefit?
ncw: > One question, probably to @ncw: Looking at existing implementations, i.e. local, s3 and internetarchive, I'm unsure if I should include mtime as system metadata for the backend or not, duplicating the existing ModTime function in rclone... Seems they do that, so I've done the same in Jottacloud. But what is the benefit?

Yes you should include it as system metadata.

I know it is duplicated info, but it helps make the metadata more consistent between backends.

`created` is very useful for windows users - they often ask for created time to be synced as you can see it easily in the Windows UI I think. In the unix world, created time is a recent invention and not supported everywhere!
albertony: Thanks, that is noted!
Would the same apply for mime? I.e. should I set it as metadata item "content-type" (even though in Jottacloud's case its read from a property "mime" in response xml and not content-type response header, and also Jottacloud only supports reading it, not writing it, afaik).
ncw: > Would the same apply for mime, ie. should I set it as metadata item "content-type"? In Jottacloud's case its read from a property "mime" in response xml and not actual content-type response header, but I don't think that matters. But Jottacloud only supports reading mtime, not writing it (afaik), that reduces the relevancy significantly. But still, I wonder if in general mime should also be included as a metadata key whenever the backend has some sort of mime support?

Yes it should be included if possible. I think the integration tests test for that too, but maybe only if you can set content types.
albertony: I'm wondering a bit about performance implications, also in light of the recent issue https://github.com/rclone/rclone/pull/6393 - though I don't think the same problem handled there is present in Jottacloud backend.

The Update function was changed to now always perform an extra get request at the end. The upload response itself contains main metadata properties (size, md5 and modTime) which was previously set back to the object, but it does not contain all the metadata information - specifically the createTime and updateTime properties. Therefore I now changed to an extra read. But can we avoid this? E.g. can we detect if we are in a "metadata-aware operation" where this is needed, and if not just update the object as before without reading the additional metadata?
albertony: I think the result object from the Update backend function is returned here:

https://github.com/rclone/rclone/blob/0361acbde40815534ab11b4027f8083151ff8640/fs/operations/operations.go#L507

And then here, where it is just ignored:

https://github.com/rclone/rclone/blob/0361acbde40815534ab11b4027f8083151ff8640/fs/operations/operations.go#L1934

But does this mean it is safe to return an incomplete/outdated object from backend? Or is the returned object used somewhere else? Or should we by definition always return a valid updated and complete object?
ncw: > I'm wondering a bit about performance implications, also in light of the recent issue #6393 - though I don't think the same problem handled there is present in Jottacloud backend.

Its OK for metadata to use a bit more memory. It won't be much. The problem with that issue was that it was pinning the massive XML response from S3 in memory by using a small part of it.

> The Update function was changed to now always perform an extra get request at the end. The upload response itself contains main metadata properties (size, md5 and modTime) which was previously set back to the object, but it does not contain all the metadata information - specifically the createTime and updateTime properties. Therefore I now changed to an extra read. But can we avoid this? E.g. can we detect if we are in a "metadata-aware operation" where this is needed, and if not just update the object as before without reading the additional metadata?

I think that is fine. Update is only called when uploading objects and if that takes a fraction of a second longer that is not a problem.


> I think the result object from the Update backend function is returned here:
> 
> https://github.com/rclone/rclone/blob/0361acbde40815534ab11b4027f8083151ff8640/fs/operations/operations.go#L507
> 
> And then here, where it is just ignored:
> 
> https://github.com/rclone/rclone/blob/0361acbde40815534ab11b4027f8083151ff8640/fs/operations/operations.go#L1934
> 
> But does this mean it is safe to return an incomplete/outdated object from backend? Or is the returned object used somewhere else? Or should we by definition always return a valid updated and complete object?

Update is expected to update the metadata of the object it is working on. Rclone will ask for hashes with the updated object so it is important it is up to date after an upload.

There are other parts of rclone (like the VFS layer) which will stash the object for later use.

Where are we at with this PR? Shall we try to merge it for 1.63?


ncw: Give me a nudge when you want me to look at this :-) I'm using github notifications exclusively to manage stuff now-a-days so I need a post for it to reappear on my notifications!
albertony: @ncw I've revived this PR now, and decided to remove the `name`, `public-link` and `public-link-web` entries to reduce number of decision points (which was partly why the PR stalled back in the day), but also I figured it's best to just include the minimum set of metadata entries which we know can actually be useful - and its easy to add the other ones later if there should be a need. I've left the changes as additional commits for now, but will squash them at the end, if that's ok with you (i.e. it isn't too noisy when doing the review or something). So; when it suits you - please review. 😅 
albertony: > squash the jotta commits and leave the docs one for two commits?

I have done this now. Will merge as soon as the checks are all green.

Patch:
diff --git a/backend/jottacloud/api/types.go b/backend/jottacloud/api/types.go
index 8daf0f7ad..6d192bd4e 100644
--- a/backend/jottacloud/api/types.go
+++ b/backend/jottacloud/api/types.go
@@ -395,7 +395,7 @@ type JottaFile struct {
 	State           string    `xml:"currentRevision>state"`
 	CreatedAt       JottaTime `xml:"currentRevision>created"`
 	ModifiedAt      JottaTime `xml:"currentRevision>modified"`
-	Updated         JottaTime `xml:"currentRevision>updated"`
+	UpdatedAt       JottaTime `xml:"currentRevision>updated"`
 	Size            int64     `xml:"currentRevision>size"`
 	MimeType        string    `xml:"currentRevision>mime"`
 	MD5             string    `xml:"currentRevision>md5"`
diff --git a/backend/jottacloud/jottacloud.go b/backend/jottacloud/jottacloud.go
index e3f43c2b9..7c48c116d 100644
--- a/backend/jottacloud/jottacloud.go
+++ b/backend/jottacloud/jottacloud.go
@@ -92,6 +92,33 @@ func init() {
 		Description: "Jottacloud",
 		NewFs:       NewFs,
 		Config:      Config,
+		MetadataInfo: &fs.MetadataInfo{
+			Help: `Jottacloud has limited support for metadata, currently an extended set of timestamps.`,
+			System: map[string]fs.MetadataHelp{
+				"btime": {
+					Help:    "Time of file birth (creation), read from rclone metadata",
+					Type:    "RFC 3339",
+					Example: "2006-01-02T15:04:05.999999999Z07:00",
+				},
+				"mtime": {
+					Help:    "Time of last modification, read from rclone metadata",
+					Type:    "RFC 3339",
+					Example: "2006-01-02T15:04:05.999999999Z07:00",
+				},
+				"utime": {
+					Help:     "Time of last upload, when current revision was created, generated by backend",
+					Type:     "RFC 3339",
+					Example:  "2006-01-02T15:04:05.999999999Z07:00",
+					ReadOnly: true,
+				},
+				"content-type": {
+					Help:     "MIME type, also known as media type",
+					Type:     "string",
+					Example:  "text/plain",
+					ReadOnly: true,
+				},
+			},
+		},
 		Options: append(oauthutil.SharedOptions, []fs.Option{{
 			Name:     "md5_memory_limit",
 			Help:     "Files bigger than this will be cached on disk to calculate the MD5 if required.",
@@ -497,7 +524,9 @@ type Object struct {
 	remote      string
 	hasMetaData bool
 	size        int64
+	createTime  time.Time
 	modTime     time.Time
+	updateTime  time.Time
 	md5         string
 	mimeType    string
 }
@@ -972,6 +1001,9 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
 		CanHaveEmptyDirectories: true,
 		ReadMimeType:            true,
 		WriteMimeType:           false,
+		ReadMetadata:            true,
+		WriteMetadata:           true,
+		UserMetadata:            false,
 	}).Fill(ctx, f)
 	f.jfsSrv.SetErrorHandler(errorHandler)
 	if opt.TrashedOnly { // we cannot support showing Trashed Files when using ListR right now
@@ -1158,6 +1190,7 @@ func parseListRStream(ctx context.Context, r io.Reader, filesystem *Fs, callback
 			remote:      filesystem.opt.Enc.ToStandardPath(path.Join(f.Path, f.Name)),
 			size:        f.Size,
 			md5:         f.Checksum,
+			createTime:  time.Time(f.Created),
 			modTime:     time.Time(f.Modified),
 		})
 	}
@@ -1387,7 +1420,7 @@ func (f *Fs) Purge(ctx context.Context, dir string) error {
 // is currently in trash, but can be made to match, it will be
 // restored. Returns ErrorObjectNotFound if upload will be necessary
 // to get a matching remote file.
-func (f *Fs) createOrUpdate(ctx context.Context, file string, modTime time.Time, size int64, md5 string) (info *api.JottaFile, err error) {
+func (f *Fs) createOrUpdate(ctx context.Context, file string, createTime time.Time, modTime time.Time, size int64, md5 string) (info *api.JottaFile, err error) {
 	opts := rest.Opts{
 		Method:       "POST",
 		Path:         f.filePath(file),
@@ -1397,11 +1430,10 @@ func (f *Fs) createOrUpdate(ctx context.Context, file string, modTime time.Time,
 
 	opts.Parameters.Set("cphash", "true")
 
-	fileDate := api.JottaTime(modTime).String()
 	opts.ExtraHeaders["JSize"] = strconv.FormatInt(size, 10)
 	opts.ExtraHeaders["JMd5"] = md5
-	opts.ExtraHeaders["JCreated"] = fileDate
-	opts.ExtraHeaders["JModified"] = fileDate
+	opts.ExtraHeaders["JCreated"] = api.JottaTime(createTime).String()
+	opts.ExtraHeaders["JModified"] = api.JottaTime(modTime).String()
 
 	var resp *http.Response
 	err = f.pacer.Call(func() (bool, error) {
@@ -1464,7 +1496,7 @@ func (f *Fs) Copy(ctx context.Context, src fs.Object, remote string) (fs.Object,
 	// if destination was a trashed file then after a successful copy the copied file is still in trash (bug in api?)
 	if err == nil && bool(info.Deleted) && !f.opt.TrashedOnly && info.State == "COMPLETED" {
 		fs.Debugf(src, "Server-side copied to trashed destination, restoring")
-		info, err = f.createOrUpdate(ctx, remote, srcObj.modTime, srcObj.size, srcObj.md5)
+		info, err = f.createOrUpdate(ctx, remote, srcObj.createTime, srcObj.modTime, srcObj.size, srcObj.md5)
 	}
 
 	if err != nil {
@@ -1727,7 +1759,9 @@ func (o *Object) setMetaData(info *api.JottaFile) (err error) {
 	o.size = info.Size
 	o.md5 = info.MD5
 	o.mimeType = info.MimeType
+	o.createTime = time.Time(info.CreatedAt)
 	o.modTime = time.Time(info.ModifiedAt)
+	o.updateTime = time.Time(info.UpdatedAt)
 	return nil
 }
 
@@ -1772,7 +1806,7 @@ func (o *Object) SetModTime(ctx context.Context, modTime time.Time) error {
 	// (note that if size/md5 does not match, the file content will
 	// also be modified if deduplication is possible, i.e. it is
 	// important to use correct/latest values)
-	_, err = o.fs.createOrUpdate(ctx, o.remote, modTime, o.size, o.md5)
+	_, err = o.fs.createOrUpdate(ctx, o.remote, o.createTime, modTime, o.size, o.md5)
 	if err != nil {
 		if err == fs.ErrorObjectNotFound {
 			// file was modified (size/md5 changed) between readMetaData and createOrUpdate?
@@ -1909,6 +1943,37 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
 		// Wrap the accounting back onto the stream
 		in = wrap(in)
 	}
+	// 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)
+	}
+	var createdTime string
+	var modTime string
+	if meta != nil {
+		if v, ok := meta["btime"]; ok {
+			t, err := time.Parse(time.RFC3339Nano, v) // metadata stores RFC3339Nano timestamps
+			if err != nil {
+				fs.Debugf(o, "failed to parse metadata btime: %q: %v", v, err)
+			} else {
+				createdTime = api.Rfc3339Time(t).String() // jottacloud api wants RFC3339 timestamps
+			}
+		}
+		if v, ok := meta["mtime"]; ok {
+			t, err := time.Parse(time.RFC3339Nano, v)
+			if err != nil {
+				fs.Debugf(o, "failed to parse metadata mtime: %q: %v", v, err)
+			} else {
+				modTime = api.Rfc3339Time(t).String()
+			}
+		}
+	}
+	if modTime == "" { // prefer mtime in meta as Modified time, fallback to source ModTime
+		modTime = api.Rfc3339Time(src.ModTime(ctx)).String()
+	}
+	if createdTime == "" { // if no Created time set same as Modified
+		createdTime = modTime
+	}
 
 	// use the api to allocate the file first and get resume / deduplication info
 	var resp *http.Response
@@ -1918,13 +1983,12 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
 		Options:      options,
 		ExtraHeaders: make(map[string]string),
 	}
-	fileDate := api.Rfc3339Time(src.ModTime(ctx)).String()
 
 	// the allocate request
 	var request = api.AllocateFileRequest{
 		Bytes:    size,
-		Created:  fileDate,
-		Modified: fileDate,
+		Created:  createdTime,
+		Modified: modTime,
 		Md5:      md5String,
 		Path:     o.fs.allocatePathRaw(o.remote, true),
 	}
@@ -1939,7 +2003,10 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
 		return err
 	}
 
-	// If the file state is INCOMPLETE and CORRUPT, try to upload a then
+	// If the file state is INCOMPLETE and CORRUPT, we must upload it.
+	// Else, if the file state is COMPLETE, we don't need to upload it because
+	// the content is already there, possibly it was created with deduplication,
+	// and also any metadata changes are already performed by the allocate request.
 	if response.State != "COMPLETED" {
 		// how much do we still have to upload?
 		remainingBytes := size - response.ResumePos
@@ -1963,22 +2030,18 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
 		}
 
 		// send the remaining bytes
-		resp, err = o.fs.apiSrv.CallJSON(ctx, &opts, nil, &result)
+		_, err = o.fs.apiSrv.CallJSON(ctx, &opts, nil, &result)
 		if err != nil {
 			return err
 		}
 
-		// finally update the meta data
-		o.hasMetaData = true
-		o.size = result.Bytes
-		o.md5 = result.Md5
-		o.modTime = time.Unix(result.Modified/1000, 0)
-	} else {
-		// If the file state is COMPLETE we don't need to upload it because the file was already found but we still need to update our metadata
-		return o.readMetaData(ctx, true)
+		// Upload response contains main metadata properties (size, md5 and modTime)
+		// which could be set back to the object, but it does not contain the
+		// necessary information to set the createTime and updateTime properties,
+		// so must therefore perform a read instead.
 	}
-
-	return nil
+	// in any case we must update the object meta data
+	return o.readMetaData(ctx, true)
 }
 
 func (o *Object) remove(ctx context.Context, hard bool) error {
@@ -2013,6 +2076,22 @@ func (o *Object) Remove(ctx context.Context) error {
 	return o.remove(ctx, o.fs.opt.HardDelete)
 }
 
+// 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, false)
+	if err != nil {
+		fs.Logf(o, "Failed to read metadata: %v", err)
+		return nil, err
+	}
+	metadata.Set("btime", o.createTime.Format(time.RFC3339Nano)) // metadata timestamps should be RFC3339Nano
+	metadata.Set("mtime", o.modTime.Format(time.RFC3339Nano))
+	metadata.Set("utime", o.updateTime.Format(time.RFC3339Nano))
+	metadata.Set("content-type", o.mimeType)
+	return metadata, nil
+}
+
 // Check the interfaces are satisfied
 var (
 	_ fs.Fs           = (*Fs)(nil)
@@ -2027,4 +2106,5 @@ var (
 	_ fs.CleanUpper   = (*Fs)(nil)
 	_ fs.Object       = (*Object)(nil)
 	_ fs.MimeTyper    = (*Object)(nil)
+	_ fs.Metadataer   = (*Object)(nil)
 )
diff --git a/docs/content/docs.md b/docs/content/docs.md
index f6a14bca2..de6459a17 100644
--- a/docs/content/docs.md
+++ b/docs/content/docs.md
@@ -550,6 +550,7 @@ backend may implement.
 | 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 |
+| utime               | Time of file upload:  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 |
diff --git a/docs/content/jottacloud.md b/docs/content/jottacloud.md
index 918b499f6..d2b19e53e 100644
--- a/docs/content/jottacloud.md
+++ b/docs/content/jottacloud.md
@@ -233,7 +233,7 @@ them. Generally you should avoid these, unless you know what you are doing.
 
 ### --fast-list
 
-This remote supports `--fast-list` which allows you to use fewer
+This backend supports `--fast-list` which allows you to use fewer
 transactions in exchange for more memory. See the [rclone
 docs](/docs/#fast-list) for more details.
 
@@ -241,8 +241,9 @@ Note that the implementation in Jottacloud always uses only a single
 API request to get the entire list, so for large folders this could
 lead to long wait time before the first results are shown.
 
-Note also that with rclone version 1.58 and newer information about
-[MIME types](/overview/#mime-type) are not available when using `--fast-list`.
+Note also that with rclone version 1.58 and newer, information about
+[MIME types](/overview/#mime-type) and metadata item [utime](#metadata)
+are not available when using `--fast-list`.
 
 ### Modified time and hashes
 
diff --git a/docs/content/overview.md b/docs/content/overview.md
index a0759e579..37e10c589 100644
--- a/docs/content/overview.md
+++ b/docs/content/overview.md
@@ -33,7 +33,7 @@ Here is an overview of the major features of each cloud storage system.
 | HiDrive                      | HiDrive ¹²        | R/W     | No               | No              | -         | -        |
 | HTTP                         | -                 | R       | No               | No              | R         | -        |
 | Internet Archive             | MD5, SHA1, CRC32  | R/W ¹¹  | No               | No              | -         | RWU      |
-| Jottacloud                   | MD5               | R/W     | Yes              | No              | R         | -        |
+| Jottacloud                   | MD5               | R/W     | Yes              | No              | R         | RW       |
 | Koofr                        | MD5               | -       | Yes              | No              | -         | -        |
 | Mail.ru Cloud                | Mailru ⁶          | R/W     | Yes              | No              | -         | -        |
 | Mega                         | -                 | -       | No               | Yes             | -         | -        |


Test patch:
diff --git a/backend/jottacloud/jottacloud_internal_test.go b/backend/jottacloud/jottacloud_internal_test.go
index b2b11758a..f77a3f291 100644
--- a/backend/jottacloud/jottacloud_internal_test.go
+++ b/backend/jottacloud/jottacloud_internal_test.go
@@ -1,11 +1,17 @@
 package jottacloud
 
 import (
+	"context"
 	"crypto/md5"
 	"fmt"
 	"io"
 	"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/rclone/rclone/lib/readers"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
@@ -40,3 +46,48 @@ func TestReadMD5(t *testing.T) {
 		})
 	}
 }
+
+func (f *Fs) InternalTestMetadata(t *testing.T) {
+	ctx := context.Background()
+	contents := random.String(1000)
+
+	item := fstest.NewItem("test-metadata", contents, fstest.Time("2001-05-06T04:05:06.499999999Z"))
+	utime := time.Now()
+	metadata := fs.Metadata{
+		"btime": "2009-05-06T04:05:06.499999999Z",
+		"mtime": "2010-06-07T08:09:07.599999999Z",
+		//"utime" - read-only
+		//"content-type" - 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 "btime":
+			assert.True(t, fstest.Time(v).Truncate(f.Precision()).Equal(fstest.Time(got)), fmt.Sprintf("btime not equal want %v got %v", v, got))
+		case "mtime":
+			assert.True(t, fstest.Time(v).Truncate(f.Precision()).Equal(fstest.Time(got)), fmt.Sprintf("btime not equal want %v got %v", v, got))
+		case "utime":
+			gotUtime := fstest.Time(got)
+			dt := gotUtime.Sub(utime)
+			assert.True(t, dt < time.Minute && dt > -time.Minute, fmt.Sprintf("utime more than 1 minute out want %v got %v delta %v", utime, gotUtime, dt))
+			assert.True(t, fstest.Time(v).Equal(fstest.Time(got)))
+		case "content-type":
+			assert.True(t, o.MimeType(ctx) == 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)