Repository: rclone/rclone
Pull request #4105 (2020-04-03 00:37:40)

backend/googlephotos: Add start_year for google photos options
#### What is the purpose of this change?
Make the start year configurable instead of hard coding it to `2000`

#### Was the change discussed in an issue or in the forum before?
Discussion: https://github.com/rclone/rclone/issues/3630

#### Checklist

- [x] I have read the [contribution guidelines](https://github.com/rclone/rclone/blob/master/CONTRIBUTING.md#submitting-a-pull-request).
- [ ] 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 :-)


dooven: Im getting an error while runnign the integration test. I might have reached my quota for uploading photos

```
--- FAIL: TestIntegration (1.73s)
    --- FAIL: TestIntegration/CreateAlbum (1.64s)
        --- FAIL: TestIntegration/CreateAlbum/PutFile (0.12s)
            googlephotos_test.go:60: 
                        Error Trace:    googlephotos_test.go:60
                        Error:          Received unexpected error:
                                        {
                                          "code": 8,
                                          "message": "Quota exceeded for quota metric \u0027Size of uploads\u00
27 and limit \u0027Size of uploads per day\u0027 of service \u0027photoslibrary.googleapis.com\u0027 for consum
er \u0027project_number:202264815644\u0027."
                                        } (429 429 Too Many Requests)
                                        couldn't upload file
                                        github.com/rclone/rclone/backend/googlephotos.(*Object).Update
                                                /home/daven/go/src/github.com/rclone/rclone/backend/googlephotos/googlephotos.go:978
                                        github.com/rclone/rclone/backend/googlephotos.(*Fs).Put
                                                /home/daven/go/src/github.com/rclone/rclone/backend/googlephotos/googlephotos.go:636
                                        github.com/rclone/rclone/backend/googlephotos.TestIntegration.func1.1
                                                /home/daven/go/src/github.com/rclone/rclone/backend/googlephotos/googlephotos_test.go:59
                                        testing.tRunner
                                                /usr/local/go/src/testing/testing.go:909
                                        runtime.goexit
                                                /usr/local/go/src/runtime/asm_amd64.s:1357
                        Test:           TestIntegration/CreateAlbum/PutFile
    --- PASS: TestIntegration/UploadMkdir (0.00s)
        --- PASS: TestIntegration/UploadMkdir/List (0.00s)
        --- PASS: TestIntegration/UploadMkdir/Rmdir (0.00s)
        --- PASS: TestIntegration/UploadMkdir/ListEmpty (0.00s)
    --- FAIL: TestIntegration/Upload (0.09s)
        googlephotos_test.go:225: 
                Error Trace:    googlephotos_test.go:225
                Error:          Received unexpected error:
                                {
                                  "code": 8,
                                  "message": "Quota exceeded for quota metric \u0027Size of uploads\u0027 and limit \u0027Size of uploads per day\u0027 of service \u0027photoslibrary.googleapis.com\u0027 for consumer \u0027project_number:202264815644\u0027."
                                } (429 429 Too Many Requests)
                                couldn't upload file
                                github.com/rclone/rclone/backend/googlephotos.(*Object).Update
                                        /home/daven/go/src/github.com/rclone/rclone/backend/googlephotos/googlephotos.go:978
                                github.com/rclone/rclone/backend/googlephotos.(*Fs).Put
                                        /home/daven/go/src/github.com/rclone/rclone/backend/googlephotos/googlephotos.go:636
                                github.com/rclone/rclone/backend/googlephotos.TestIntegration.func3
                                        /home/daven/go/src/github.com/rclone/rclone/backend/googlephotos/googlephotos_test.go:224
                                testing.tRunner
                                        /usr/local/go/src/testing/testing.go:909
                                runtime.goexit
                                        /usr/local/go/src/runtime/asm_amd64.s:1357
```

Patch:
diff --git a/Dockerfile b/Dockerfile
index a6f3a64cb..c80e04713 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -5,7 +5,7 @@ WORKDIR /go/src/github.com/rclone/rclone/
 
 RUN make quicktest
 RUN \
-  CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
+  CGO_ENABLED=0 \
   make
 RUN ./rclone version
 
diff --git a/backend/googlephotos/googlephotos.go b/backend/googlephotos/googlephotos.go
index 4fa4b327d..292bb0974 100644
--- a/backend/googlephotos/googlephotos.go
+++ b/backend/googlephotos/googlephotos.go
@@ -134,14 +134,20 @@ rclone mount needs to know the size of files in advance of reading
 them, so setting this flag when using rclone mount is recommended if
 you want to read the media.`,
 			Advanced: true,
+		}, {
+			Name:     "start_year",
+			Default:  2000,
+			Help:     `Year limits the photos to be downloaded to those which are uploaded after the given year`,
+			Advanced: true,
 		}},
 	})
 }
 
 // Options defines the configuration for this backend
 type Options struct {
-	ReadOnly bool `config:"read_only"`
-	ReadSize bool `config:"read_size"`
+	ReadOnly  bool `config:"read_only"`
+	ReadSize  bool `config:"read_size"`
+	StartYear int  `config:"start_year"`
 }
 
 // Fs represents a remote storage server
@@ -202,6 +208,11 @@ func (f *Fs) dirTime() time.Time {
 	return f.startTime
 }
 
+// startYear returns the start year
+func (f *Fs) startYear() int {
+	return f.opt.StartYear
+}
+
 // retryErrorCodes is a slice of error codes that we will retry
 var retryErrorCodes = []int{
 	429, // Too Many Requests.
diff --git a/backend/googlephotos/pattern.go b/backend/googlephotos/pattern.go
index 198a82c06..e54b7c4ab 100644
--- a/backend/googlephotos/pattern.go
+++ b/backend/googlephotos/pattern.go
@@ -23,6 +23,7 @@ type lister interface {
 	listAlbums(ctx context.Context, shared bool) (all *albums, err error)
 	listUploads(ctx context.Context, dir string) (entries fs.DirEntries, err error)
 	dirTime() time.Time
+	startYear() int
 }
 
 // dirPattern describes a single directory pattern
@@ -222,11 +223,10 @@ func (ds dirPatterns) match(root string, itemPath string, isFile bool) (match []
 	return nil, "", nil
 }
 
-// Return the years from 2000 to today
-// FIXME make configurable?
+// Return the years from startYear to today
 func years(ctx context.Context, f lister, prefix string, match []string) (entries fs.DirEntries, err error) {
 	currentYear := f.dirTime().Year()
-	for year := 2000; year <= currentYear; year++ {
+	for year := f.startYear(); year <= currentYear; year++ {
 		entries = append(entries, fs.NewDir(prefix+fmt.Sprint(year), f.dirTime()))
 	}
 	return entries, nil
diff --git a/backend/jottacloud/api/types.go b/backend/jottacloud/api/types.go
index fb28d39fa..06395f06a 100644
--- a/backend/jottacloud/api/types.go
+++ b/backend/jottacloud/api/types.go
@@ -164,6 +164,12 @@ type CustomerInfo struct {
 	IOSHash           string      `json:"ios_hash"`
 }
 
+// TrashResponse is returned when emptying the Trash
+type TrashResponse struct {
+	Folders int64 `json:"folders"`
+	Files   int64 `json:"files"`
+}
+
 // XML structures returned by the old API
 
 // Flag is a hacky type for checking if an attribute is present
diff --git a/backend/jottacloud/jottacloud.go b/backend/jottacloud/jottacloud.go
index 8f414ff7d..5422766ca 100644
--- a/backend/jottacloud/jottacloud.go
+++ b/backend/jottacloud/jottacloud.go
@@ -140,6 +140,11 @@ func init() {
 			Help:     "Files bigger than this will be cached on disk to calculate the MD5 if required.",
 			Default:  fs.SizeSuffix(10 * 1024 * 1024),
 			Advanced: true,
+		}, {
+			Name:     "trashed_only",
+			Help:     "Only show files that are in the trash.\nThis will show trashed files in their original directory structure.",
+			Default:  false,
+			Advanced: true,
 		}, {
 			Name:     "hard_delete",
 			Help:     "Delete files permanently rather than putting them into the trash.",
@@ -174,6 +179,7 @@ type Options struct {
 	Device             string               `config:"device"`
 	Mountpoint         string               `config:"mountpoint"`
 	MD5MemoryThreshold fs.SizeSuffix        `config:"md5_memory_limit"`
+	TrashedOnly        bool                 `config:"trashed_only"`
 	HardDelete         bool                 `config:"hard_delete"`
 	Unlink             bool                 `config:"unlink"`
 	UploadThreshold    fs.SizeSuffix        `config:"upload_resume_limit"`
@@ -519,6 +525,9 @@ func NewFs(name, root string, m configmap.Mapper) (fs.Fs, error) {
 		WriteMimeType:           true,
 	}).Fill(f)
 	f.srv.SetErrorHandler(errorHandler)
+	if opt.TrashedOnly { // we cannot support showing Trashed Files when using ListR right now
+		f.features.ListR = nil
+	}
 
 	// Renew the token in the background
 	f.tokenRenewer = oauthutil.NewRenew(f.String(), ts, func() error {
@@ -638,13 +647,13 @@ func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err e
 		return nil, errors.Wrap(err, "couldn't list files")
 	}
 
-	if result.Deleted {
+	if bool(result.Deleted) && !f.opt.TrashedOnly {
 		return nil, fs.ErrorDirNotFound
 	}
 
 	for i := range result.Folders {
 		item := &result.Folders[i]
-		if item.Deleted {
+		if !f.opt.TrashedOnly && bool(item.Deleted) {
 			continue
 		}
 		remote := path.Join(dir, f.opt.Enc.ToStandardName(item.Name))
@@ -654,8 +663,14 @@ func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err e
 
 	for i := range result.Files {
 		item := &result.Files[i]
-		if item.Deleted || item.State != "COMPLETED" {
-			continue
+		if f.opt.TrashedOnly {
+			if !item.Deleted || item.State != "COMPLETED" {
+				continue
+			}
+		} else {
+			if item.Deleted || item.State != "COMPLETED" {
+				continue
+			}
 		}
 		remote := path.Join(dir, f.opt.Enc.ToStandardName(item.Name))
 		o, err := f.newObjectWithInfo(ctx, remote, item)
@@ -1049,6 +1064,22 @@ func (f *Fs) About(ctx context.Context) (*fs.Usage, error) {
 	return usage, nil
 }
 
+// CleanUp empties the trash
+func (f *Fs) CleanUp(ctx context.Context) error {
+	opts := rest.Opts{
+		Method: "POST",
+		Path:   "files/v1/purge_trash",
+	}
+
+	var info api.TrashResponse
+	_, err := f.apiSrv.CallJSON(ctx, &opts, nil, &info)
+	if err != nil {
+		return errors.Wrap(err, "couldn't empty trash")
+	}
+
+	return nil
+}
+
 // Hashes returns the supported hash sets.
 func (f *Fs) Hashes() hash.Set {
 	return hash.Set(hash.MD5)
@@ -1122,7 +1153,7 @@ func (o *Object) readMetaData(ctx context.Context, force bool) (err error) {
 	if err != nil {
 		return err
 	}
-	if info.Deleted {
+	if bool(info.Deleted) && !o.fs.opt.TrashedOnly {
 		return fs.ErrorObjectNotFound
 	}
 	return o.setMetaData(info)
@@ -1355,6 +1386,7 @@ var (
 	_ fs.ListRer      = (*Fs)(nil)
 	_ fs.PublicLinker = (*Fs)(nil)
 	_ fs.Abouter      = (*Fs)(nil)
+	_ fs.CleanUpper   = (*Fs)(nil)
 	_ fs.Object       = (*Object)(nil)
 	_ fs.MimeTyper    = (*Object)(nil)
 )
diff --git a/backend/s3/s3.go b/backend/s3/s3.go
index 3291be4dd..9bd4dbcb7 100644
--- a/backend/s3/s3.go
+++ b/backend/s3/s3.go
@@ -941,19 +941,18 @@ type Options struct {
 
 // Fs represents a remote s3 server
 type Fs struct {
-	name          string               // the name of the remote
-	root          string               // root of the bucket - ignore all objects above this
-	opt           Options              // parsed options
-	features      *fs.Features         // optional features
-	c             *s3.S3               // the connection to the s3 server
-	ses           *session.Session     // the s3 session
-	rootBucket    string               // bucket part of root (if any)
-	rootDirectory string               // directory part of root (if any)
-	cache         *bucket.Cache        // cache for bucket creation status
-	pacer         *fs.Pacer            // To pace the API calls
-	srv           *http.Client         // a plain http client
-	poolMu        sync.Mutex           // mutex protecting memory pools map
-	pools         map[int64]*pool.Pool // memory pools
+	name          string           // the name of the remote
+	root          string           // root of the bucket - ignore all objects above this
+	opt           Options          // parsed options
+	features      *fs.Features     // optional features
+	c             *s3.S3           // the connection to the s3 server
+	ses           *session.Session // the s3 session
+	rootBucket    string           // bucket part of root (if any)
+	rootDirectory string           // directory part of root (if any)
+	cache         *bucket.Cache    // cache for bucket creation status
+	pacer         *fs.Pacer        // To pace the API calls
+	srv           *http.Client     // a plain http client
+	pool          *pool.Pool       // memory pool
 }
 
 // Object describes a s3 object
@@ -1247,7 +1246,12 @@ func NewFs(name, root string, m configmap.Mapper) (fs.Fs, error) {
 		pacer: fs.NewPacer(pacer.NewS3(pacer.MinSleep(minSleep))),
 		cache: bucket.NewCache(),
 		srv:   fshttp.NewClient(fs.Config),
-		pools: make(map[int64]*pool.Pool),
+		pool: pool.New(
+			time.Duration(opt.MemoryPoolFlushTime),
+			int(opt.ChunkSize),
+			opt.UploadConcurrency*fs.Config.Transfers,
+			opt.MemoryPoolUseMmap,
+		),
 	}
 
 	f.setRoot(root)
@@ -1734,7 +1738,7 @@ func (f *Fs) makeBucket(ctx context.Context, bucket string) error {
 				err = nil
 			}
 		}
-		return nil
+		return err
 	}, func() (bool, error) {
 		return f.bucketExists(ctx, bucket)
 	})
@@ -1938,19 +1942,16 @@ func (f *Fs) Hashes() hash.Set {
 }
 
 func (f *Fs) getMemoryPool(size int64) *pool.Pool {
-	f.poolMu.Lock()
-	defer f.poolMu.Unlock()
+	if size == int64(f.opt.ChunkSize) {
+		return f.pool
+	}
 
-	_, ok := f.pools[size]
-	if !ok {
-		f.pools[size] = pool.New(
-			time.Duration(f.opt.MemoryPoolFlushTime),
-			int(size),
-			f.opt.UploadConcurrency*fs.Config.Transfers,
-			f.opt.MemoryPoolUseMmap,
-		)
-	}
-	return f.pools[size]
+	return pool.New(
+		time.Duration(f.opt.MemoryPoolFlushTime),
+		int(size),
+		f.opt.UploadConcurrency*fs.Config.Transfers,
+		f.opt.MemoryPoolUseMmap,
+	)
 }
 
 // ------------------------------------------------------------
@@ -2305,7 +2306,7 @@ func (o *Object) uploadMultipart(ctx context.Context, req *s3.PutObjectInput, si
 			})
 
 			// return the memory and token
-			memPool.Put(buf[:partSize])
+			memPool.Put(buf)
 			tokens.Put()
 
 			if err != nil {
diff --git a/cmd/cmd.go b/cmd/cmd.go
index 01f0426db..5f1729401 100644
--- a/cmd/cmd.go
+++ b/cmd/cmd.go
@@ -66,6 +66,7 @@ const (
 	exitCodeNoRetryError
 	exitCodeFatalError
 	exitCodeTransferExceeded
+	exitCodeNoFilesTransferred
 )
 
 // ShowVersion prints the version to stdout
@@ -312,6 +313,7 @@ func Run(Retry bool, showStats bool, cmd *cobra.Command, f func() error) {
 		}
 	}
 	resolveExitCode(cmdErr)
+
 }
 
 // CheckArgs checks there are enough arguments and prints a message if not
@@ -430,6 +432,11 @@ func initConfig() {
 func resolveExitCode(err error) {
 	atexit.Run()
 	if err == nil {
+		if fs.Config.ErrorOnNoTransfer {
+			if accounting.GlobalStats().GetTransfers() == 0 {
+				os.Exit(exitCodeNoFilesTransferred)
+			}
+		}
 		os.Exit(exitCodeSuccess)
 	}
 
diff --git a/cmd/cmount/fs.go b/cmd/cmount/fs.go
index 352e45d19..9f49c777a 100644
--- a/cmd/cmount/fs.go
+++ b/cmd/cmount/fs.go
@@ -268,25 +268,15 @@ func (fsys *FS) Releasedir(path string, fh uint64) (errc int) {
 func (fsys *FS) Statfs(path string, stat *fuse.Statfs_t) (errc int) {
 	defer log.Trace(path, "")("stat=%+v, errc=%d", stat, &errc)
 	const blockSize = 4096
-	const fsBlocks = (1 << 50) / blockSize
-	stat.Blocks = fsBlocks  // Total data blocks in file system.
-	stat.Bfree = fsBlocks   // Free blocks in file system.
-	stat.Bavail = fsBlocks  // Free blocks in file system if you're not root.
-	stat.Files = 1e9        // Total files in file system.
-	stat.Ffree = 1e9        // Free files in file system.
-	stat.Bsize = blockSize  // Block size
-	stat.Namemax = 255      // Maximum file name length?
-	stat.Frsize = blockSize // Fragment size, smallest addressable data size in the file system.
-	total, used, free := fsys.VFS.Statfs()
-	if total >= 0 {
-		stat.Blocks = uint64(total) / blockSize
-	}
-	if used >= 0 {
-		stat.Bfree = stat.Blocks - uint64(used)/blockSize
-	}
-	if free >= 0 {
-		stat.Bavail = uint64(free) / blockSize
-	}
+	total, _, free := fsys.VFS.Statfs()
+	stat.Blocks = uint64(total) / blockSize // Total data blocks in file system.
+	stat.Bfree = uint64(free) / blockSize   // Free blocks in file system.
+	stat.Bavail = stat.Bfree                // Free blocks in file system if you're not root.
+	stat.Files = 1e9                        // Total files in file system.
+	stat.Ffree = 1e9                        // Free files in file system.
+	stat.Bsize = blockSize                  // Block size
+	stat.Namemax = 255                      // Maximum file name length?
+	stat.Frsize = blockSize                 // Fragment size, smallest addressable data size in the file system.
 	mountlib.ClipBlocks(&stat.Blocks)
 	mountlib.ClipBlocks(&stat.Bfree)
 	mountlib.ClipBlocks(&stat.Bavail)
diff --git a/cmd/copy/copy.go b/cmd/copy/copy.go
index 9b5444105..6bc8c07f7 100644
--- a/cmd/copy/copy.go
+++ b/cmd/copy/copy.go
@@ -66,8 +66,8 @@ option when copying a small number of files into a large destination
 can speed transfers up greatly.
 
 For example, if you have many files in /path/to/src but only a few of
-them change every day, you can to copy all the files which have
-changed recently very efficiently like this:
+them change every day, you can copy all the files which have changed
+recently very efficiently like this:
 
     rclone copy --max-age 24h --no-traverse /path/to/src remote:
 
diff --git a/cmd/lsf/lsf.go b/cmd/lsf/lsf.go
index f82972e6b..9a019776d 100644
--- a/cmd/lsf/lsf.go
+++ b/cmd/lsf/lsf.go
@@ -132,13 +132,13 @@ Eg
     "this file contains a comma, in the file name.txt",6
 
 Note that the --absolute parameter is useful for making lists of files
-to pass to an rclone copy with the --files-from flag.
+to pass to an rclone copy with the --files-from-raw flag.
 
 For example to find all the files modified within one day and copy
 those only (without traversing the whole directory structure):
 
     rclone lsf --absolute --files-only --max-age 1d /path/to/local > new_files
-    rclone copy --files-from new_files /path/to/local remote:path
+    rclone copy --files-from-raw new_files /path/to/local remote:path
 
 ` + lshelp.Help,
 	Run: func(command *cobra.Command, args []string) {
diff --git a/cmd/mount/fs.go b/cmd/mount/fs.go
index baa285fb6..016e97eac 100644
--- a/cmd/mount/fs.go
+++ b/cmd/mount/fs.go
@@ -54,25 +54,15 @@ var _ fusefs.FSStatfser = (*FS)(nil)
 func (f *FS) Statfs(ctx context.Context, req *fuse.StatfsRequest, resp *fuse.StatfsResponse) (err error) {
 	defer log.Trace("", "")("stat=%+v, err=%v", resp, &err)
 	const blockSize = 4096
-	const fsBlocks = (1 << 50) / blockSize
-	resp.Blocks = fsBlocks  // Total data blocks in file system.
-	resp.Bfree = fsBlocks   // Free blocks in file system.
-	resp.Bavail = fsBlocks  // Free blocks in file system if you're not root.
-	resp.Files = 1e9        // Total files in file system.
-	resp.Ffree = 1e9        // Free files in file system.
-	resp.Bsize = blockSize  // Block size
-	resp.Namelen = 255      // Maximum file name length?
-	resp.Frsize = blockSize // Fragment size, smallest addressable data size in the file system.
-	total, used, free := f.VFS.Statfs()
-	if total >= 0 {
-		resp.Blocks = uint64(total) / blockSize
-	}
-	if used >= 0 {
-		resp.Bfree = resp.Blocks - uint64(used)/blockSize
-	}
-	if free >= 0 {
-		resp.Bavail = uint64(free) / blockSize
-	}
+	total, _, free := f.VFS.Statfs()
+	resp.Blocks = uint64(total) / blockSize // Total data blocks in file system.
+	resp.Bfree = uint64(free) / blockSize   // Free blocks in file system.
+	resp.Bavail = resp.Bfree                // Free blocks in file system if you're not root.
+	resp.Files = 1e9                        // Total files in file system.
+	resp.Ffree = 1e9                        // Free files in file system.
+	resp.Bsize = blockSize                  // Block size
+	resp.Namelen = 255                      // Maximum file name length?
+	resp.Frsize = blockSize                 // Fragment size, smallest addressable data size in the file system.
 	mountlib.ClipBlocks(&resp.Blocks)
 	mountlib.ClipBlocks(&resp.Bfree)
 	mountlib.ClipBlocks(&resp.Bavail)
diff --git a/cmd/serve/sftp/sftp.go b/cmd/serve/sftp/sftp.go
index 7a4508f5a..5cc27c91b 100644
--- a/cmd/serve/sftp/sftp.go
+++ b/cmd/serve/sftp/sftp.go
@@ -20,7 +20,7 @@ import (
 // Options contains options for the http Server
 type Options struct {
 	ListenAddr     string // Port to listen on
-	Key            string // Path to private key
+	Key            string // Path to private host key
 	AuthorizedKeys string // Path to authorized keys file
 	User           string // single username
 	Pass           string // password for user
@@ -40,7 +40,7 @@ var Opt = DefaultOpt
 func AddFlags(flagSet *pflag.FlagSet, Opt *Options) {
 	rc.AddOption("sftp", &Opt)
 	flags.StringVarP(flagSet, &Opt.ListenAddr, "addr", "", Opt.ListenAddr, "IPaddress:Port or :Port to bind server to.")
-	flags.StringVarP(flagSet, &Opt.Key, "key", "", Opt.Key, "SSH private key file (leave blank to auto generate)")
+	flags.StringVarP(flagSet, &Opt.Key, "key", "", Opt.Key, "SSH private host key file (leave blank to auto generate)")
 	flags.StringVarP(flagSet, &Opt.AuthorizedKeys, "authorized-keys", "", Opt.AuthorizedKeys, "Authorized keys file")
 	flags.StringVarP(flagSet, &Opt.User, "user", "", Opt.User, "User name for authentication.")
 	flags.StringVarP(flagSet, &Opt.Pass, "pass", "", Opt.Pass, "Password for authentication.")
diff --git a/docs/content/authors.md b/docs/content/authors.md
index 68cc532df..a6d6f459d 100644
--- a/docs/content/authors.md
+++ b/docs/content/authors.md
@@ -311,7 +311,7 @@ Contributors
   * Barry Muldrey <barry@muldrey.net>
   * Sebastian Brandt <sebastian.brandt@friday.de>
   * Marco Molteni <marco.molteni@mailbox.org>
-  * Ankur Gupta <ankur0493@gmail.com>
+  * Ankur Gupta <ankur0493@gmail.com> <7876747+ankur0493@users.noreply.github.com>
   * Maciej Zimnoch <maciej@scylladb.com>
   * anuar45 <serdaliyev.anuar@gmail.com>
   * Fernando <ferferga@users.noreply.github.com>
@@ -355,3 +355,4 @@ Contributors
   * harry <me@harry.plus>
   * Samantha McVey <samantham@posteo.net>
   * Jack Anderson <jack.anderson@metaswitch.com>
+  * Michael G <draget@speciesm.net>
diff --git a/docs/content/docs.md b/docs/content/docs.md
index 274ba9a0b..9791d744a 100644
--- a/docs/content/docs.md
+++ b/docs/content/docs.md
@@ -553,6 +553,18 @@ the time to send the request header.
 
 The default is `1s`.  Set to 0 to disable.
 
+### --error-on-no-transfer ###
+
+By default, rclone will exit with return code 0 if there were no errors.
+
+This option allows rclone to return exit code 9 if no files were transferred
+between the source and destination. This allows using rclone in scripts, and
+triggering follow-on actions if data was copied, or skipping if not.
+
+NB: Enabling this option turns a usually non-fatal error into a potentially
+fatal one - please check and adjust your scripts accordingly!
+
+
 ### --ignore-case-sync ###
 
 Using this option will cause rclone to ignore the case of the files 
@@ -1542,6 +1554,7 @@ For the filtering options
   * `--include`
   * `--include-from`
   * `--files-from`
+  * `--files-from-raw`
   * `--min-size`
   * `--max-size`
   * `--min-age`
@@ -1621,6 +1634,7 @@ it will log a high priority message if the retry was successful.
   * `6` - Less serious errors (like 461 errors from dropbox) (NoRetry errors)
   * `7` - Fatal error (one that more retries won't fix, like account suspended) (Fatal errors)
   * `8` - Transfer exceeded - limit set by --max-transfer reached
+  * `9` - Operation successful, but no files transferred
 
 Environment Variables
 ---------------------
diff --git a/docs/content/filtering.md b/docs/content/filtering.md
index 96bcbfa89..b69e494fe 100644
--- a/docs/content/filtering.md
+++ b/docs/content/filtering.md
@@ -17,8 +17,9 @@ Each path as it passes through rclone is matched against the include
 and exclude rules like `--include`, `--exclude`, `--include-from`,
 `--exclude-from`, `--filter`, or `--filter-from`. The simplest way to
 try them out is using the `ls` command, or `--dry-run` together with
-`-v`. `--filter-from`, `--exclude-from`, `--include-from`, `--files-from`
-understand `-` as a file name to mean read from standard input.
+`-v`. `--filter-from`, `--exclude-from`, `--include-from`, `--files-from`,
+`--files-from-raw` understand `-` as a file name to mean read from standard
+input.
 
 ## Patterns ##
 
@@ -179,6 +180,7 @@ type.
   * `--exclude-from`
   * `--filter`
   * `--filter-from`
+  * `--filter-from-raw`
 
 **Important** You should not use `--include*` together with `--exclude*`. 
 It may produce different results than you expected. In that case try to use: `--filter*`.
@@ -306,9 +308,9 @@ This reads a list of file names from the file passed in and **only**
 these files are transferred.  The **filtering rules are ignored**
 completely if you use this option.
 
-`--files-from` expects a list of files as it's input. [rclone lsf](/commands/rclone_lsf/)
-has a compatible format that can be used to export file lists from
-remotes.
+`--files-from` expects a list of files as it's input. Leading / trailing
+whitespace is stripped from the input lines and lines starting with `#`
+and `;` are ignored.
 
 Rclone will traverse the file system if you use `--files-from`,
 effectively using the files in `--files-from` as a set of filters.
@@ -324,7 +326,8 @@ are read in the order that they are placed on the command line.
 
 Paths within the `--files-from` file will be interpreted as starting
 with the root specified in the command.  Leading `/` characters are
-ignored.
+ignored. See [--files-from-raw](#files-from-raw-read-list-of-source-file-names-without-any-processing)
+if you need the input to be processed in a raw manner.
 
 For example, suppose you had `files-from.txt` with this content:
 
@@ -384,6 +387,13 @@ In this case there will be an extra `home` directory on the remote:
     /home/user1/dir/file  → remote:backup/home/user1/dir/file
     /home/user2/stuff     → remote:backup/home/user2/stuff
 
+### `--files-from-raw` - Read list of source-file names without any processing ###
+This option is same as `--files-from` with the only difference being that the input
+is read in a raw manner. This means that lines with leading/trailing whitespace and
+lines starting with `;` or `#` are read without any processing. [rclone lsf](/commands/rclone_lsf/)
+has a compatible format that can be used to export file lists from remotes, which
+can then be used as an input to `--files-from-raw`.
+
 ### `--min-size` - Don't transfer any file smaller than this ###
 
 This option controls the minimum size file which will be transferred.
diff --git a/docs/content/install.sh b/docs/content/install.sh
index 01359702f..90c400c93 100755
--- a/docs/content/install.sh
+++ b/docs/content/install.sh
@@ -187,8 +187,6 @@ esac
 
 #update version variable post install
 version=`rclone --version 2>>errors | head -n 1`
-#create the directory for the default configuration folder
-mkdir $HOME/.config/rclone -p
 
 printf "\n${version} has successfully installed."
 printf '\nNow run "rclone config" for setup. Check https://rclone.org/docs/ for more details.\n\n'
diff --git a/docs/content/jottacloud.md b/docs/content/jottacloud.md
index bb9d120d8..a0ae664c5 100644
--- a/docs/content/jottacloud.md
+++ b/docs/content/jottacloud.md
@@ -174,6 +174,16 @@ Files bigger than this will be cached on disk to calculate the MD5 if required.
 - Type:        SizeSuffix
 - Default:     10M
 
+#### --jottacloud-trashed-only
+
+Only show files that are in the trash.
+This will show trashed files in their original directory structure.
+
+- Config:      trashed_only
+- Env Var:     RCLONE_JOTTACLOUD_TRASHED_ONLY
+- Type:        bool
+- Default:     false
+
 #### --jottacloud-hard-delete
 
 Delete files permanently rather than putting them into the trash.
diff --git a/fs/accounting/stats.go b/fs/accounting/stats.go
index 00423a46c..0ed50a322 100644
--- a/fs/accounting/stats.go
+++ b/fs/accounting/stats.go
@@ -375,7 +375,13 @@ func (s *StatsInfo) Transferred() []TransferSnapshot {
 
 // Log outputs the StatsInfo to the log
 func (s *StatsInfo) Log() {
-	fs.LogLevelPrintf(fs.Config.StatsLogLevel, nil, "%v\n", s)
+	if fs.Config.UseJSONLog {
+		out, _ := s.RemoteStats()
+		fs.LogLevelPrintf(fs.Config.StatsLogLevel, nil, "%v%v\n", s, fs.LogValue("stats", out))
+	} else {
+		fs.LogLevelPrintf(fs.Config.StatsLogLevel, nil, "%v\n", s)
+	}
+
 }
 
 // Bytes updates the stats for bytes bytes
diff --git a/fs/config.go b/fs/config.go
index 285ac4024..4e3cc6a60 100644
--- a/fs/config.go
+++ b/fs/config.go
@@ -100,6 +100,7 @@ type ConfigInfo struct {
 	StatsOneLine           bool
 	StatsOneLineDate       bool   // If we want a date prefix at all
 	StatsOneLineDateFormat string // If we want to customize the prefix
+	ErrorOnNoTransfer      bool   // Set appropriate exit code if no files transferred
 	Progress               bool
 	Cookie                 bool
 	UseMmap                bool
diff --git a/fs/config/configflags/configflags.go b/fs/config/configflags/configflags.go
index 890ac2c56..d142f42c6 100644
--- a/fs/config/configflags/configflags.go
+++ b/fs/config/configflags/configflags.go
@@ -101,6 +101,7 @@ func AddFlags(flagSet *pflag.FlagSet) {
 	flags.BoolVarP(flagSet, &fs.Config.StatsOneLine, "stats-one-line", "", fs.Config.StatsOneLine, "Make the stats fit on one line.")
 	flags.BoolVarP(flagSet, &fs.Config.StatsOneLineDate, "stats-one-line-date", "", fs.Config.StatsOneLineDate, "Enables --stats-one-line and add current date/time prefix.")
 	flags.StringVarP(flagSet, &fs.Config.StatsOneLineDateFormat, "stats-one-line-date-format", "", fs.Config.StatsOneLineDateFormat, "Enables --stats-one-line-date and uses custom formatted date. Enclose date string in double quotes (\"). See https://golang.org/pkg/time/#Time.Format")
+	flags.BoolVarP(flagSet, &fs.Config.ErrorOnNoTransfer, "error-on-no-transfer", "", fs.Config.ErrorOnNoTransfer, "Sets exit code 9 if no files are transferred, useful in scripts")
 	flags.BoolVarP(flagSet, &fs.Config.Progress, "progress", "P", fs.Config.Progress, "Show progress during transfer.")
 	flags.BoolVarP(flagSet, &fs.Config.Cookie, "use-cookies", "", fs.Config.Cookie, "Enable session cookiejar.")
 	flags.BoolVarP(flagSet, &fs.Config.UseMmap, "use-mmap", "", fs.Config.UseMmap, "Use mmap allocator (see docs).")
diff --git a/fs/filter/filter.go b/fs/filter/filter.go
index aa52ab010..c083f2349 100644
--- a/fs/filter/filter.go
+++ b/fs/filter/filter.go
@@ -88,6 +88,7 @@ type Opt struct {
 	IncludeRule    []string
 	IncludeFrom    []string
 	FilesFrom      []string
+	FilesFromRaw   []string
 	MinAge         fs.Duration
 	MaxAge         fs.Duration
 	MinSize        fs.SizeSuffix
@@ -150,7 +151,7 @@ func NewFilter(opt *Opt) (f *Filter, err error) {
 		addImplicitExclude = true
 	}
 	for _, rule := range f.Opt.IncludeFrom {
-		err := forEachLine(rule, func(line string) error {
+		err := forEachLine(rule, false, func(line string) error {
 			return f.Add(true, line)
 		})
 		if err != nil {
@@ -166,7 +167,7 @@ func NewFilter(opt *Opt) (f *Filter, err error) {
 		foundExcludeRule = true
 	}
 	for _, rule := range f.Opt.ExcludeFrom {
-		err := forEachLine(rule, func(line string) error {
+		err := forEachLine(rule, false, func(line string) error {
 			return f.Add(false, line)
 		})
 		if err != nil {
@@ -186,25 +187,42 @@ func NewFilter(opt *Opt) (f *Filter, err error) {
 		}
 	}
 	for _, rule := range f.Opt.FilterFrom {
-		err := forEachLine(rule, f.AddRule)
+		err := forEachLine(rule, false, f.AddRule)
 		if err != nil {
 			return nil, err
 		}
 	}
 
 	inActive := f.InActive()
+
 	for _, rule := range f.Opt.FilesFrom {
 		if !inActive {
-			return nil, fmt.Errorf("The usage of --files-from overrides all other filters, it should be used alone")
+			return nil, fmt.Errorf("The usage of --files-from overrides all other filters, it should be used alone or with --files-from-raw")
+		}
+		f.initAddFile() // init to show --files-from set even if no files within
+		err := forEachLine(rule, false, func(line string) error {
+			return f.AddFile(line)
+		})
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	for _, rule := range f.Opt.FilesFromRaw {
+		// --files-from-raw can be used with --files-from, hence we do
+		// not need to get the value of f.InActive again
+		if !inActive {
+			return nil, fmt.Errorf("The usage of --files-from-raw overrides all other filters, it should be used alone or with --files-from")
 		}
 		f.initAddFile() // init to show --files-from set even if no files within
-		err := forEachLine(rule, func(line string) error {
+		err := forEachLine(rule, true, func(line string) error {
 			return f.AddFile(line)
 		})
 		if err != nil {
 			return nil, err
 		}
 	}
+
 	if addImplicitExclude {
 		err = f.Add(false, "/**")
 		if err != nil {
@@ -463,8 +481,8 @@ func (f *Filter) IncludeObject(ctx context.Context, o fs.Object) bool {
 
 // forEachLine calls fn on every line in the file pointed to by path
 //
-// It ignores empty lines and lines starting with '#' or ';'
-func forEachLine(path string, fn func(string) error) (err error) {
+// It ignores empty lines and lines starting with '#' or ';' if raw is false
+func forEachLine(path string, raw bool, fn func(string) error) (err error) {
 	var scanner *bufio.Scanner
 	if path == "-" {
 		scanner = bufio.NewScanner(os.Stdin)
@@ -478,9 +496,11 @@ func forEachLine(path string, fn func(string) error) (err error) {
 	}
 	for scanner.Scan() {
 		line := scanner.Text()
-		line = strings.TrimSpace(line)
-		if len(line) == 0 || line[0] == '#' || line[0] == ';' {
-			continue
+		if !raw {
+			line = strings.TrimSpace(line)
+			if len(line) == 0 || line[0] == '#' || line[0] == ';' {
+				continue
+			}
 		}
 		err := fn(line)
 		if err != nil {
diff --git a/fs/filter/filterflags/filterflags.go b/fs/filter/filterflags/filterflags.go
index 603218b04..e2e48ca27 100644
--- a/fs/filter/filterflags/filterflags.go
+++ b/fs/filter/filterflags/filterflags.go
@@ -31,6 +31,7 @@ func AddFlags(flagSet *pflag.FlagSet) {
 	flags.StringArrayVarP(flagSet, &Opt.IncludeRule, "include", "", nil, "Include files matching pattern")
 	flags.StringArrayVarP(flagSet, &Opt.IncludeFrom, "include-from", "", nil, "Read include patterns from file (use - to read from stdin)")
 	flags.StringArrayVarP(flagSet, &Opt.FilesFrom, "files-from", "", nil, "Read list of source-file names from file (use - to read from stdin)")
+	flags.StringArrayVarP(flagSet, &Opt.FilesFromRaw, "files-from-raw", "", nil, "Read list of source-file names from file without any processing of lines (use - to read from stdin)")
 	flags.FVarP(flagSet, &Opt.MinAge, "min-age", "", "Only transfer files older than this in s or suffix ms|s|m|h|d|w|M|y")
 	flags.FVarP(flagSet, &Opt.MaxAge, "max-age", "", "Only transfer files younger than this in s or suffix ms|s|m|h|d|w|M|y")
 	flags.FVarP(flagSet, &Opt.MinSize, "min-size", "", "Only transfer files bigger than this in k or suffix b|k|M|G")
diff --git a/fs/fs.go b/fs/fs.go
index 6f35b52e8..d8db0c255 100644
--- a/fs/fs.go
+++ b/fs/fs.go
@@ -61,7 +61,7 @@ var (
 	ErrorListAborted                 = errors.New("list aborted")
 	ErrorListBucketRequired          = errors.New("bucket or container name is needed in remote")
 	ErrorIsFile                      = errors.New("is a file not a directory")
-	ErrorNotAFile                    = errors.New("is a not a regular file")
+	ErrorNotAFile                    = errors.New("is not a regular file")
 	ErrorNotDeleting                 = errors.New("not deleting files as there were IO errors")
 	ErrorNotDeletingDirs             = errors.New("not deleting directories as there were IO errors")
 	ErrorOverlapping                 = errors.New("can't sync or move files on overlapping remotes")
diff --git a/fs/log.go b/fs/log.go
index 68f09e9cc..3baf00400 100644
--- a/fs/log.go
+++ b/fs/log.go
@@ -74,6 +74,27 @@ var LogPrint = func(level LogLevel, text string) {
 	_ = log.Output(4, text)
 }
 
+// LogValueItem describes keyed item for a JSON log entry
+type LogValueItem struct {
+	key   string
+	value interface{}
+}
+
+// LogValue should be used as an argument to any logging calls to
+// augment the JSON output with more structured information.
+//
+// key is the dictionary parameter used to store value.
+func LogValue(key string, value interface{}) LogValueItem {
+	return LogValueItem{key: key, value: value}
+}
+
+// String returns an empty string so LogValueItem entries won't show
+// in the textual representation of logs. They need to be put in so
+// the number of parameters of the log call matches.
+func (j LogValueItem) String() string {
+	return ""
+}
+
 // LogPrintf produces a log string from the arguments passed in
 func LogPrintf(level LogLevel, o interface{}, text string, args ...interface{}) {
 	out := fmt.Sprintf(text, args...)
@@ -86,6 +107,11 @@ func LogPrintf(level LogLevel, o interface{}, text string, args ...interface{})
 				"objectType": fmt.Sprintf("%T", o),
 			}
 		}
+		for _, arg := range args {
+			if item, ok := arg.(LogValueItem); ok {
+				fields[item.key] = item.value
+			}
+		}
 		switch level {
 		case LogLevelDebug:
 			logrus.WithFields(fields).Debug(out)
diff --git a/vfs/vfs.go b/vfs/vfs.go
index ccd10265c..2f35a0a8f 100644
--- a/vfs/vfs.go
+++ b/vfs/vfs.go
@@ -477,6 +477,37 @@ func (vfs *VFS) Rename(oldName, newName string) error {
 	return nil
 }
 
+// This works out the missing values from (total, used, free) using
+// unknownFree as the intended free space
+func fillInMissingSizes(total, used, free, unknownFree int64) (newTotal, newUsed, newFree int64) {
+	if total < 0 {
+		if free >= 0 {
+			total = free
+		} else {
+			total = unknownFree
+		}
+		if used >= 0 {
+			total += used
+		}
+	}
+	// total is now defined
+	if used < 0 {
+		if free >= 0 {
+			used = total - free
+		} else {
+			used = 0
+		}
+	}
+	// used is now defined
+	if free < 0 {
+		free = total - used
+	}
+	return total, used, free
+}
+
+// If the total size isn't known then we will aim for this many bytes free (1PB)
+const unknownFreeBytes = 1 << 50
+
 // Statfs returns into about the filing system if known
 //
 // The values will be -1 if they aren't known
@@ -488,10 +519,7 @@ func (vfs *VFS) Statfs() (total, used, free int64) {
 	defer vfs.usageMu.Unlock()
 	total, used, free = -1, -1, -1
 	doAbout := vfs.f.Features().About
-	if doAbout == nil {
-		return
-	}
-	if vfs.usageTime.IsZero() || time.Since(vfs.usageTime) >= vfs.Opt.DirCacheTime {
+	if doAbout != nil && (vfs.usageTime.IsZero() || time.Since(vfs.usageTime) >= vfs.Opt.DirCacheTime) {
 		var err error
 		vfs.usage, err = doAbout(context.TODO())
 		vfs.usageTime = time.Now()
@@ -510,15 +538,7 @@ func (vfs *VFS) Statfs() (total, used, free int64) {
 		if u.Used != nil {
 			used = *u.Used
 		}
-		if total < 0 && free >= 0 && used >= 0 {
-			total = free + used
-		}
-		if free < 0 && total >= 0 && used >= 0 {
-			free = total - used
-		}
-		if used < 0 && total >= 0 && free >= 0 {
-			used = total - free
-		}
 	}
+	total, used, free = fillInMissingSizes(total, used, free, unknownFreeBytes)
 	return
 }


Test patch:
diff --git a/backend/googlephotos/pattern_test.go b/backend/googlephotos/pattern_test.go
index 2fc2648e1..f7aafdd3c 100644
--- a/backend/googlephotos/pattern_test.go
+++ b/backend/googlephotos/pattern_test.go
@@ -59,6 +59,11 @@ func (f *testLister) dirTime() time.Time {
 	return startTime
 }
 
+// mock startYear for testing
+func (f *testLister) startYear() int {
+	return 2000
+}
+
 func TestPatternMatch(t *testing.T) {
 	for testNumber, test := range []struct {
 		// input
diff --git a/fs/filter/filter_test.go b/fs/filter/filter_test.go
index 0d1b4e90f..399d4616e 100644
--- a/fs/filter/filter_test.go
+++ b/fs/filter/filter_test.go
@@ -65,6 +65,29 @@ func TestNewFilterForbiddenMixOfFilesFromAndFilterRule(t *testing.T) {
 	require.Contains(t, err.Error(), "The usage of --files-from overrides all other filters")
 }
 
+func TestNewFilterForbiddenMixOfFilesFromRawAndFilterRule(t *testing.T) {
+	Opt := DefaultOpt
+
+	// Set up the input
+	Opt.FilterRule = []string{"- filter1", "- filter1b"}
+	Opt.FilesFromRaw = []string{testFile(t, "#comment\nfiles1\nfiles2\n")}
+
+	rm := func(p string) {
+		err := os.Remove(p)
+		if err != nil {
+			t.Logf("error removing %q: %v", p, err)
+		}
+	}
+	// Reset the input
+	defer func() {
+		rm(Opt.FilesFromRaw[0])
+	}()
+
+	_, err := NewFilter(&Opt)
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "The usage of --files-from-raw overrides all other filters")
+}
+
 func TestNewFilterWithFilesFromAlone(t *testing.T) {
 	Opt := DefaultOpt
 
@@ -93,6 +116,34 @@ func TestNewFilterWithFilesFromAlone(t *testing.T) {
 	}
 }
 
+func TestNewFilterWithFilesFromRaw(t *testing.T) {
+	Opt := DefaultOpt
+
+	// Set up the input
+	Opt.FilesFromRaw = []string{testFile(t, "#comment\nfiles1\nfiles2\n")}
+
+	rm := func(p string) {
+		err := os.Remove(p)
+		if err != nil {
+			t.Logf("error removing %q: %v", p, err)
+		}
+	}
+	// Reset the input
+	defer func() {
+		rm(Opt.FilesFromRaw[0])
+	}()
+
+	f, err := NewFilter(&Opt)
+	require.NoError(t, err)
+	assert.Len(t, f.files, 3)
+	for _, name := range []string{"#comment", "files1", "files2"} {
+		_, ok := f.files[name]
+		if !ok {
+			t.Errorf("Didn't find file %q in f.files", name)
+		}
+	}
+}
+
 func TestNewFilterFullExceptFilesFromOpt(t *testing.T) {
 	Opt := DefaultOpt
 
@@ -517,7 +568,7 @@ func TestFilterAddDirRuleOrFileRule(t *testing.T) {
 	}
 }
 
-func testFilterForEachLine(t *testing.T, useStdin bool) {
+func testFilterForEachLine(t *testing.T, useStdin, raw bool) {
 	file := testFile(t, `; comment
 one
 # another comment
@@ -546,20 +597,33 @@ five
 		}()
 		fileName = "-"
 	}
-	err := forEachLine(fileName, func(s string) error {
+	err := forEachLine(fileName, raw, func(s string) error {
 		lines = append(lines, s)
 		return nil
 	})
 	require.NoError(t, err)
-	assert.Equal(t, "one,two,three,four,five,six", strings.Join(lines, ","))
+	if raw {
+		assert.Equal(t, "; comment,one,# another comment,,,two, # indented comment,three  ,four    ,five,  six  ",
+			strings.Join(lines, ","))
+	} else {
+		assert.Equal(t, "one,two,three,four,five,six", strings.Join(lines, ","))
+	}
 }
 
 func TestFilterForEachLine(t *testing.T) {
-	testFilterForEachLine(t, false)
+	testFilterForEachLine(t, false, false)
 }
 
 func TestFilterForEachLineStdin(t *testing.T) {
-	testFilterForEachLine(t, true)
+	testFilterForEachLine(t, true, false)
+}
+
+func TestFilterForEachLineWithRaw(t *testing.T) {
+	testFilterForEachLine(t, false, true)
+}
+
+func TestFilterForEachLineStdinWithRaw(t *testing.T) {
+	testFilterForEachLine(t, true, true)
 }
 
 func TestFilterMatchesFromDocs(t *testing.T) {
diff --git a/vfs/vfs_test.go b/vfs/vfs_test.go
index ed3e2faf0..4216a3a89 100644
--- a/vfs/vfs_test.go
+++ b/vfs/vfs_test.go
@@ -4,6 +4,7 @@ package vfs
 
 import (
 	"context"
+	"fmt"
 	"io"
 	"os"
 	"testing"
@@ -311,3 +312,51 @@ func TestVFSStatfs(t *testing.T) {
 	assert.Equal(t, free, free2)
 	assert.Equal(t, oldTime, vfs.usageTime)
 }
+
+func TestFillInMissingSizes(t *testing.T) {
+	const unknownFree = 10
+	for _, test := range []struct {
+		total, free, used             int64
+		wantTotal, wantUsed, wantFree int64
+	}{
+		{
+			total: 20, free: 5, used: 15,
+			wantTotal: 20, wantFree: 5, wantUsed: 15,
+		},
+		{
+			total: 20, free: 5, used: -1,
+			wantTotal: 20, wantFree: 5, wantUsed: 15,
+		},
+		{
+			total: 20, free: -1, used: 15,
+			wantTotal: 20, wantFree: 5, wantUsed: 15,
+		},
+		{
+			total: 20, free: -1, used: -1,
+			wantTotal: 20, wantFree: 20, wantUsed: 0,
+		},
+		{
+			total: -1, free: 5, used: 15,
+			wantTotal: 20, wantFree: 5, wantUsed: 15,
+		},
+		{
+			total: -1, free: 15, used: -1,
+			wantTotal: 15, wantFree: 15, wantUsed: 0,
+		},
+		{
+			total: -1, free: -1, used: 15,
+			wantTotal: 25, wantFree: 10, wantUsed: 15,
+		},
+		{
+			total: -1, free: -1, used: -1,
+			wantTotal: 10, wantFree: 10, wantUsed: 0,
+		},
+	} {
+		t.Run(fmt.Sprintf("total=%d,free=%d,used=%d", test.total, test.free, test.used), func(t *testing.T) {
+			gotTotal, gotUsed, gotFree := fillInMissingSizes(test.total, test.used, test.free, unknownFree)
+			assert.Equal(t, test.wantTotal, gotTotal, "total")
+			assert.Equal(t, test.wantUsed, gotUsed, "used")
+			assert.Equal(t, test.wantFree, gotFree, "free")
+		})
+	}
+}