aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--fs/license19
-rw-r--r--fs/operations/check.go626
-rw-r--r--fs/operations/check_test.go617
-rw-r--r--fs/operations/copy.go422
-rw-r--r--fs/operations/copy_test.go534
-rw-r--r--fs/operations/dedupe.go506
-rw-r--r--fs/operations/dedupe_test.go280
-rw-r--r--fs/operations/listdirsorted_test.go126
-rw-r--r--fs/operations/logger.go384
-rw-r--r--fs/operations/lsjson.go348
-rw-r--r--fs/operations/lsjson_test.go406
-rw-r--r--fs/operations/multithread.go380
-rw-r--r--fs/operations/multithread_test.go333
-rw-r--r--fs/operations/operations.go2742
-rw-r--r--fs/operations/operations_internal_test.go44
-rw-r--r--fs/operations/operations_test.go1968
-rw-r--r--fs/operations/operationsflags/operationsflags.go147
-rw-r--r--fs/operations/operationsflags/operationsflags.md40
-rw-r--r--fs/operations/rc.go1016
-rw-r--r--fs/operations/rc_test.go892
-rw-r--r--fs/operations/reopen.go346
-rw-r--r--fs/operations/reopen_test.go429
-rw-r--r--fs/sync/pipe.go237
-rw-r--r--fs/sync/pipe_test.go290
-rw-r--r--fs/sync/rc.go61
-rw-r--r--fs/sync/rc_test.go97
-rw-r--r--fs/sync/sync.go1423
-rw-r--r--fs/sync/sync_test.go3108
-rw-r--r--fs/sync/sync_transform_test.go515
-rw-r--r--magefile.go15
30 files changed, 18351 insertions, 0 deletions
diff --git a/fs/license b/fs/license
new file mode 100644
index 0000000..bc452e3
--- /dev/null
+++ b/fs/license
@@ -0,0 +1,19 @@
+Copyright (C) 2012 by Nick Craig-Wood http://www.craig-wood.com/nick/
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/fs/operations/check.go b/fs/operations/check.go
new file mode 100644
index 0000000..d4d1eb3
--- /dev/null
+++ b/fs/operations/check.go
@@ -0,0 +1,626 @@
+package operations
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "regexp"
+ "strings"
+ "sync"
+ "sync/atomic"
+
+ "github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/fs/accounting"
+ "github.com/rclone/rclone/fs/filter"
+ "github.com/rclone/rclone/fs/fserrors"
+ "github.com/rclone/rclone/fs/hash"
+ "github.com/rclone/rclone/fs/march"
+ "github.com/rclone/rclone/lib/readers"
+ "golang.org/x/text/unicode/norm"
+)
+
+// checkFn is the type of the checking function used in CheckFn()
+//
+// It should check the two objects (a, b) and return if they differ
+// and whether the hash was used.
+//
+// If there are differences then this should Errorf the difference and
+// the reason but return with err = nil. It should not CountError in
+// this case.
+type checkFn func(ctx context.Context, a, b fs.Object) (differ bool, noHash bool, err error)
+
+// CheckOpt contains options for the Check functions
+type CheckOpt struct {
+ Fdst, Fsrc fs.Fs // fses to check
+ Check checkFn // function to use for checking
+ OneWay bool // one way only?
+ Combined io.Writer // a file with file names with leading sigils
+ MissingOnSrc io.Writer // files only in the destination
+ MissingOnDst io.Writer // files only in the source
+ Match io.Writer // matching files
+ Differ io.Writer // differing files
+ Error io.Writer // files with errors of some kind
+}
+
+// checkMarch is used to march over two Fses in the same way as
+// sync/copy
+type checkMarch struct {
+ ctx context.Context
+ ioMu sync.Mutex
+ wg sync.WaitGroup
+ tokens chan struct{}
+ differences atomic.Int32
+ noHashes atomic.Int32
+ srcFilesMissing atomic.Int32
+ dstFilesMissing atomic.Int32
+ matches atomic.Int32
+ opt CheckOpt
+}
+
+// report outputs the fileName to out if required and to the combined log
+func (c *checkMarch) report(o fs.DirEntry, out io.Writer, sigil rune) {
+ c.reportFilename(o.String(), out, sigil)
+}
+
+func (c *checkMarch) reportFilename(filename string, out io.Writer, sigil rune) {
+ if out != nil {
+ SyncFprintf(out, "%s\n", filename)
+ }
+ if c.opt.Combined != nil {
+ SyncFprintf(c.opt.Combined, "%c %s\n", sigil, filename)
+ }
+}
+
+// DstOnly have an object which is in the destination only
+func (c *checkMarch) DstOnly(dst fs.DirEntry) (recurse bool) {
+ switch dst.(type) {
+ case fs.Object:
+ if c.opt.OneWay {
+ return false
+ }
+ err := fmt.Errorf("file not in %v", c.opt.Fsrc)
+ fs.Errorf(dst, "%v", err)
+ _ = fs.CountError(c.ctx, err)
+ c.differences.Add(1)
+ c.srcFilesMissing.Add(1)
+ c.report(dst, c.opt.MissingOnSrc, '-')
+ case fs.Directory:
+ // Do the same thing to the entire contents of the directory
+ if c.opt.OneWay {
+ return false
+ }
+ return true
+ default:
+ panic("Bad object in DirEntries")
+ }
+ return false
+}
+
+// SrcOnly have an object which is in the source only
+func (c *checkMarch) SrcOnly(src fs.DirEntry) (recurse bool) {
+ switch src.(type) {
+ case fs.Object:
+ err := fmt.Errorf("file not in %v", c.opt.Fdst)
+ fs.Errorf(src, "%v", err)
+ _ = fs.CountError(c.ctx, err)
+ c.differences.Add(1)
+ c.dstFilesMissing.Add(1)
+ c.report(src, c.opt.MissingOnDst, '+')
+ case fs.Directory:
+ // Do the same thing to the entire contents of the directory
+ return true
+ default:
+ panic("Bad object in DirEntries")
+ }
+ return false
+}
+
+// check to see if two objects are identical using the check function
+func (c *checkMarch) checkIdentical(ctx context.Context, dst, src fs.Object) (differ bool, noHash bool, err error) {
+ ci := fs.GetConfig(ctx)
+ tr := accounting.Stats(ctx).NewCheckingTransfer(src, "checking")
+ defer func() {
+ tr.Done(ctx, err)
+ }()
+ if sizeDiffers(ctx, src, dst) {
+ err = fmt.Errorf("sizes differ")
+ fs.Errorf(src, "%v", err)
+ return true, false, nil
+ }
+ if ci.SizeOnly {
+ return false, false, nil
+ }
+ return c.opt.Check(ctx, dst, src)
+}
+
+// Match is called when src and dst are present, so sync src to dst
+func (c *checkMarch) Match(ctx context.Context, dst, src fs.DirEntry) (recurse bool) {
+ switch srcX := src.(type) {
+ case fs.Object:
+ dstX, ok := dst.(fs.Object)
+ if ok {
+ if SkipDestructive(ctx, src, "check") {
+ return false
+ }
+ c.wg.Add(1)
+ c.tokens <- struct{}{} // put a token to limit concurrency
+ go func() {
+ defer func() {
+ <-c.tokens // get the token back to free up a slot
+ c.wg.Done()
+ }()
+ differ, noHash, err := c.checkIdentical(ctx, dstX, srcX)
+ if err != nil {
+ fs.Errorf(src, "%v", err)
+ _ = fs.CountError(ctx, err)
+ c.report(src, c.opt.Error, '!')
+ } else if differ {
+ c.differences.Add(1)
+ err := errors.New("files differ")
+ // the checkFn has already logged the reason
+ _ = fs.CountError(ctx, err)
+ c.report(src, c.opt.Differ, '*')
+ } else {
+ c.matches.Add(1)
+ c.report(src, c.opt.Match, '=')
+ if noHash {
+ c.noHashes.Add(1)
+ fs.Debugf(dstX, "OK - could not check hash")
+ } else {
+ fs.Debugf(dstX, "OK")
+ }
+ }
+ }()
+ } else {
+ err := fmt.Errorf("is file on %v but directory on %v", c.opt.Fsrc, c.opt.Fdst)
+ fs.Errorf(src, "%v", err)
+ _ = fs.CountError(ctx, err)
+ c.differences.Add(1)
+ c.dstFilesMissing.Add(1)
+ c.report(src, c.opt.MissingOnDst, '+')
+ }
+ case fs.Directory:
+ // Do the same thing to the entire contents of the directory
+ _, ok := dst.(fs.Directory)
+ if ok {
+ return true
+ }
+ err := fmt.Errorf("is file on %v but directory on %v", c.opt.Fdst, c.opt.Fsrc)
+ fs.Errorf(dst, "%v", err)
+ _ = fs.CountError(ctx, err)
+ c.differences.Add(1)
+ c.srcFilesMissing.Add(1)
+ c.report(dst, c.opt.MissingOnSrc, '-')
+
+ default:
+ panic("Bad object in DirEntries")
+ }
+ return false
+}
+
+// CheckFn checks the files in fsrc and fdst according to Size and
+// hash using checkFunction on each file to check the hashes.
+//
+// checkFunction sees if dst and src are identical
+//
+// it returns true if differences were found
+// it also returns whether it couldn't be hashed
+func CheckFn(ctx context.Context, opt *CheckOpt) error {
+ ci := fs.GetConfig(ctx)
+ if opt.Check == nil {
+ return errors.New("internal error: nil check function")
+ }
+ c := &checkMarch{
+ ctx: ctx,
+ tokens: make(chan struct{}, ci.Checkers),
+ opt: *opt,
+ }
+
+ // set up a march over fdst and fsrc
+ m := &march.March{
+ Ctx: ctx,
+ Fdst: c.opt.Fdst,
+ Fsrc: c.opt.Fsrc,
+ Dir: "",
+ Callback: c,
+ NoTraverse: ci.NoTraverse,
+ NoUnicodeNormalization: ci.NoUnicodeNormalization,
+ }
+ fs.Debugf(c.opt.Fdst, "Waiting for checks to finish")
+ err := m.Run(ctx)
+ c.wg.Wait() // wait for background go-routines
+
+ return c.reportResults(ctx, err)
+}
+
+func (c *checkMarch) reportResults(ctx context.Context, err error) error {
+ if c.dstFilesMissing.Load() > 0 {
+ fs.Logf(c.opt.Fdst, "%d files missing", c.dstFilesMissing.Load())
+ }
+ if c.srcFilesMissing.Load() > 0 {
+ entity := "files"
+ if c.opt.Fsrc == nil {
+ entity = "hashes"
+ }
+ fs.Logf(c.opt.Fsrc, "%d %s missing", c.srcFilesMissing.Load(), entity)
+ }
+
+ fs.Logf(c.opt.Fdst, "%d differences found", c.differences.Load())
+ if errs := accounting.Stats(ctx).GetErrors(); errs > 0 {
+ fs.Logf(c.opt.Fdst, "%d errors while checking", errs)
+ }
+ if c.noHashes.Load() > 0 {
+ fs.Logf(c.opt.Fdst, "%d hashes could not be checked", c.noHashes.Load())
+ }
+ if c.matches.Load() > 0 {
+ fs.Logf(c.opt.Fdst, "%d matching files", c.matches.Load())
+ }
+ if err != nil {
+ return err
+ }
+ if c.differences.Load() > 0 {
+ // Return an already counted error so we don't double count this error too
+ err = fserrors.FsError(fmt.Errorf("%d differences found", c.differences.Load()))
+ fserrors.Count(err)
+ return err
+ }
+ return nil
+}
+
+// Check the files in fsrc and fdst according to Size and hash
+func Check(ctx context.Context, opt *CheckOpt) error {
+ optCopy := *opt
+ optCopy.Check = func(ctx context.Context, dst, src fs.Object) (differ bool, noHash bool, err error) {
+ same, ht, err := CheckHashes(ctx, src, dst)
+ if err != nil {
+ return true, false, err
+ }
+ if ht == hash.None {
+ return false, true, nil
+ }
+ if !same {
+ err = fmt.Errorf("%v differ", ht)
+ fs.Errorf(src, "%v", err)
+ return true, false, nil
+ }
+ return false, false, nil
+ }
+
+ return CheckFn(ctx, &optCopy)
+}
+
+// CheckEqualReaders checks to see if in1 and in2 have the same
+// content when read.
+//
+// it returns true if no differences were found
+func CheckEqualReaders(in1, in2 io.Reader) (equal bool, err error) {
+ const bufSize = 64 * 1024
+ buf1 := make([]byte, bufSize)
+ buf2 := make([]byte, bufSize)
+ for {
+ n1, err1 := readers.ReadFill(in1, buf1)
+ n2, err2 := readers.ReadFill(in2, buf2)
+ // check errors
+ if err1 != nil && err1 != io.EOF {
+ return false, err1
+ } else if err2 != nil && err2 != io.EOF {
+ return false, err2
+ }
+ // err1 && err2 are nil or io.EOF here
+ // process the data
+ if n1 != n2 || !bytes.Equal(buf1[:n1], buf2[:n2]) {
+ return false, nil
+ }
+ // if both streams finished the we have finished
+ if err1 == io.EOF && err2 == io.EOF {
+ break
+ }
+ }
+ return true, nil
+}
+
+// CheckIdenticalDownload checks to see if dst and src are identical
+// by reading all their bytes if necessary.
+//
+// it returns true if no differences were found
+func CheckIdenticalDownload(ctx context.Context, src, dst fs.Object) (equal bool, err error) {
+ ci := fs.GetConfig(ctx)
+ err = Retry(ctx, src, ci.LowLevelRetries, func() error {
+ equal, err = checkIdenticalDownload(ctx, src, dst)
+ return err
+ })
+ return equal, err
+}
+
+// Does the work for CheckIdenticalDownload
+func checkIdenticalDownload(ctx context.Context, src, dst fs.Object) (equal bool, err error) {
+ var in1, in2 io.ReadCloser
+ in1, err = Open(ctx, dst)
+ if err != nil {
+ return false, fmt.Errorf("failed to open %q: %w", dst, err)
+ }
+ tr1 := accounting.Stats(ctx).NewTransfer(dst, nil)
+ defer func() {
+ tr1.Done(ctx, nil) // error handling is done by the caller
+ }()
+ in1 = tr1.Account(ctx, in1).WithBuffer() // account and buffer the transfer
+
+ in2, err = Open(ctx, src)
+ if err != nil {
+ return false, fmt.Errorf("failed to open %q: %w", src, err)
+ }
+ tr2 := accounting.Stats(ctx).NewTransfer(dst, nil)
+ defer func() {
+ tr2.Done(ctx, nil) // error handling is done by the caller
+ }()
+ in2 = tr2.Account(ctx, in2).WithBuffer() // account and buffer the transfer
+
+ // To assign err variable before defer.
+ equal, err = CheckEqualReaders(in1, in2)
+ return
+}
+
+// CheckDownload checks the files in fsrc and fdst according to Size
+// and the actual contents of the files.
+func CheckDownload(ctx context.Context, opt *CheckOpt) error {
+ optCopy := *opt
+ optCopy.Check = func(ctx context.Context, dst, src fs.Object) (differ bool, noHash bool, err error) {
+ same, err := CheckIdenticalDownload(ctx, src, dst)
+ if err != nil {
+ return true, true, fmt.Errorf("failed to download: %w", err)
+ }
+ if !same {
+ err = errors.New("contents differ")
+ fs.Errorf(src, "%v", err)
+ return true, false, nil
+ }
+ return false, false, nil
+ }
+ return CheckFn(ctx, &optCopy)
+}
+
+// ApplyTransforms handles --no-unicode-normalization and --ignore-case-sync for CheckSum
+// so that it matches behavior of Check (where it's handled by March)
+func ApplyTransforms(ctx context.Context, s string) string {
+ ci := fs.GetConfig(ctx)
+ return ToNormal(s, !ci.NoUnicodeNormalization, ci.IgnoreCaseSync)
+}
+
+// ToNormal normalizes case and unicode form and returns the transformed string.
+// It is similar to ApplyTransforms but does not use a context.
+// If normUnicode == true, s will be transformed to NFC.
+// If normCase == true, s will be transformed to lowercase.
+// If both are true, both transformations will be performed.
+func ToNormal(s string, normUnicode, normCase bool) string {
+ if normUnicode {
+ s = norm.NFC.String(s)
+ }
+ if normCase {
+ s = strings.ToLower(s)
+ }
+ return s
+}
+
+// CheckSum checks filesystem hashes against a SUM file
+func CheckSum(ctx context.Context, fsrc, fsum fs.Fs, sumFile string, hashType hash.Type, opt *CheckOpt, download bool) error {
+ var options CheckOpt
+ if opt != nil {
+ options = *opt
+ } else {
+ // default options for hashsum -c
+ options.Combined = os.Stdout
+ }
+ // CheckSum treats Fsrc and Fdst specially:
+ options.Fsrc = nil // no file system here, corresponds to the sum list
+ options.Fdst = fsrc // denotes the file system to check
+ opt = &options // override supplied argument
+
+ if !download && (hashType == hash.None || !opt.Fdst.Hashes().Contains(hashType)) {
+ return fmt.Errorf("%s: hash type is not supported by file system: %s", hashType, opt.Fdst)
+ }
+
+ if sumFile == "" {
+ return fmt.Errorf("not a sum file: %s", fsum)
+ }
+ sumObj, err := fsum.NewObject(ctx, sumFile)
+ if err != nil {
+ return fmt.Errorf("cannot open sum file: %w", err)
+ }
+ hashes, err := ParseSumFile(ctx, sumObj)
+ if err != nil {
+ return fmt.Errorf("failed to parse sum file: %w", err)
+ }
+
+ ci := fs.GetConfig(ctx)
+ c := &checkMarch{
+ ctx: ctx,
+ tokens: make(chan struct{}, ci.Checkers),
+ opt: *opt,
+ }
+ lastErr := ListFn(ctx, opt.Fdst, func(obj fs.Object) {
+ c.checkSum(ctx, obj, download, hashes, hashType)
+ })
+ c.wg.Wait() // wait for background go-routines
+
+ // make census of unhandled sums
+ fi := filter.GetConfig(ctx)
+ for filename, hash := range hashes {
+ if hash == "" { // the sum has been successfully consumed
+ continue
+ }
+ if !fi.IncludeRemote(filename) { // the file was filtered out
+ continue
+ }
+ // filesystem missed the file, sum wasn't consumed
+ err := fmt.Errorf("file not in %v", opt.Fdst)
+ fs.Errorf(filename, "%v", err)
+ _ = fs.CountError(ctx, err)
+ if lastErr == nil {
+ lastErr = err
+ }
+ c.dstFilesMissing.Add(1)
+ c.reportFilename(filename, opt.MissingOnDst, '+')
+ }
+
+ return c.reportResults(ctx, lastErr)
+}
+
+// checkSum checks single object against golden hashes
+func (c *checkMarch) checkSum(ctx context.Context, obj fs.Object, download bool, hashes HashSums, hashType hash.Type) {
+ normalizedRemote := ApplyTransforms(ctx, obj.Remote())
+ c.ioMu.Lock()
+ sumHash, sumFound := hashes[normalizedRemote]
+ hashes[normalizedRemote] = "" // mark sum as consumed
+ c.ioMu.Unlock()
+
+ if !sumFound && c.opt.OneWay {
+ return
+ }
+
+ var err error
+ tr := accounting.Stats(ctx).NewCheckingTransfer(obj, "hashing")
+ defer tr.Done(ctx, err)
+
+ if !sumFound {
+ err = errors.New("sum not found")
+ _ = fs.CountError(ctx, err)
+ fs.Errorf(obj, "%v", err)
+ c.differences.Add(1)
+ c.srcFilesMissing.Add(1)
+ c.report(obj, c.opt.MissingOnSrc, '-')
+ return
+ }
+
+ if !download {
+ var objHash string
+ objHash, err = obj.Hash(ctx, hashType)
+ c.matchSum(ctx, sumHash, objHash, obj, err, hashType)
+ return
+ }
+
+ c.wg.Add(1)
+ c.tokens <- struct{}{} // put a token to limit concurrency
+ go func() {
+ var (
+ objHash string
+ err error
+ in io.ReadCloser
+ )
+ defer func() {
+ c.matchSum(ctx, sumHash, objHash, obj, err, hashType)
+ <-c.tokens // get the token back to free up a slot
+ c.wg.Done()
+ }()
+ if in, err = Open(ctx, obj); err != nil {
+ return
+ }
+ tr := accounting.Stats(ctx).NewTransfer(obj, nil)
+ in = tr.Account(ctx, in).WithBuffer() // account and buffer the transfer
+ defer func() {
+ tr.Done(ctx, nil) // will close the stream
+ }()
+ hashVals, err2 := hash.StreamTypes(in, hash.NewHashSet(hashType))
+ if err2 != nil {
+ err = err2 // pass to matchSum
+ return
+ }
+ objHash = hashVals[hashType]
+ }()
+}
+
+// matchSum sums up the results of hashsum matching for an object
+func (c *checkMarch) matchSum(ctx context.Context, sumHash, objHash string, obj fs.Object, err error, hashType hash.Type) {
+ switch {
+ case err != nil:
+ _ = fs.CountError(ctx, err)
+ fs.Errorf(obj, "Failed to calculate hash: %v", err)
+ c.report(obj, c.opt.Error, '!')
+ case sumHash == "":
+ err = errors.New("duplicate file")
+ _ = fs.CountError(ctx, err)
+ fs.Errorf(obj, "%v", err)
+ c.report(obj, c.opt.Error, '!')
+ case objHash == "":
+ fs.Debugf(nil, "%v = %s (sum)", hashType, sumHash)
+ fs.Debugf(obj, "%v - could not check hash (%v)", hashType, c.opt.Fdst)
+ c.noHashes.Add(1)
+ c.matches.Add(1)
+ c.report(obj, c.opt.Match, '=')
+ case objHash == sumHash:
+ fs.Debugf(obj, "%v = %s OK", hashType, sumHash)
+ c.matches.Add(1)
+ c.report(obj, c.opt.Match, '=')
+ default:
+ err = errors.New("files differ")
+ _ = fs.CountError(ctx, err)
+ fs.Debugf(nil, "%v = %s (sum)", hashType, sumHash)
+ fs.Debugf(obj, "%v = %s (%v)", hashType, objHash, c.opt.Fdst)
+ fs.Errorf(obj, "%v", err)
+ c.differences.Add(1)
+ c.report(obj, c.opt.Differ, '*')
+ }
+}
+
+// HashSums represents a parsed SUM file
+type HashSums map[string]string
+
+// ParseSumFile parses a hash SUM file and returns hashes as a map
+func ParseSumFile(ctx context.Context, sumFile fs.Object) (HashSums, error) {
+ rd, err := Open(ctx, sumFile)
+ if err != nil {
+ return nil, err
+ }
+ parser := bufio.NewReader(rd)
+
+ const maxWarn = 3
+ numWarn := 0
+
+ re := regexp.MustCompile(`^([^ ]+) [ *](.+)$`)
+ hashes := HashSums{}
+ for lineNo := 0; true; lineNo++ {
+ lineBytes, _, err := parser.ReadLine()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+ line := string(lineBytes)
+ if line == "" {
+ continue
+ }
+
+ fields := re.FindStringSubmatch(ApplyTransforms(ctx, line))
+ if fields == nil {
+ numWarn++
+ if numWarn <= maxWarn {
+ fs.Logf(sumFile, "improperly formatted checksum line %d", lineNo)
+ }
+ continue
+ }
+
+ sum, file := fields[1], fields[2]
+ if hashes[file] != "" {
+ numWarn++
+ if numWarn <= maxWarn {
+ fs.Logf(sumFile, "duplicate file on checksum line %d", lineNo)
+ }
+ continue
+ }
+
+ // We've standardised on lower case checksums in rclone internals.
+ hashes[file] = strings.ToLower(sum)
+ }
+
+ if numWarn > maxWarn {
+ fs.Logf(sumFile, "%d warning(s) suppressed...", numWarn-maxWarn)
+ }
+ if err = rd.Close(); err != nil {
+ return nil, err
+ }
+ return hashes, nil
+}
diff --git a/fs/operations/check_test.go b/fs/operations/check_test.go
new file mode 100644
index 0000000..e6c3092
--- /dev/null
+++ b/fs/operations/check_test.go
@@ -0,0 +1,617 @@
+package operations_test
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "sort"
+ "strings"
+ "testing"
+
+ "github.com/rclone/rclone/cmd/bisync/bilib"
+ "github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/fs/accounting"
+ "github.com/rclone/rclone/fs/hash"
+ "github.com/rclone/rclone/fs/operations"
+ "github.com/rclone/rclone/fstest"
+ "github.com/rclone/rclone/lib/readers"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/text/unicode/norm"
+)
+
+func testCheck(t *testing.T, checkFunction func(ctx context.Context, opt *operations.CheckOpt) error) {
+ r := fstest.NewRun(t)
+ ctx := context.Background()
+ ci := fs.GetConfig(ctx)
+
+ addBuffers := func(opt *operations.CheckOpt) {
+ opt.Combined = new(bytes.Buffer)
+ opt.MissingOnSrc = new(bytes.Buffer)
+ opt.MissingOnDst = new(bytes.Buffer)
+ opt.Match = new(bytes.Buffer)
+ opt.Differ = new(bytes.Buffer)
+ opt.Error = new(bytes.Buffer)
+ }
+
+ sortLines := func(in string) []string {
+ if in == "" {
+ return []string{}
+ }
+ lines := strings.Split(in, "\n")
+ sort.Strings(lines)
+ return lines
+ }
+
+ checkBuffer := func(name string, want map[string]string, out io.Writer) {
+ expected := want[name]
+ buf, ok := out.(*bytes.Buffer)
+ require.True(t, ok)
+ assert.Equal(t, sortLines(expected), sortLines(buf.String()), name)
+ }
+
+ checkBuffers := func(opt *operations.CheckOpt, want map[string]string) {
+ checkBuffer("combined", want, opt.Combined)
+ checkBuffer("missingonsrc", want, opt.MissingOnSrc)
+ checkBuffer("missingondst", want, opt.MissingOnDst)
+ checkBuffer("match", want, opt.Match)
+ checkBuffer("differ", want, opt.Differ)
+ checkBuffer("error", want, opt.Error)
+ }
+
+ check := func(i int, wantErrors int64, wantChecks int64, oneway bool, wantOutput map[string]string) {
+ t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+ accounting.GlobalStats().ResetCounters()
+ opt := operations.CheckOpt{
+ Fdst: r.Fremote,
+ Fsrc: r.Flocal,
+ OneWay: oneway,
+ }
+ addBuffers(&opt)
+ var err error
+ buf := bilib.CaptureOutput(func() {
+ err = checkFunction(ctx, &opt)
+ })
+ gotErrors := accounting.GlobalStats().GetErrors()
+ gotChecks := accounting.GlobalStats().GetChecks()
+ if wantErrors == 0 && err != nil {
+ t.Errorf("%d: Got error when not expecting one: %v", i, err)
+ }
+ if wantErrors != 0 && err == nil {
+ t.Errorf("%d: No error when expecting one", i)
+ }
+ if wantErrors != gotErrors {
+ t.Errorf("%d: Expecting %d errors but got %d", i, wantErrors, gotErrors)
+ }
+ if gotChecks > 0 && !strings.Contains(string(buf), "matching files") {
+ t.Errorf("%d: Total files matching line missing", i)
+ }
+ if wantChecks != gotChecks {
+ t.Errorf("%d: Expecting %d total matching files but got %d", i, wantChecks, gotChecks)
+ }
+ checkBuffers(&opt, wantOutput)
+ })
+ }
+
+ file1 := r.WriteBoth(ctx, "rutabaga", "is tasty", t3)
+ r.CheckRemoteItems(t, file1)
+ r.CheckLocalItems(t, file1)
+ check(1, 0, 1, false, map[string]string{
+ "combined": "= rutabaga\n",
+ "missingonsrc": "",
+ "missingondst": "",
+ "match": "rutabaga\n",
+ "differ": "",
+ "error": "",
+ })
+
+ file2 := r.WriteFile("potato2", "------------------------------------------------------------", t1)
+ r.CheckLocalItems(t, file1, file2)
+ check(2, 1, 1, false, map[string]string{
+ "combined": "+ potato2\n= rutabaga\n",
+ "missingonsrc": "",
+ "missingondst": "potato2\n",
+ "match": "rutabaga\n",
+ "differ": "",
+ "error": "",
+ })
+
+ file3 := r.WriteObject(ctx, "empty space", "-", t2)
+ r.CheckRemoteItems(t, file1, file3)
+ check(3, 2, 1, false, map[string]string{
+ "combined": "- empty space\n+ potato2\n= rutabaga\n",
+ "missingonsrc": "empty space\n",
+ "missingondst": "potato2\n",
+ "match": "rutabaga\n",
+ "differ": "",
+ "error": "",
+ })
+
+ file2r := file2
+ if ci.SizeOnly {
+ file2r = r.WriteObject(ctx, "potato2", "--Some-Differences-But-Size-Only-Is-Enabled-----------------", t1)
+ } else {
+ r.WriteObject(ctx, "potato2", "------------------------------------------------------------", t1)
+ }
+ r.CheckRemoteItems(t, file1, file2r, file3)
+ check(4, 1, 2, false, map[string]string{
+ "combined": "- empty space\n= potato2\n= rutabaga\n",
+ "missingonsrc": "empty space\n",
+ "missingondst": "",
+ "match": "rutabaga\npotato2\n",
+ "differ": "",
+ "error": "",
+ })
+
+ file3r := file3
+ file3l := r.WriteFile("empty space", "DIFFER", t2)
+ r.CheckLocalItems(t, file1, file2, file3l)
+ check(5, 1, 3, false, map[string]string{
+ "combined": "* empty space\n= potato2\n= rutabaga\n",
+ "missingonsrc": "",
+ "missingondst": "",
+ "match": "potato2\nrutabaga\n",
+ "differ": "empty space\n",
+ "error": "",
+ })
+
+ file4 := r.WriteObject(ctx, "remotepotato", "------------------------------------------------------------", t1)
+ r.CheckRemoteItems(t, file1, file2r, file3r, file4)
+ check(6, 2, 3, false, map[string]string{
+ "combined": "* empty space\n= potato2\n= rutabaga\n- remotepotato\n",
+ "missingonsrc": "remotepotato\n",
+ "missingondst": "",
+ "match": "potato2\nrutabaga\n",
+ "differ": "empty space\n",
+ "error": "",
+ })
+ check(7, 1, 3, true, map[string]string{
+ "combined": "* empty space\n= potato2\n= rutabaga\n",
+ "missingonsrc": "",
+ "missingondst": "",
+ "match": "potato2\nrutabaga\n",
+ "differ": "empty space\n",
+ "error": "",
+ })
+}
+
+func TestCheck(t *testing.T) {
+ testCheck(t, operations.Check)
+}
+
+func TestCheckFsError(t *testing.T) {
+ ctx := context.Background()
+ dstFs, err := fs.NewFs(ctx, "nonexistent")
+ if err != nil {
+ t.Fatal(err)
+ }
+ srcFs, err := fs.NewFs(ctx, "nonexistent")
+ if err != nil {
+ t.Fatal(err)
+ }
+ opt := operations.CheckOpt{
+ Fdst: dstFs,
+ Fsrc: srcFs,
+ OneWay: false,
+ }
+ err = operations.Check(ctx, &opt)
+ require.Error(t, err)
+}
+
+func TestCheckDownload(t *testing.T) {
+ testCheck(t, operations.CheckDownload)
+}
+
+func TestCheckSizeOnly(t *testing.T) {
+ ctx := context.Background()
+ ci := fs.GetConfig(ctx)
+ ci.SizeOnly = true
+ defer func() { ci.SizeOnly = false }()
+ TestCheck(t)
+}
+
+func TestCheckEqualReaders(t *testing.T) {
+ b65a := make([]byte, 65*1024)
+ b65b := make([]byte, 65*1024)
+ b65b[len(b65b)-1] = 1
+ b66 := make([]byte, 66*1024)
+
+ equal, err := operations.CheckEqualReaders(bytes.NewBuffer(b65a), bytes.NewBuffer(b65a))
+ assert.NoError(t, err)
+ assert.Equal(t, equal, true)
+
+ equal, err = operations.CheckEqualReaders(bytes.NewBuffer(b65a), bytes.NewBuffer(b65b))
+ assert.NoError(t, err)
+ assert.Equal(t, equal, false)
+
+ equal, err = operations.CheckEqualReaders(bytes.NewBuffer(b65a), bytes.NewBuffer(b66))
+ assert.NoError(t, err)
+ assert.Equal(t, equal, false)
+
+ equal, err = operations.CheckEqualReaders(bytes.NewBuffer(b66), bytes.NewBuffer(b65a))
+ assert.NoError(t, err)
+ assert.Equal(t, equal, false)
+
+ myErr := errors.New("sentinel")
+ wrap := func(b []byte) io.Reader {
+ r := bytes.NewBuffer(b)
+ e := readers.ErrorReader{Err: myErr}
+ return io.MultiReader(r, e)
+ }
+
+ equal, err = operations.CheckEqualReaders(wrap(b65a), bytes.NewBuffer(b65a))
+ assert.Equal(t, myErr, err)
+ assert.Equal(t, equal, false)
+
+ equal, err = operations.CheckEqualReaders(wrap(b65a), bytes.NewBuffer(b65b))
+ assert.Equal(t, myErr, err)
+ assert.Equal(t, equal, false)
+
+ equal, err = operations.CheckEqualReaders(wrap(b65a), bytes.NewBuffer(b66))
+ assert.Equal(t, myErr, err)
+ assert.Equal(t, equal, false)
+
+ equal, err = operations.CheckEqualReaders(wrap(b66), bytes.NewBuffer(b65a))
+ assert.Equal(t, myErr, err)
+ assert.Equal(t, equal, false)
+
+ equal, err = operations.CheckEqualReaders(bytes.NewBuffer(b65a), wrap(b65a))
+ assert.Equal(t, myErr, err)
+ assert.Equal(t, equal, false)
+
+ equal, err = operations.CheckEqualReaders(bytes.NewBuffer(b65a), wrap(b65b))
+ assert.Equal(t, myErr, err)
+ assert.Equal(t, equal, false)
+
+ equal, err = operations.CheckEqualReaders(bytes.NewBuffer(b65a), wrap(b66))
+ assert.Equal(t, myErr, err)
+ assert.Equal(t, equal, false)
+
+ equal, err = operations.CheckEqualReaders(bytes.NewBuffer(b66), wrap(b65a))
+ assert.Equal(t, myErr, err)
+ assert.Equal(t, equal, false)
+}
+
+func TestParseSumFile(t *testing.T) {
+ r := fstest.NewRun(t)
+ ctx := context.Background()
+
+ const sumFile = "test.sum"
+
+ samples := []struct {
+ hash, sep, name string
+ ok bool
+ }{
+ {"1", " ", "file1", true},
+ {"2", " *", "file2", true},
+ {"3", " ", " file3 ", true},
+ {"4", " ", "\tfile3\t", true},
+ {"5", " ", "file5", false},
+ {"6", "\t", "file6", false},
+ {"7", " \t", " file7 ", false},
+ {"", " ", "file8", false},
+ {"", "", "file9", false},
+ }
+
+ for _, eol := range []string{"\n", "\r\n"} {
+ data := &bytes.Buffer{}
+ wantNum := 0
+ for _, s := range samples {
+ _, _ = data.WriteString(s.hash + s.sep + s.name + eol)
+ if s.ok {
+ wantNum++
+ }
+ }
+
+ _ = r.WriteObject(ctx, sumFile, data.String(), t1)
+ file := fstest.NewObject(ctx, t, r.Fremote, sumFile)
+ sums, err := operations.ParseSumFile(ctx, file)
+ assert.NoError(t, err)
+
+ assert.Equal(t, wantNum, len(sums))
+ for _, s := range samples {
+ if s.ok {
+ assert.Equal(t, s.hash, sums[s.name])
+ }
+ }
+ }
+}
+
+func testCheckSum(t *testing.T, download bool) {
+ const dataDir = "data"
+ const sumFile = "test.sum"
+
+ hashType := hash.MD5
+ const (
+ testString1 = "Hello, World!"
+ testDigest1 = "65a8e27d8879283831b664bd8b7f0ad4"
+ testDigest1Upper = "65A8E27D8879283831B664BD8B7F0AD4"
+ testString2 = "I am the walrus"
+ testDigest2 = "87396e030ef3f5b35bbf85c0a09a4fb3"
+ testDigest2Mixed = "87396e030EF3f5b35BBf85c0a09a4FB3"
+ )
+
+ type wantType map[string]string
+
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+
+ subRemote := r.FremoteName
+ if !strings.HasSuffix(subRemote, ":") {
+ subRemote += "/"
+ }
+ subRemote += dataDir
+ dataFs, err := fs.NewFs(ctx, subRemote)
+ require.NoError(t, err)
+
+ if !download && !dataFs.Hashes().Contains(hashType) {
+ t.Skipf("%s lacks %s, skipping", dataFs, hashType)
+ }
+
+ makeFile := func(name, content string) fstest.Item {
+ remote := dataDir + "/" + name
+ return r.WriteObject(ctx, remote, content, t1)
+ }
+
+ makeSums := func(sums operations.HashSums) fstest.Item {
+ files := make([]string, 0, len(sums))
+ for name := range sums {
+ files = append(files, name)
+ }
+ sort.Strings(files)
+ buf := &bytes.Buffer{}
+ for _, name := range files {
+ _, _ = fmt.Fprintf(buf, "%s %s\n", sums[name], name)
+ }
+ return r.WriteObject(ctx, sumFile, buf.String(), t1)
+ }
+
+ sortLines := func(in string) []string {
+ if in == "" {
+ return []string{}
+ }
+ lines := strings.Split(in, "\n")
+ sort.Strings(lines)
+ return lines
+ }
+
+ checkResult := func(runNo int, want wantType, name string, out io.Writer) {
+ expected := want[name]
+ buf, ok := out.(*bytes.Buffer)
+ require.True(t, ok)
+ assert.Equal(t, sortLines(expected), sortLines(buf.String()), "wrong %s result in run %d", name, runNo)
+ }
+
+ checkRun := func(runNo, wantChecks, wantErrors int, want wantType) {
+ accounting.GlobalStats().ResetCounters()
+
+ opt := operations.CheckOpt{
+ Combined: new(bytes.Buffer),
+ Match: new(bytes.Buffer),
+ Differ: new(bytes.Buffer),
+ Error: new(bytes.Buffer),
+ MissingOnSrc: new(bytes.Buffer),
+ MissingOnDst: new(bytes.Buffer),
+ }
+ var err error
+ buf := bilib.CaptureOutput(func() {
+ err = operations.CheckSum(ctx, dataFs, r.Fremote, sumFile, hashType, &opt, download)
+ })
+ gotErrors := int(accounting.GlobalStats().GetErrors())
+ if wantErrors == 0 {
+ assert.NoError(t, err, "unexpected error in run %d", runNo)
+ }
+ if wantErrors > 0 {
+ assert.Error(t, err, "no expected error in run %d", runNo)
+ }
+ assert.Equal(t, wantErrors, gotErrors, "wrong error count in run %d", runNo)
+
+ gotChecks := int(accounting.GlobalStats().GetChecks())
+ if wantChecks > 0 || gotChecks > 0 {
+ assert.Contains(t, string(buf), "matching files", "missing matching files in run %d", runNo)
+ }
+ assert.Equal(t, wantChecks, gotChecks, "wrong number of checks in run %d", runNo)
+
+ checkResult(runNo, want, "combined", opt.Combined)
+ checkResult(runNo, want, "missingonsrc", opt.MissingOnSrc)
+ checkResult(runNo, want, "missingondst", opt.MissingOnDst)
+ checkResult(runNo, want, "match", opt.Match)
+ checkResult(runNo, want, "differ", opt.Differ)
+ checkResult(runNo, want, "error", opt.Error)
+ }
+
+ check := func(runNo, wantChecks, wantErrors int, wantResults wantType) {
+ runName := fmt.Sprintf("subtest%d", runNo)
+ t.Run(runName, func(t *testing.T) {
+ checkRun(runNo, wantChecks, wantErrors, wantResults)
+ })
+ }
+
+ file1 := makeFile("banana", testString1)
+ fcsums := makeSums(operations.HashSums{
+ "banana": testDigest1,
+ })
+ r.CheckRemoteItems(t, fcsums, file1)
+ check(1, 1, 0, wantType{
+ "combined": "= banana\n",
+ "missingonsrc": "",
+ "missingondst": "",
+ "match": "banana\n",
+ "differ": "",
+ "error": "",
+ })
+
+ file2 := makeFile("potato", testString2)
+ fcsums = makeSums(operations.HashSums{
+ "banana": testDigest1,
+ })
+ r.CheckRemoteItems(t, fcsums, file1, file2)
+ check(2, 2, 1, wantType{
+ "combined": "- potato\n= banana\n",
+ "missingonsrc": "potato\n",
+ "missingondst": "",
+ "match": "banana\n",
+ "differ": "",
+ "error": "",
+ })
+
+ fcsums = makeSums(operations.HashSums{
+ "banana": testDigest1,
+ "potato": testDigest2,
+ })
+ r.CheckRemoteItems(t, fcsums, file1, file2)
+ check(3, 2, 0, wantType{
+ "combined": "= potato\n= banana\n",
+ "missingonsrc": "",
+ "missingondst": "",
+ "match": "banana\npotato\n",
+ "differ": "",
+ "error": "",
+ })
+
+ fcsums = makeSums(operations.HashSums{
+ "banana": testDigest2,
+ "potato": testDigest2,
+ })
+ r.CheckRemoteItems(t, fcsums, file1, file2)
+ check(4, 2, 1, wantType{
+ "combined": "* banana\n= potato\n",
+ "missingonsrc": "",
+ "missingondst": "",
+ "match": "potato\n",
+ "differ": "banana\n",
+ "error": "",
+ })
+
+ fcsums = makeSums(operations.HashSums{
+ "banana": testDigest1,
+ "potato": testDigest2,
+ "orange": testDigest2,
+ })
+ r.CheckRemoteItems(t, fcsums, file1, file2)
+ check(5, 2, 1, wantType{
+ "combined": "+ orange\n= potato\n= banana\n",
+ "missingonsrc": "",
+ "missingondst": "orange\n",
+ "match": "banana\npotato\n",
+ "differ": "",
+ "error": "",
+ })
+
+ fcsums = makeSums(operations.HashSums{
+ "banana": testDigest1,
+ "potato": testDigest1,
+ "orange": testDigest2,
+ })
+ r.CheckRemoteItems(t, fcsums, file1, file2)
+ check(6, 2, 2, wantType{
+ "combined": "+ orange\n* potato\n= banana\n",
+ "missingonsrc": "",
+ "missingondst": "orange\n",
+ "match": "banana\n",
+ "differ": "potato\n",
+ "error": "",
+ })
+
+ // test mixed-case checksums
+ file1 = makeFile("banana", testString1)
+ file2 = makeFile("potato", testString2)
+ fcsums = makeSums(operations.HashSums{
+ "banana": testDigest1Upper,
+ "potato": testDigest2Mixed,
+ })
+ r.CheckRemoteItems(t, fcsums, file1, file2)
+ check(7, 2, 0, wantType{
+ "combined": "= banana\n= potato\n",
+ "missingonsrc": "",
+ "missingondst": "",
+ "match": "banana\npotato\n",
+ "differ": "",
+ "error": "",
+ })
+}
+
+func TestCheckSum(t *testing.T) {
+ testCheckSum(t, false)
+}
+
+func TestCheckSumDownload(t *testing.T) {
+ testCheckSum(t, true)
+}
+
+func TestApplyTransforms(t *testing.T) {
+ var (
+ hashType = hash.MD5
+ content = "Hello, World!"
+ hash = "65a8e27d8879283831b664bd8b7f0ad4"
+ nfc = norm.NFC.String(norm.NFD.String("測試_Русский___ě_áñ"))
+ nfd = norm.NFD.String(nfc)
+ nfcx2 = nfc + nfc
+ nfdx2 = nfd + nfd
+ both = nfc + nfd
+ upper = "HELLO, WORLD!"
+ lower = "hello, world!"
+ upperlowermixed = "HeLlO, wOrLd!"
+ )
+
+ testScenario := func(checkfileName, remotefileName, scenario string) {
+ r := fstest.NewRunIndividual(t)
+ ctx := context.Background()
+ ci := fs.GetConfig(ctx)
+ opt := operations.CheckOpt{}
+
+ remotefile := r.WriteObject(ctx, remotefileName, content, t2)
+ // test whether remote is capable of running test
+ entries, err := r.Fremote.List(ctx, "")
+ assert.NoError(t, err)
+ if entries.Len() == 1 && entries[0].Remote() != remotefileName {
+ t.Skipf("Fs is incapable of running test, skipping: %s (expected: %s (%s) actual: %s (%s))", scenario, remotefileName, detectEncoding(remotefileName), entries[0].Remote(), detectEncoding(entries[0].Remote()))
+ }
+
+ checkfile := r.WriteFile("test.sum", hash+" "+checkfileName, t2)
+ r.CheckLocalItems(t, checkfile)
+ assert.False(t, checkfileName == remotefile.Path, "Values match but should not: %s %s", checkfileName, remotefile.Path)
+
+ testname := scenario + " (without normalization)"
+ println(testname)
+ ci.NoUnicodeNormalization = true
+ ci.IgnoreCaseSync = false
+ accounting.GlobalStats().ResetCounters()
+ err = operations.CheckSum(ctx, r.Fremote, r.Flocal, "test.sum", hashType, &opt, true)
+ assert.Error(t, err, "no expected error for %s %v %v", testname, checkfileName, remotefileName)
+
+ testname = scenario + " (with normalization)"
+ println(testname)
+ ci.NoUnicodeNormalization = false
+ ci.IgnoreCaseSync = true
+ accounting.GlobalStats().ResetCounters()
+ err = operations.CheckSum(ctx, r.Fremote, r.Flocal, "test.sum", hashType, &opt, true)
+ assert.NoError(t, err, "unexpected error for %s %v %v", testname, checkfileName, remotefileName)
+ }
+
+ testScenario(upper, lower, "upper checkfile vs. lower remote")
+ testScenario(lower, upper, "lower checkfile vs. upper remote")
+ testScenario(lower, upperlowermixed, "lower checkfile vs. upperlowermixed remote")
+ testScenario(upperlowermixed, upper, "upperlowermixed checkfile vs. upper remote")
+ testScenario(nfd, nfc, "NFD checkfile vs. NFC remote")
+ testScenario(nfc, nfd, "NFC checkfile vs. NFD remote")
+ testScenario(nfdx2, both, "NFDx2 checkfile vs. both remote")
+ testScenario(nfcx2, both, "NFCx2 checkfile vs. both remote")
+ testScenario(both, nfdx2, "both checkfile vs. NFDx2 remote")
+ testScenario(both, nfcx2, "both checkfile vs. NFCx2 remote")
+}
+
+func detectEncoding(s string) string {
+ if norm.NFC.IsNormalString(s) && norm.NFD.IsNormalString(s) {
+ return "BOTH"
+ }
+ if !norm.NFC.IsNormalString(s) && norm.NFD.IsNormalString(s) {
+ return "NFD"
+ }
+ if norm.NFC.IsNormalString(s) && !norm.NFD.IsNormalString(s) {
+ return "NFC"
+ }
+ return "OTHER"
+}
diff --git a/fs/operations/copy.go b/fs/operations/copy.go
new file mode 100644
index 0000000..f7871d2
--- /dev/null
+++ b/fs/operations/copy.go
@@ -0,0 +1,422 @@
+// This file implements operations.Copy
+//
+// This is probably the most important operation in rclone.
+
+package operations
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "hash/crc32"
+ "io"
+ "path"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/fs/accounting"
+ "github.com/rclone/rclone/fs/fserrors"
+ "github.com/rclone/rclone/fs/hash"
+ "github.com/rclone/rclone/lib/atexit"
+ "github.com/rclone/rclone/lib/pacer"
+ "github.com/rclone/rclone/lib/transform"
+)
+
+// State of the copy
+type copy struct {
+ f fs.Fs // destination fs.Fs
+ dstFeatures *fs.Features // Features() for fs.Fs
+ dst fs.Object // destination object to update, may be nil
+ remote string // destination path, used if dst is nil
+ src fs.Object // source object
+ ci *fs.ConfigInfo // current config
+ maxTries int // max number of tries to do the copy
+ doUpdate bool // whether we are updating an existing file or not
+ hashType hash.Type // common hash to use
+ hashOption *fs.HashesOption // open option for the common hash
+ tr *accounting.Transfer // accounting for the transfer
+ inplace bool // set if we are updating inplace and not using a partial name
+ remoteForCopy string // the name used for the transfer, either remote or remote+".partial"
+}
+
+// Used to remove a failed copy
+func (c *copy) removeFailedCopy(ctx context.Context, o fs.Object) {
+ if o == nil {
+ return
+ }
+ fs.Infof(o, "Removing failed copy")
+ err := o.Remove(ctx)
+ if err != nil {
+ fs.Infof(o, "Failed to remove failed copy: %s", err)
+ }
+}
+
+// Used to remove a failed partial copy
+func (c *copy) removeFailedPartialCopy(ctx context.Context, f fs.Fs, remote string) {
+ o, err := f.NewObject(ctx, remote)
+ if errors.Is(err, fs.ErrorObjectNotFound) {
+ // Assume object has been deleted
+ return
+ }
+ if err != nil {
+ fs.Infof(remote, "Failed to remove failed partial copy: %s", err)
+ return
+ }
+ c.removeFailedCopy(ctx, o)
+}
+
+// TruncateString s to n bytes.
+//
+// If s is valid UTF-8 then this may truncate to fewer than n bytes to
+// make the returned string also valid UTF-8.
+func TruncateString(s string, n int) string {
+ truncated := s[:n]
+ if !utf8.ValidString(s) {
+ // If input string wasn't valid UTF-8 then just return the truncation
+ return truncated
+ }
+ for len(truncated) > 0 {
+ if utf8.ValidString(truncated) {
+ return truncated
+ }
+ // Remove 1 byte until valid
+ truncated = truncated[:len(truncated)-1]
+ }
+ return truncated
+}
+
+// Check to see if we should be using a partial name and return the name for the copy and the inplace flag
+func (c *copy) checkPartial(ctx context.Context) (remoteForCopy string, inplace bool, err error) {
+ remoteForCopy = c.remote
+ if c.ci.Inplace || c.dstFeatures.Move == nil || !c.dstFeatures.PartialUploads || strings.HasSuffix(c.remote, ".rclonelink") {
+ return remoteForCopy, true, nil
+ }
+ if len(c.ci.PartialSuffix) > 16 {
+ return remoteForCopy, true, fmt.Errorf("expecting length of --partial-suffix to be not greater than %d but got %d", 16, len(c.ci.PartialSuffix))
+ }
+ // Avoid making the leaf name longer if it's already lengthy to avoid
+ // trouble with file name length limits.
+
+ // generate a stable random suffix by hashing the filename and fingerprint
+ hasher := crc32.New(crc32.IEEETable)
+ _, _ = hasher.Write([]byte(c.remote))
+ _, _ = hasher.Write([]byte(fs.Fingerprint(ctx, c.src, true)))
+ hash := hasher.Sum32()
+
+ suffix := fmt.Sprintf(".%08x%s", hash, c.ci.PartialSuffix)
+ base := path.Base(remoteForCopy)
+ if len(base) > 100 {
+ remoteForCopy = TruncateString(remoteForCopy, len(remoteForCopy)-len(suffix)) + suffix
+ } else {
+ remoteForCopy += suffix
+ }
+ return remoteForCopy, false, nil
+}
+
+// Check to see if we have hit max transfer limits
+func (c *copy) checkLimits(ctx context.Context) (err error) {
+ if c.ci.MaxTransfer < 0 {
+ return nil
+ }
+ var bytesSoFar int64
+ if c.ci.CutoffMode == fs.CutoffModeCautious {
+ bytesSoFar = accounting.Stats(ctx).GetBytesWithPending() + c.src.Size()
+ } else {
+ bytesSoFar = accounting.Stats(ctx).GetBytes()
+ }
+ if bytesSoFar >= int64(c.ci.MaxTransfer) {
+ if c.ci.CutoffMode == fs.CutoffModeHard {
+ return accounting.ErrorMaxTransferLimitReachedFatal
+ }
+ return accounting.ErrorMaxTransferLimitReachedGraceful
+ }
+ return nil
+}
+
+// Server side copy c.src to (c.f, c.remoteForCopy) if possible or return fs.ErrorCantCopy if not
+func (c *copy) serverSideCopy(ctx context.Context) (actionTaken string, newDst fs.Object, err error) {
+ doCopy := c.dstFeatures.Copy
+ serverSideCopyOK := false
+ if doCopy == nil {
+ serverSideCopyOK = false
+ } else if SameConfig(c.src.Fs(), c.f) {
+ serverSideCopyOK = true
+ } else if SameRemoteType(c.src.Fs(), c.f) {
+ serverSideCopyOK = c.dstFeatures.ServerSideAcrossConfigs || c.ci.ServerSideAcrossConfigs
+ }
+ if !serverSideCopyOK {
+ return actionTaken, nil, fs.ErrorCantCopy
+ }
+ in := c.tr.Account(ctx, nil) // account the transfer
+ in.ServerSideTransferStart()
+ newDst, err = doCopy(ctx, c.src, c.remoteForCopy)
+ if err == nil {
+ in.ServerSideCopyEnd(newDst.Size()) // account the bytes for the server-side transfer
+ }
+ _ = in.Close()
+ if errors.Is(err, fs.ErrorCantCopy) {
+ c.tr.Reset(ctx) // skip incomplete accounting - will be overwritten by the manual copy
+ }
+ actionTaken = "Copied (server-side copy)"
+ return actionTaken, newDst, err
+}
+
+// Copy c.src to (c.f, c.remoteForCopy) using multiThreadCopy
+func (c *copy) multiThreadCopy(ctx context.Context, uploadOptions []fs.OpenOption) (actionTaken string, newDst fs.Object, err error) {
+ newDst, err = multiThreadCopy(ctx, c.f, c.remoteForCopy, c.src, c.ci.MultiThreadStreams, c.tr, uploadOptions...)
+ if c.doUpdate {
+ actionTaken = "Multi-thread Copied (replaced existing)"
+ } else {
+ actionTaken = "Multi-thread Copied (new)"
+ }
+ return actionTaken, newDst, err
+}
+
+// Copy the stream from in to (c.f, c.remoteForCopy) and close it
+//
+// Use Rcat to handle both remotes supporting and not supporting PutStream.
+func (c *copy) rcat(ctx context.Context, in io.ReadCloser) (actionTaken string, newDst fs.Object, err error) {
+ // Make any metadata to pass to rcat
+ var meta fs.Metadata
+ if c.ci.Metadata {
+ meta, err = fs.GetMetadata(ctx, c.src)
+ if err != nil {
+ fs.Errorf(c.src, "Failed to read metadata: %v", err)
+ }
+ }
+
+ // NB Rcat closes in0
+ fsrc, ok := c.src.Fs().(fs.Fs)
+ if !ok {
+ fsrc = nil
+ }
+ newDst, err = rcatSrc(ctx, c.f, c.remoteForCopy, in, c.src.ModTime(ctx), meta, fsrc)
+ if c.doUpdate {
+ actionTaken = "Copied (Rcat, replaced existing)"
+ } else {
+ actionTaken = "Copied (Rcat, new)"
+ }
+ return actionTaken, newDst, err
+}
+
+// Copy the stream from in to (c.f, c.remoteForCopy) and close it
+func (c *copy) updateOrPut(ctx context.Context, in io.ReadCloser, uploadOptions []fs.OpenOption) (actionTaken string, newDst fs.Object, err error) {
+ // account and buffer the transfer
+ inAcc := c.tr.Account(ctx, in).WithBuffer()
+ var wrappedSrc fs.ObjectInfo = c.src
+
+ // We try to pass the original object if possible
+ if c.src.Remote() != c.remoteForCopy {
+ wrappedSrc = fs.NewOverrideRemote(c.src, c.remoteForCopy)
+ }
+ if c.doUpdate && c.inplace {
+ err = c.dst.Update(ctx, inAcc, wrappedSrc, uploadOptions...)
+ // Make sure newDst is c.dst since we updated it
+ if err == nil {
+ newDst = c.dst
+ }
+ } else {
+ newDst, err = c.f.Put(ctx, inAcc, wrappedSrc, uploadOptions...)
+ }
+ closeErr := inAcc.Close()
+ if err == nil {
+ err = closeErr
+ }
+ if c.doUpdate {
+ actionTaken = "Copied (replaced existing)"
+ } else {
+ actionTaken = "Copied (new)"
+ }
+ return actionTaken, newDst, err
+}
+
+// Do a manual copy by reading the bytes and writing them
+func (c *copy) manualCopy(ctx context.Context) (actionTaken string, newDst fs.Object, err error) {
+ // Remove partial files on premature exit
+ if !c.inplace {
+ defer atexit.Unregister(atexit.Register(func() {
+ ctx := context.Background()
+ c.removeFailedPartialCopy(ctx, c.f, c.remoteForCopy)
+ }))
+ }
+
+ // Options for the upload
+ uploadOptions := []fs.OpenOption{c.hashOption}
+ for _, option := range c.ci.UploadHeaders {
+ uploadOptions = append(uploadOptions, option)
+ }
+ if c.ci.MetadataSet != nil {
+ uploadOptions = append(uploadOptions, fs.MetadataOption(c.ci.MetadataSet))
+ }
+
+ // Options for the download
+ downloadOptions := []fs.OpenOption{c.hashOption}
+ for _, option := range c.ci.DownloadHeaders {
+ downloadOptions = append(downloadOptions, option)
+ }
+
+ if doMultiThreadCopy(ctx, c.f, c.src) {
+ return c.multiThreadCopy(ctx, uploadOptions)
+ }
+
+ var in io.ReadCloser
+ in, err = Open(ctx, c.src, downloadOptions...)
+ if err != nil {
+ return actionTaken, nil, fmt.Errorf("failed to open source object: %w", err)
+ }
+
+ // Note that c.rcat and c.updateOrPut close in
+ if c.src.Size() == -1 {
+ return c.rcat(ctx, in)
+ }
+ return c.updateOrPut(ctx, in, uploadOptions)
+}
+
+// Verify the copy
+func (c *copy) verify(ctx context.Context, newDst fs.Object) (err error) {
+ // Verify sizes are the same after transfer
+ if sizeDiffers(ctx, c.src, newDst) {
+ return fmt.Errorf("corrupted on transfer: sizes differ src(%s) %d vs dst(%s) %d", c.src.Fs(), c.src.Size(), newDst.Fs(), newDst.Size())
+ }
+ // Verify hashes are the same after transfer - ignoring blank hashes
+ if c.hashType != hash.None {
+ // checkHashes has logs and counts errors
+ equal, _, srcSum, dstSum, _ := checkHashes(ctx, c.src, newDst, c.hashType)
+ if !equal {
+ return fmt.Errorf("corrupted on transfer: %v hashes differ src(%s) %q vs dst(%s) %q", c.hashType, c.src.Fs(), srcSum, newDst.Fs(), dstSum)
+ }
+ }
+ return nil
+}
+
+// copy src object to dst or f if nil. If dst is nil then it uses
+// remote as the name of the new object.
+//
+// It returns the destination object if possible. Note that this may
+// be nil.
+func (c *copy) copy(ctx context.Context) (newDst fs.Object, err error) {
+ var actionTaken string
+ retry := true
+ for tries := 0; retry && tries < c.maxTries; tries++ {
+ // Check we haven't hit any accounting limits
+ err = c.checkLimits(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ // Try server side copy
+ actionTaken, newDst, err = c.serverSideCopy(ctx)
+
+ // If can't server-side copy, do it manually
+ if errors.Is(err, fs.ErrorCantCopy) {
+ actionTaken, newDst, err = c.manualCopy(ctx)
+ }
+
+ // End if ctx is in error
+ if fserrors.ContextError(ctx, &err) {
+ break
+ }
+
+ // Retry if err returned a retry error
+ retry = false
+ if fserrors.IsRetryError(err) || fserrors.ShouldRetry(err) {
+ retry = true
+ } else if t, ok := pacer.IsRetryAfter(err); ok {
+ fs.Debugf(c.src, "Sleeping for %v (as indicated by the server) to obey Retry-After error: %v", t, err)
+ time.Sleep(t)
+ retry = true
+ }
+ if retry {
+ fs.Debugf(c.src, "Received error: %v - low level retry %d/%d", err, tries, c.maxTries)
+ c.tr.Reset(ctx) // skip incomplete accounting - will be overwritten by retry
+ continue
+ }
+ }
+ if err != nil {
+ err = fs.CountError(ctx, err)
+ fs.Errorf(c.src, "Failed to copy: %v", err)
+ if !c.inplace {
+ c.removeFailedPartialCopy(ctx, c.f, c.remoteForCopy)
+ }
+ return newDst, err
+ }
+
+ // Verify the copy
+ err = c.verify(ctx, newDst)
+ if err != nil {
+ fs.Errorf(newDst, "%v", err)
+ err = fs.CountError(ctx, err)
+ c.removeFailedCopy(ctx, newDst)
+ return nil, err
+ }
+
+ // Move the copied file to its real destination.
+ if !c.inplace && c.remoteForCopy != c.remote {
+ movedNewDst, err := c.dstFeatures.Move(ctx, newDst, c.remote)
+ if err != nil {
+ fs.Errorf(newDst, "partial file rename failed: %v", err)
+ err = fs.CountError(ctx, err)
+ c.removeFailedCopy(ctx, newDst)
+ return nil, err
+ }
+ fs.Debugf(newDst, "renamed to: %s", c.remote)
+ newDst = movedNewDst
+ }
+
+ // Log what we have done
+ if newDst != nil && c.src.String() != newDst.String() {
+ actionTaken = fmt.Sprintf("%s to: %s", actionTaken, newDst.String())
+ }
+ fs.Infof(c.src, "%s%s", actionTaken, fs.LogValueHide("size", fs.SizeSuffix(c.src.Size())))
+
+ return newDst, nil
+}
+
+// Copy src object to dst or f if nil. If dst is nil then it uses
+// remote as the name of the new object.
+//
+// It returns the destination object if possible. Note that this may
+// be nil.
+func Copy(ctx context.Context, f fs.Fs, dst fs.Object, remote string, src fs.Object) (newDst fs.Object, err error) {
+ ci := fs.GetConfig(ctx)
+ tr := accounting.Stats(ctx).NewTransfer(src, f)
+ defer func() {
+ tr.Done(ctx, err)
+ }()
+ if SkipDestructive(ctx, src, "copy") {
+ in := tr.Account(ctx, nil)
+ in.DryRun(src.Size())
+ return newDst, nil
+ }
+ c := &copy{
+ f: f,
+ dstFeatures: f.Features(),
+ dst: dst,
+ remote: transform.Path(ctx, remote, false),
+ src: src,
+ ci: ci,
+ tr: tr,
+ maxTries: ci.LowLevelRetries,
+ doUpdate: dst != nil,
+ }
+ c.hashType, c.hashOption = CommonHash(ctx, f, src.Fs())
+ if c.dst != nil {
+ c.remote = transform.Path(ctx, c.dst.Remote(), false)
+ }
+ // Are we using partials?
+ //
+ // If so set the flag and update the name we use for the copy
+ c.remoteForCopy, c.inplace, err = c.checkPartial(ctx)
+ if err != nil {
+ return nil, err
+ }
+ // Do the copy now everything is set up
+ return c.copy(ctx)
+}
+
+// CopyFile moves a single file possibly to a new name
+func CopyFile(ctx context.Context, fdst fs.Fs, fsrc fs.Fs, dstFileName string, srcFileName string) (err error) {
+ return moveOrCopyFile(ctx, fdst, fsrc, dstFileName, srcFileName, true, false)
+}
diff --git a/fs/operations/copy_test.go b/fs/operations/copy_test.go
new file mode 100644
index 0000000..e717f3b
--- /dev/null
+++ b/fs/operations/copy_test.go
@@ -0,0 +1,534 @@
+package operations_test
+
+import (
+ "context"
+ "crypto/rand"
+ "errors"
+ "fmt"
+ "os"
+ "path"
+ "runtime"
+ "sort"
+ "strings"
+ "testing"
+
+ "github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/fs/accounting"
+ "github.com/rclone/rclone/fs/operations"
+ "github.com/rclone/rclone/fs/sync"
+ "github.com/rclone/rclone/fstest"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTruncateString(t *testing.T) {
+ for _, test := range []struct {
+ in string
+ n int
+ want string
+ }{
+ {
+ in: "",
+ n: 0,
+ want: "",
+ }, {
+ in: "Hello World",
+ n: 5,
+ want: "Hello",
+ }, {
+ in: "ááááá",
+ n: 5,
+ want: "áá",
+ }, {
+ in: "ááááá\xFF\xFF",
+ n: 5,
+ want: "áá\xc3",
+ }, {
+ in: "世世世世世",
+ n: 7,
+ want: "世世",
+ }, {
+ in: "🙂🙂🙂🙂🙂",
+ n: 16,
+ want: "🙂🙂🙂🙂",
+ }, {
+ in: "🙂🙂🙂🙂🙂",
+ n: 15,
+ want: "🙂🙂🙂",
+ }, {
+ in: "🙂🙂🙂🙂🙂",
+ n: 14,
+ want: "🙂🙂🙂",
+ }, {
+ in: "🙂🙂🙂🙂🙂",
+ n: 13,
+ want: "🙂🙂🙂",
+ }, {
+ in: "🙂🙂🙂🙂🙂",
+ n: 12,
+ want: "🙂🙂🙂",
+ }, {
+ in: "🙂🙂🙂🙂🙂",
+ n: 11,
+ want: "🙂🙂",
+ }, {
+ in: "𝓝𝓸𝓫𝓸𝓭𝔂 𝓲𝓼 𝓱𝓸𝓶𝓮 ᴬ ⱽⁱˢⁱᵗ ᶠʳᵒᵐ ᵗʰᵉ ⱽⁱˢⁱᵒⁿᵃʳʸ",
+ n: 100,
+ want: "𝓝𝓸𝓫𝓸𝓭𝔂 𝓲𝓼 𝓱𝓸𝓶𝓮 ᴬ ⱽⁱˢⁱᵗ ᶠʳᵒᵐ ᵗʰᵉ ⱽⁱˢ",
+ }, {
+ in: "a𝓝𝓸𝓫𝓸𝓭𝔂 𝓲𝓼 𝓱𝓸𝓶𝓮 ᴬ ⱽⁱˢⁱᵗ ᶠʳᵒᵐ ᵗʰᵉ ⱽⁱˢⁱᵒⁿᵃʳʸ",
+ n: 100,
+ want: "a𝓝𝓸𝓫𝓸𝓭𝔂 𝓲𝓼 𝓱𝓸𝓶𝓮 ᴬ ⱽⁱˢⁱᵗ ᶠʳᵒᵐ ᵗʰᵉ ⱽⁱˢ",
+ }, {
+ in: "aa𝓝𝓸𝓫𝓸𝓭𝔂 𝓲𝓼 𝓱𝓸𝓶𝓮 ᴬ ⱽⁱˢⁱᵗ ᶠʳᵒᵐ ᵗʰᵉ ⱽⁱˢⁱᵒⁿᵃʳʸ",
+ n: 100,
+ want: "aa𝓝𝓸𝓫𝓸𝓭𝔂 𝓲𝓼 𝓱𝓸𝓶𝓮 ᴬ ⱽⁱˢⁱᵗ ᶠʳᵒᵐ ᵗʰᵉ ⱽⁱ",
+ }, {
+ in: "aaa𝓝𝓸𝓫𝓸𝓭𝔂 𝓲𝓼 𝓱𝓸𝓶𝓮 ᴬ ⱽⁱˢⁱᵗ ᶠʳᵒᵐ ᵗʰᵉ ⱽⁱˢⁱᵒⁿᵃʳʸ",
+ n: 100,
+ want: "aaa𝓝𝓸𝓫𝓸𝓭𝔂 𝓲𝓼 𝓱𝓸𝓶𝓮 ᴬ ⱽⁱˢⁱᵗ ᶠʳᵒᵐ ᵗʰᵉ ⱽⁱ",
+ }, {
+ in: "aaaa𝓝𝓸𝓫𝓸𝓭𝔂 𝓲𝓼 𝓱𝓸𝓶𝓮 ᴬ ⱽⁱˢⁱᵗ ᶠʳᵒᵐ ᵗʰᵉ ⱽⁱˢⁱᵒⁿᵃʳʸ",
+ n: 100,
+ want: "aaaa𝓝𝓸𝓫𝓸𝓭𝔂 𝓲𝓼 𝓱𝓸𝓶𝓮 ᴬ ⱽⁱˢⁱᵗ ᶠʳᵒᵐ ᵗʰᵉ ⱽ",
+ },
+ } {
+ got := operations.TruncateString(test.in, test.n)
+ assert.Equal(t, test.want, got, fmt.Sprintf("In %q", test.in))
+ assert.LessOrEqual(t, len(got), test.n)
+ assert.GreaterOrEqual(t, len(got), test.n-3)
+ }
+}
+
+func TestCopyFile(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+
+ file1 := r.WriteFile("file1", "file1 contents", t1)
+ r.CheckLocalItems(t, file1)
+
+ file2 := file1
+ file2.Path = "sub/file2"
+
+ err := operations.CopyFile(ctx, r.Fremote, r.Flocal, file2.Path, file1.Path)
+ require.NoError(t, err)
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, file2)
+
+ err = operations.CopyFile(ctx, r.Fremote, r.Flocal, file2.Path, file1.Path)
+ require.NoError(t, err)
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, file2)
+
+ err = operations.CopyFile(ctx, r.Fremote, r.Fremote, file2.Path, file2.Path)
+ require.NoError(t, err)
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, file2)
+}
+
+// Find the longest file name for writing to local
+func maxLengthFileName(t *testing.T, r *fstest.Run) string {
+ require.NoError(t, r.Flocal.Mkdir(context.Background(), "")) // create the root
+ const maxLen = 16 * 1024
+ name := strings.Repeat("A", maxLen)
+ i := sort.Search(len(name), func(i int) (fail bool) {
+ filePath := path.Join(r.LocalName, name[:i])
+ err := os.WriteFile(filePath, []byte{0}, 0777)
+ if err != nil {
+ return true
+ }
+ err = os.Remove(filePath)
+ if err != nil {
+ t.Logf("Failed to remove test file: %v", err)
+ }
+ return false
+ })
+ return name[:i-1]
+}
+
+// Check we can copy a file of maximum name length
+func TestCopyLongFile(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ if !r.Fremote.Features().IsLocal {
+ t.Skip("Test only runs on local")
+ }
+
+ // Find the maximum length of file we can write
+ name := maxLengthFileName(t, r)
+ t.Logf("Max length of file name is %d", len(name))
+ file1 := r.WriteFile(name, "file1 contents", t1)
+ r.CheckLocalItems(t, file1)
+
+ err := operations.CopyFile(ctx, r.Fremote, r.Flocal, file1.Path, file1.Path)
+ require.NoError(t, err)
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, file1)
+}
+
+func TestCopyFileBackupDir(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+ if !operations.CanServerSideMove(r.Fremote) {
+ t.Skip("Skipping test as remote does not support server-side move or copy")
+ }
+
+ ci.BackupDir = r.FremoteName + "/backup"
+
+ file1 := r.WriteFile("dst/file1", "file1 contents", t1)
+ r.CheckLocalItems(t, file1)
+
+ file1old := r.WriteObject(ctx, "dst/file1", "file1 contents old", t1)
+ r.CheckRemoteItems(t, file1old)
+
+ err := operations.CopyFile(ctx, r.Fremote, r.Flocal, file1.Path, file1.Path)
+ require.NoError(t, err)
+ r.CheckLocalItems(t, file1)
+ file1old.Path = "backup/dst/file1"
+ r.CheckRemoteItems(t, file1old, file1)
+}
+
+// Test with CompareDest set
+func TestCopyFileCompareDest(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+
+ ci.CompareDest = []string{r.FremoteName + "/CompareDest"}
+ fdst, err := fs.NewFs(ctx, r.FremoteName+"/dst")
+ require.NoError(t, err)
+
+ // check empty dest, empty compare
+ file1 := r.WriteFile("one", "one", t1)
+ r.CheckLocalItems(t, file1)
+
+ err = operations.CopyFile(ctx, fdst, r.Flocal, file1.Path, file1.Path)
+ require.NoError(t, err)
+
+ file1dst := file1
+ file1dst.Path = "dst/one"
+
+ r.CheckRemoteItems(t, file1dst)
+
+ // check old dest, empty compare
+ file1b := r.WriteFile("one", "onet2", t2)
+ r.CheckRemoteItems(t, file1dst)
+ r.CheckLocalItems(t, file1b)
+
+ err = operations.CopyFile(ctx, fdst, r.Flocal, file1b.Path, file1b.Path)
+ require.NoError(t, err)
+
+ file1bdst := file1b
+ file1bdst.Path = "dst/one"
+
+ r.CheckRemoteItems(t, file1bdst)
+
+ // check old dest, new compare
+ file3 := r.WriteObject(ctx, "dst/one", "one", t1)
+ file2 := r.WriteObject(ctx, "CompareDest/one", "onet2", t2)
+ file1c := r.WriteFile("one", "onet2", t2)
+ r.CheckRemoteItems(t, file2, file3)
+ r.CheckLocalItems(t, file1c)
+
+ err = operations.CopyFile(ctx, fdst, r.Flocal, file1c.Path, file1c.Path)
+ require.NoError(t, err)
+
+ r.CheckRemoteItems(t, file2, file3)
+
+ // check empty dest, new compare
+ file4 := r.WriteObject(ctx, "CompareDest/two", "two", t2)
+ file5 := r.WriteFile("two", "two", t2)
+ r.CheckRemoteItems(t, file2, file3, file4)
+ r.CheckLocalItems(t, file1c, file5)
+
+ err = operations.CopyFile(ctx, fdst, r.Flocal, file5.Path, file5.Path)
+ require.NoError(t, err)
+
+ r.CheckRemoteItems(t, file2, file3, file4)
+
+ // check new dest, new compare
+ err = operations.CopyFile(ctx, fdst, r.Flocal, file5.Path, file5.Path)
+ require.NoError(t, err)
+
+ r.CheckRemoteItems(t, file2, file3, file4)
+
+ // check empty dest, old compare
+ file5b := r.WriteFile("two", "twot3", t3)
+ r.CheckRemoteItems(t, file2, file3, file4)
+ r.CheckLocalItems(t, file1c, file5b)
+
+ err = operations.CopyFile(ctx, fdst, r.Flocal, file5b.Path, file5b.Path)
+ require.NoError(t, err)
+
+ file5bdst := file5b
+ file5bdst.Path = "dst/two"
+
+ r.CheckRemoteItems(t, file2, file3, file4, file5bdst)
+}
+
+// Test with CopyDest set
+func TestCopyFileCopyDest(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+
+ if r.Fremote.Features().Copy == nil {
+ t.Skip("Skipping test as remote does not support server-side copy")
+ }
+
+ ci.CopyDest = []string{r.FremoteName + "/CopyDest"}
+
+ fdst, err := fs.NewFs(ctx, r.FremoteName+"/dst")
+ require.NoError(t, err)
+
+ // check empty dest, empty copy
+ file1 := r.WriteFile("one", "one", t1)
+ r.CheckLocalItems(t, file1)
+
+ err = operations.CopyFile(ctx, fdst, r.Flocal, file1.Path, file1.Path)
+ require.NoError(t, err)
+
+ file1dst := file1
+ file1dst.Path = "dst/one"
+
+ r.CheckRemoteItems(t, file1dst)
+
+ // check old dest, empty copy
+ file1b := r.WriteFile("one", "onet2", t2)
+ r.CheckRemoteItems(t, file1dst)
+ r.CheckLocalItems(t, file1b)
+
+ err = operations.CopyFile(ctx, fdst, r.Flocal, file1b.Path, file1b.Path)
+ require.NoError(t, err)
+
+ file1bdst := file1b
+ file1bdst.Path = "dst/one"
+
+ r.CheckRemoteItems(t, file1bdst)
+
+ // check old dest, new copy, backup-dir
+
+ ci.BackupDir = r.FremoteName + "/BackupDir"
+
+ file3 := r.WriteObject(ctx, "dst/one", "one", t1)
+ file2 := r.WriteObject(ctx, "CopyDest/one", "onet2", t2)
+ file1c := r.WriteFile("one", "onet2", t2)
+ r.CheckRemoteItems(t, file2, file3)
+ r.CheckLocalItems(t, file1c)
+
+ err = operations.CopyFile(ctx, fdst, r.Flocal, file1c.Path, file1c.Path)
+ require.NoError(t, err)
+
+ file2dst := file2
+ file2dst.Path = "dst/one"
+ file3.Path = "BackupDir/one"
+
+ r.CheckRemoteItems(t, file2, file2dst, file3)
+ ci.BackupDir = ""
+
+ // check empty dest, new copy
+ file4 := r.WriteObject(ctx, "CopyDest/two", "two", t2)
+ file5 := r.WriteFile("two", "two", t2)
+ r.CheckRemoteItems(t, file2, file2dst, file3, file4)
+ r.CheckLocalItems(t, file1c, file5)
+
+ err = operations.CopyFile(ctx, fdst, r.Flocal, file5.Path, file5.Path)
+ require.NoError(t, err)
+
+ file4dst := file4
+ file4dst.Path = "dst/two"
+
+ r.CheckRemoteItems(t, file2, file2dst, file3, file4, file4dst)
+
+ // check new dest, new copy
+ err = operations.CopyFile(ctx, fdst, r.Flocal, file5.Path, file5.Path)
+ require.NoError(t, err)
+
+ r.CheckRemoteItems(t, file2, file2dst, file3, file4, file4dst)
+
+ // check empty dest, old copy
+ file6 := r.WriteObject(ctx, "CopyDest/three", "three", t2)
+ file7 := r.WriteFile("three", "threet3", t3)
+ r.CheckRemoteItems(t, file2, file2dst, file3, file4, file4dst, file6)
+ r.CheckLocalItems(t, file1c, file5, file7)
+
+ err = operations.CopyFile(ctx, fdst, r.Flocal, file7.Path, file7.Path)
+ require.NoError(t, err)
+
+ file7dst := file7
+ file7dst.Path = "dst/three"
+
+ r.CheckRemoteItems(t, file2, file2dst, file3, file4, file4dst, file6, file7dst)
+}
+
+func TestCopyInplace(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+
+ if !r.Fremote.Features().PartialUploads {
+ t.Skip("Partial uploads not supported")
+ }
+
+ ci.Inplace = true
+
+ file1 := r.WriteFile("file1", "file1 contents", t1)
+ r.CheckLocalItems(t, file1)
+
+ file2 := file1
+ file2.Path = "sub/file2"
+
+ err := operations.CopyFile(ctx, r.Fremote, r.Flocal, file2.Path, file1.Path)
+ require.NoError(t, err)
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, file2)
+
+ err = operations.CopyFile(ctx, r.Fremote, r.Flocal, file2.Path, file1.Path)
+ require.NoError(t, err)
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, file2)
+
+ err = operations.CopyFile(ctx, r.Fremote, r.Fremote, file2.Path, file2.Path)
+ require.NoError(t, err)
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, file2)
+}
+
+func TestCopyLongFileName(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+
+ if !r.Fremote.Features().PartialUploads {
+ t.Skip("Partial uploads not supported")
+ }
+
+ ci.Inplace = false // the default
+
+ file1 := r.WriteFile("file1", "file1 contents", t1)
+ r.CheckLocalItems(t, file1)
+
+ file2 := file1
+ file2.Path = "sub/" + strings.Repeat("file2", 30)
+
+ err := operations.CopyFile(ctx, r.Fremote, r.Flocal, file2.Path, file1.Path)
+ require.NoError(t, err)
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, file2)
+
+ err = operations.CopyFile(ctx, r.Fremote, r.Flocal, file2.Path, file1.Path)
+ require.NoError(t, err)
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, file2)
+
+ err = operations.CopyFile(ctx, r.Fremote, r.Fremote, file2.Path, file2.Path)
+ require.NoError(t, err)
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, file2)
+}
+
+func TestCopyLongFileNameCollision(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+
+ if !r.Fremote.Features().PartialUploads {
+ t.Skip("Partial uploads not supported")
+ }
+
+ ci.Inplace = false
+ ci.Transfers = 4
+
+ // Write a lot of identical files with long names
+ files := make([]fstest.Item, 10)
+ namePrefix := strings.Repeat("file1", 30)
+ for i := range files {
+ files[i] = r.WriteFile(fmt.Sprintf("%s%02d", namePrefix, i), "file1 contents", t1)
+ }
+ r.CheckLocalItems(t, files...)
+
+ err := sync.CopyDir(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ r.CheckLocalItems(t, files...)
+ r.CheckRemoteItems(t, files...)
+}
+
+func TestCopyFileMaxTransfer(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+ defer accounting.Stats(ctx).ResetCounters()
+
+ const sizeCutoff = 2048
+
+ // Make random incompressible data
+ randomData := make([]byte, sizeCutoff)
+ _, err := rand.Read(randomData)
+ require.NoError(t, err)
+ randomString := string(randomData)
+
+ file1 := r.WriteFile("TestCopyFileMaxTransfer/file1", "file1 contents", t1)
+ file2 := r.WriteFile("TestCopyFileMaxTransfer/file2", "file2 contents"+randomString, t2)
+ file3 := r.WriteFile("TestCopyFileMaxTransfer/file3", "file3 contents"+randomString, t2)
+ file4 := r.WriteFile("TestCopyFileMaxTransfer/file4", "file4 contents"+randomString, t2)
+
+ // Cutoff mode: Hard
+ ci.MaxTransfer = sizeCutoff
+ ci.CutoffMode = fs.CutoffModeHard
+
+ if runtime.GOOS == "darwin" {
+ // disable server-side copies as they don't count towards transfer size stats
+ r.Flocal.Features().Disable("Copy")
+ if r.Fremote.Features().IsLocal {
+ r.Fremote.Features().Disable("Copy")
+ }
+ }
+
+ // file1: Show a small file gets transferred OK
+ accounting.Stats(ctx).ResetCounters()
+ err = operations.CopyFile(ctx, r.Fremote, r.Flocal, file1.Path, file1.Path)
+ require.NoError(t, err)
+ r.CheckLocalItems(t, file1, file2, file3, file4)
+ r.CheckRemoteItems(t, file1)
+
+ // file2: show a large file does not get transferred
+ accounting.Stats(ctx).ResetCounters()
+ err = operations.CopyFile(ctx, r.Fremote, r.Flocal, file2.Path, file2.Path)
+ require.NotNil(t, err, "Did not get expected max transfer limit error")
+ if !errors.Is(err, accounting.ErrorMaxTransferLimitReachedFatal) {
+ t.Log("Expecting error to contain accounting.ErrorMaxTransferLimitReachedFatal")
+ // Sometimes the backends or their SDKs don't pass the
+ // error through properly, so check that it at least
+ // has the text we expect in.
+ assert.Contains(t, err.Error(), "max transfer limit reached")
+ }
+ r.CheckLocalItems(t, file1, file2, file3, file4)
+ r.CheckRemoteItems(t, file1)
+
+ // Cutoff mode: Cautious
+ ci.CutoffMode = fs.CutoffModeCautious
+
+ // file3: show a large file does not get transferred
+ accounting.Stats(ctx).ResetCounters()
+ err = operations.CopyFile(ctx, r.Fremote, r.Flocal, file3.Path, file3.Path)
+ require.NotNil(t, err)
+ assert.True(t, errors.Is(err, accounting.ErrorMaxTransferLimitReachedGraceful))
+ r.CheckLocalItems(t, file1, file2, file3, file4)
+ r.CheckRemoteItems(t, file1)
+
+ if isChunker(r.Fremote) {
+ t.Log("skipping remainder of test for chunker as it involves multiple transfers")
+ return
+ }
+
+ // Cutoff mode: Soft
+ ci.CutoffMode = fs.CutoffModeSoft
+
+ // file4: show a large file does get transferred this time
+ accounting.Stats(ctx).ResetCounters()
+ err = operations.CopyFile(ctx, r.Fremote, r.Flocal, file4.Path, file4.Path)
+ require.NoError(t, err)
+ r.CheckLocalItems(t, file1, file2, file3, file4)
+ r.CheckRemoteItems(t, file1, file4)
+}
diff --git a/fs/operations/dedupe.go b/fs/operations/dedupe.go
new file mode 100644
index 0000000..60c66c4
--- /dev/null
+++ b/fs/operations/dedupe.go
@@ -0,0 +1,506 @@
+// dedupe - gets rid of identical files remotes which can have duplicate file names (drive, mega)
+
+package operations
+
+import (
+ "context"
+ "fmt"
+ "path"
+ "sort"
+ "strings"
+
+ "github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/fs/accounting"
+ "github.com/rclone/rclone/fs/config"
+ "github.com/rclone/rclone/fs/hash"
+ "github.com/rclone/rclone/fs/walk"
+)
+
+// dedupeRename renames the objs slice to different names
+func dedupeRename(ctx context.Context, f fs.Fs, remote string, objs []fs.Object) {
+ doMove := f.Features().Move
+ if doMove == nil {
+ fs.Fatalf(nil, "Fs %v doesn't support Move", f)
+ }
+ ext := path.Ext(remote)
+ base := remote[:len(remote)-len(ext)]
+
+outer:
+ for i, o := range objs {
+ suffix := 1
+ newName := fmt.Sprintf("%s-%d%s", base, i+suffix, ext)
+ _, err := f.NewObject(ctx, newName)
+ for ; err != fs.ErrorObjectNotFound; suffix++ {
+ if err != nil {
+ err = fs.CountError(ctx, err)
+ fs.Errorf(o, "Failed to check for existing object: %v", err)
+ continue outer
+ }
+ if suffix > 100 {
+ fs.Errorf(o, "Could not find an available new name")
+ continue outer
+ }
+ newName = fmt.Sprintf("%s-%d%s", base, i+suffix, ext)
+ _, err = f.NewObject(ctx, newName)
+ }
+ if !SkipDestructive(ctx, o, "rename") {
+ newObj, err := doMove(ctx, o, newName)
+ if err != nil {
+ err = fs.CountError(ctx, err)
+ fs.Errorf(o, "Failed to rename: %v", err)
+ continue
+ }
+ fs.Infof(newObj, "renamed from: %v", o)
+ }
+ }
+}
+
+// dedupeDeleteAllButOne deletes all but the one in keep
+func dedupeDeleteAllButOne(ctx context.Context, keep int, remote string, objs []fs.Object) {
+ count := 0
+ for i, o := range objs {
+ if i == keep {
+ continue
+ }
+ err := DeleteFile(ctx, o)
+ if err == nil {
+ count++
+ }
+ }
+ if count > 0 {
+ fs.Logf(remote, "Deleted %d extra copies", count)
+ }
+}
+
+// dedupeDeleteIdentical deletes all but one of identical (by hash) copies
+func dedupeDeleteIdentical(ctx context.Context, ht hash.Type, remote string, objs []fs.Object) (remainingObjs []fs.Object) {
+ ci := fs.GetConfig(ctx)
+
+ // Make map of IDs
+ IDs := make(map[string]int, len(objs))
+ for _, o := range objs {
+ if do, ok := o.(fs.IDer); ok {
+ if ID := do.ID(); ID != "" {
+ IDs[ID]++
+ }
+ }
+ }
+
+ // Remove duplicate IDs
+ newObjs := objs[:0]
+ for _, o := range objs {
+ if do, ok := o.(fs.IDer); ok {
+ if ID := do.ID(); ID != "" {
+ if IDs[ID] <= 1 {
+ newObjs = append(newObjs, o)
+ } else {
+ fs.Logf(o, "Ignoring as it appears %d times in the listing and deleting would lead to data loss", IDs[ID])
+ }
+ }
+ }
+ }
+ objs = newObjs
+
+ // See how many of these duplicates are identical
+ dupesByID := make(map[string][]fs.Object, len(objs))
+ for _, o := range objs {
+ ID := ""
+ if ci.SizeOnly && o.Size() >= 0 {
+ ID = fmt.Sprintf("size %d", o.Size())
+ } else if ht != hash.None {
+ hashValue, err := o.Hash(ctx, ht)
+ if err == nil && hashValue != "" {
+ ID = fmt.Sprintf("%v %s", ht, hashValue)
+ }
+ }
+ if ID == "" {
+ remainingObjs = append(remainingObjs, o)
+ } else {
+ dupesByID[ID] = append(dupesByID[ID], o)
+ }
+ }
+
+ // Delete identical duplicates, filling remainingObjs with the ones remaining
+ for ID, dupes := range dupesByID {
+ remainingObjs = append(remainingObjs, dupes[0])
+ if len(dupes) > 1 {
+ fs.Logf(remote, "Deleting %d/%d identical duplicates (%s)", len(dupes)-1, len(dupes), ID)
+ for _, o := range dupes[1:] {
+ err := DeleteFile(ctx, o)
+ if err != nil {
+ remainingObjs = append(remainingObjs, o)
+ }
+ }
+ }
+ }
+
+ return remainingObjs
+}
+
+// dedupeList lists the duplicates and does nothing
+func dedupeList(ctx context.Context, f fs.Fs, ht hash.Type, remote string, objs []fs.Object, byHash bool) {
+ fmt.Printf("%s: %d duplicates\n", remote, len(objs))
+ for i, o := range objs {
+ hashValue := ""
+ if ht != hash.None {
+ var err error
+ hashValue, err = o.Hash(ctx, ht)
+ if err != nil {
+ hashValue = err.Error()
+ }
+ }
+ if byHash {
+ fmt.Printf(" %d: %12d bytes, %s, %s\n", i+1, o.Size(), o.ModTime(ctx).Local().Format("2006-01-02 15:04:05.000000000"), o.Remote())
+ } else {
+ fmt.Printf(" %d: %12d bytes, %s, %v %32s\n", i+1, o.Size(), o.ModTime(ctx).Local().Format("2006-01-02 15:04:05.000000000"), ht, hashValue)
+ }
+ }
+}
+
+// dedupeInteractive interactively dedupes the slice of objects
+func dedupeInteractive(ctx context.Context, f fs.Fs, ht hash.Type, remote string, objs []fs.Object, byHash bool) bool {
+ dedupeList(ctx, f, ht, remote, objs, byHash)
+ commands := []string{"sSkip and do nothing", "kKeep just one (choose which in next step)"}
+ if !byHash {
+ commands = append(commands, "rRename all to be different (by changing file.jpg to file-1.jpg)")
+ }
+ commands = append(commands, "qQuit")
+ switch config.Command(commands) {
+ case 's':
+ case 'k':
+ keep := config.ChooseNumber("Enter the number of the file to keep", 1, len(objs))
+ dedupeDeleteAllButOne(ctx, keep-1, remote, objs)
+ case 'r':
+ dedupeRename(ctx, f, remote, objs)
+ case 'q':
+ return false
+ }
+ return true
+}
+
+// DeduplicateMode is how the dedupe command chooses what to do
+type DeduplicateMode int
+
+// Deduplicate modes
+const (
+ DeduplicateInteractive DeduplicateMode = iota // interactively ask the user
+ DeduplicateSkip // skip all conflicts
+ DeduplicateFirst // choose the first object
+ DeduplicateNewest // choose the newest object
+ DeduplicateOldest // choose the oldest object
+ DeduplicateRename // rename the objects
+ DeduplicateLargest // choose the largest object
+ DeduplicateSmallest // choose the smallest object
+ DeduplicateList // list duplicates only
+)
+
+func (x DeduplicateMode) String() string {
+ switch x {
+ case DeduplicateInteractive:
+ return "interactive"
+ case DeduplicateSkip:
+ return "skip"
+ case DeduplicateFirst:
+ return "first"
+ case DeduplicateNewest:
+ return "newest"
+ case DeduplicateOldest:
+ return "oldest"
+ case DeduplicateRename:
+ return "rename"
+ case DeduplicateLargest:
+ return "largest"
+ case DeduplicateSmallest:
+ return "smallest"
+ case DeduplicateList:
+ return "list"
+ }
+ return "unknown"
+}
+
+// Set a DeduplicateMode from a string
+func (x *DeduplicateMode) Set(s string) error {
+ switch strings.ToLower(s) {
+ case "interactive":
+ *x = DeduplicateInteractive
+ case "skip":
+ *x = DeduplicateSkip
+ case "first":
+ *x = DeduplicateFirst
+ case "newest":
+ *x = DeduplicateNewest
+ case "oldest":
+ *x = DeduplicateOldest
+ case "rename":
+ *x = DeduplicateRename
+ case "largest":
+ *x = DeduplicateLargest
+ case "smallest":
+ *x = DeduplicateSmallest
+ case "list":
+ *x = DeduplicateList
+ default:
+ return fmt.Errorf("unknown mode for dedupe %q", s)
+ }
+ return nil
+}
+
+// Type of the value
+func (x *DeduplicateMode) Type() string {
+ return "string"
+}
+
+// Directory with entry count and links to parents
+type dedupeDir struct {
+ dir fs.Directory
+ parent string
+ count int
+}
+
+// Map of directories by ID with recursive counts
+type dedupeDirsMap map[string]*dedupeDir
+
+func (dm dedupeDirsMap) get(id string) *dedupeDir {
+ d := dm[id]
+ if d == nil {
+ d = &dedupeDir{}
+ dm[id] = d
+ }
+ return d
+}
+
+func (dm dedupeDirsMap) increment(parent string) {
+ if parent != "" {
+ d := dm.get(parent)
+ d.count++
+ dm.increment(d.parent)
+ }
+}
+
+// dedupeFindDuplicateDirs scans f for duplicate directories
+func dedupeFindDuplicateDirs(ctx context.Context, f fs.Fs) (duplicateDirs [][]*dedupeDir, err error) {
+ dirsByID := dedupeDirsMap{}
+ dirs := map[string][]*dedupeDir{}
+
+ ci := fs.GetConfig(ctx)
+ err = walk.ListR(ctx, f, "", false, ci.MaxDepth, walk.ListAll, func(entries fs.DirEntries) error {
+ for _, entry := range entries {
+ tr := accounting.Stats(ctx).NewCheckingTransfer(entry, "merging")
+
+ remote := entry.Remote()
+ parentRemote := path.Dir(remote)
+ if parentRemote == "." {
+ parentRemote = ""
+ }
+
+ // Obtain ID of the object parent, if known.
+ // (This usually means that backend allows duplicate paths)
+ // Fall back to remote parent path, if unavailable.
+ var parent string
+ if entryParentIDer, ok := entry.(fs.ParentIDer); ok {
+ parent = entryParentIDer.ParentID()
+ }
+ if parent == "" {
+ parent = parentRemote
+ }
+
+ var ID string
+ if entryIDer, ok := entry.(fs.IDer); ok {
+ ID = entryIDer.ID()
+ }
+ if ID == "" {
+ ID = remote
+ }
+
+ if fsDir, ok := entry.(fs.Directory); ok {
+ d := dirsByID.get(ID)
+ d.dir = fsDir
+ d.parent = parent
+ dirs[remote] = append(dirs[remote], d)
+ }
+
+ dirsByID.increment(parent)
+ tr.Done(ctx, nil)
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, fmt.Errorf("find duplicate dirs: %w", err)
+ }
+
+ // Make sure parents are before children
+ duplicateNames := []string{}
+ for name, ds := range dirs {
+ if len(ds) > 1 {
+ duplicateNames = append(duplicateNames, name)
+ }
+ }
+ sort.Strings(duplicateNames)
+ for _, name := range duplicateNames {
+ duplicateDirs = append(duplicateDirs, dirs[name])
+ }
+
+ return
+}
+
+// dedupeMergeDuplicateDirs merges all the duplicate directories found
+func dedupeMergeDuplicateDirs(ctx context.Context, f fs.Fs, duplicateDirs [][]*dedupeDir) error {
+ mergeDirs := f.Features().MergeDirs
+ if mergeDirs == nil {
+ return fmt.Errorf("%v: can't merge directories", f)
+ }
+ dirCacheFlush := f.Features().DirCacheFlush
+ if dirCacheFlush == nil {
+ return fmt.Errorf("%v: can't flush dir cache", f)
+ }
+ for _, dedupeDirs := range duplicateDirs {
+ if SkipDestructive(ctx, dedupeDirs[0].dir, "merge duplicate directories") {
+ continue
+ }
+
+ // Put largest directory in front to minimize movements
+ fsDirs := []fs.Directory{}
+ largestCount := -1
+ largestIdx := 0
+ for i, d := range dedupeDirs {
+ fsDirs = append(fsDirs, d.dir)
+ if d.count > largestCount {
+ largestIdx = i
+ largestCount = d.count
+ }
+ }
+ fsDirs[largestIdx], fsDirs[0] = fsDirs[0], fsDirs[largestIdx]
+
+ fs.Infof(fsDirs[0], "Merging contents of duplicate directories")
+ err := mergeDirs(ctx, fsDirs)
+ if err != nil {
+ err = fs.CountError(ctx, err)
+ fs.Errorf(nil, "merge duplicate dirs: %v", err)
+ }
+ }
+ dirCacheFlush()
+ return nil
+}
+
+// sort oldest first
+func sortOldestFirst(objs []fs.Object) {
+ sort.Slice(objs, func(i, j int) bool {
+ return objs[i].ModTime(context.TODO()).Before(objs[j].ModTime(context.TODO()))
+ })
+}
+
+// sort smallest first
+func sortSmallestFirst(objs []fs.Object) {
+ sort.Slice(objs, func(i, j int) bool {
+ return objs[i].Size() < objs[j].Size()
+ })
+}
+
+// Deduplicate interactively finds duplicate files and offers to
+// delete all but one or rename them to be different. Only useful with
+// Google Drive which can have duplicate file names.
+func Deduplicate(ctx context.Context, f fs.Fs, mode DeduplicateMode, byHash bool) error {
+ ci := fs.GetConfig(ctx)
+ // find a hash to use
+ ht := f.Hashes().GetOne()
+ what := "names"
+ if byHash {
+ if ht == hash.None {
+ return fmt.Errorf("%v has no hashes", f)
+ }
+ what = ht.String() + " hashes"
+ }
+ fs.Infof(f, "Looking for duplicate %s using %v mode.", what, mode)
+
+ // Find duplicate directories first and fix them
+ if !byHash {
+ duplicateDirs, err := dedupeFindDuplicateDirs(ctx, f)
+ if err != nil {
+ return err
+ }
+ if len(duplicateDirs) > 0 {
+ if mode != DeduplicateList {
+ err = dedupeMergeDuplicateDirs(ctx, f, duplicateDirs)
+ if err != nil {
+ return err
+ }
+ } else {
+ for _, dedupeDirs := range duplicateDirs {
+ remote := dedupeDirs[0].dir.Remote()
+ fmt.Printf("%s: %d duplicates of this directory\n", remote, len(dedupeDirs))
+ }
+ }
+ }
+ }
+
+ // Now find duplicate files
+ files := map[string][]fs.Object{}
+ err := walk.ListR(ctx, f, "", false, ci.MaxDepth, walk.ListObjects, func(entries fs.DirEntries) error {
+ entries.ForObject(func(o fs.Object) {
+ tr := accounting.Stats(ctx).NewCheckingTransfer(o, "checking")
+ defer tr.Done(ctx, nil)
+
+ var remote string
+ var err error
+ if byHash {
+ remote, err = o.Hash(ctx, ht)
+ if err != nil {
+ fs.Errorf(o, "Failed to hash: %v", err)
+ remote = ""
+ }
+ } else {
+ remote = o.Remote()
+ }
+ if remote != "" {
+ files[remote] = append(files[remote], o)
+ }
+ })
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+
+ for remote, objs := range files {
+ if len(objs) <= 1 {
+ continue
+ }
+ fs.Logf(remote, "Found %d files with duplicate %s", len(objs), what)
+ if !byHash && mode != DeduplicateList {
+ objs = dedupeDeleteIdentical(ctx, ht, remote, objs)
+ if len(objs) <= 1 {
+ fs.Logf(remote, "All duplicates removed")
+ continue
+ }
+ }
+ switch mode {
+ case DeduplicateInteractive:
+ if !dedupeInteractive(ctx, f, ht, remote, objs, byHash) {
+ return nil
+ }
+ case DeduplicateFirst:
+ dedupeDeleteAllButOne(ctx, 0, remote, objs)
+ case DeduplicateNewest:
+ sortOldestFirst(objs)
+ dedupeDeleteAllButOne(ctx, len(objs)-1, remote, objs)
+ case DeduplicateOldest:
+ sortOldestFirst(objs)
+ dedupeDeleteAllButOne(ctx, 0, remote, objs)
+ case DeduplicateRename:
+ dedupeRename(ctx, f, remote, objs)
+ case DeduplicateLargest:
+ sortSmallestFirst(objs)
+ dedupeDeleteAllButOne(ctx, len(objs)-1, remote, objs)
+ case DeduplicateSmallest:
+ sortSmallestFirst(objs)
+ dedupeDeleteAllButOne(ctx, 0, remote, objs)
+ case DeduplicateSkip:
+ fs.Logf(remote, "Skipping %d files with duplicate %s", len(objs), what)
+ case DeduplicateList:
+ dedupeList(ctx, f, ht, remote, objs, byHash)
+ default:
+ //skip
+ }
+ }
+ return nil
+}
diff --git a/fs/operations/dedupe_test.go b/fs/operations/dedupe_test.go
new file mode 100644
index 0000000..15a4e2d
--- /dev/null
+++ b/fs/operations/dedupe_test.go
@@ -0,0 +1,280 @@
+package operations_test
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/fs/hash"
+ "github.com/rclone/rclone/fs/operations"
+ "github.com/rclone/rclone/fs/walk"
+ "github.com/rclone/rclone/fstest"
+ "github.com/rclone/rclone/lib/random"
+ "github.com/spf13/pflag"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// Check flag satisfies the interface
+var _ pflag.Value = (*operations.DeduplicateMode)(nil)
+
+func skipIfCantDedupe(t *testing.T, f fs.Fs) {
+ if !f.Features().DuplicateFiles {
+ t.Skip("Can't test deduplicate - no duplicate files possible")
+ }
+ if f.Features().PutUnchecked == nil {
+ t.Skip("Can't test deduplicate - no PutUnchecked")
+ }
+ if f.Features().MergeDirs == nil {
+ t.Skip("Can't test deduplicate - no MergeDirs")
+ }
+}
+
+func skipIfNoHash(t *testing.T, f fs.Fs) {
+ if f.Hashes().GetOne() == hash.None {
+ t.Skip("Can't run this test without a hash")
+ }
+}
+
+func skipIfNoModTime(t *testing.T, f fs.Fs) {
+ if f.Precision() >= fs.ModTimeNotSupported {
+ t.Skip("Can't run this test without modtimes")
+ }
+}
+
+func TestDeduplicateInteractive(t *testing.T) {
+ r := fstest.NewRun(t)
+ skipIfCantDedupe(t, r.Fremote)
+ skipIfNoHash(t, r.Fremote)
+
+ file1 := r.WriteUncheckedObject(context.Background(), "one", "This is one", t1)
+ file2 := r.WriteUncheckedObject(context.Background(), "one", "This is one", t1)
+ file3 := r.WriteUncheckedObject(context.Background(), "one", "This is one", t1)
+ r.CheckWithDuplicates(t, file1, file2, file3)
+
+ err := operations.Deduplicate(context.Background(), r.Fremote, operations.DeduplicateInteractive, false)
+ require.NoError(t, err)
+
+ r.CheckRemoteItems(t, file1)
+}
+
+func TestDeduplicateSkip(t *testing.T) {
+ r := fstest.NewRun(t)
+ skipIfCantDedupe(t, r.Fremote)
+ haveHash := r.Fremote.Hashes().GetOne() != hash.None
+
+ file1 := r.WriteUncheckedObject(context.Background(), "one", "This is one", t1)
+ files := []fstest.Item{file1}
+ if haveHash {
+ file2 := r.WriteUncheckedObject(context.Background(), "one", "This is one", t1)
+ files = append(files, file2)
+ }
+ file3 := r.WriteUncheckedObject(context.Background(), "one", "This is another one", t1)
+ files = append(files, file3)
+ r.CheckWithDuplicates(t, files...)
+
+ err := operations.Deduplicate(context.Background(), r.Fremote, operations.DeduplicateSkip, false)
+ require.NoError(t, err)
+
+ r.CheckWithDuplicates(t, file1, file3)
+}
+
+func TestDeduplicateSizeOnly(t *testing.T) {
+ r := fstest.NewRun(t)
+ skipIfCantDedupe(t, r.Fremote)
+ ctx := context.Background()
+ ci := fs.GetConfig(ctx)
+
+ file1 := r.WriteUncheckedObject(context.Background(), "one", "This is one", t1)
+ file2 := r.WriteUncheckedObject(context.Background(), "one", "THIS IS ONE", t1)
+ file3 := r.WriteUncheckedObject(context.Background(), "one", "This is another one", t1)
+ r.CheckWithDuplicates(t, file1, file2, file3)
+
+ ci.SizeOnly = true
+ defer func() {
+ ci.SizeOnly = false
+ }()
+
+ err := operations.Deduplicate(context.Background(), r.Fremote, operations.DeduplicateSkip, false)
+ require.NoError(t, err)
+
+ r.CheckWithDuplicates(t, file1, file3)
+}
+
+func TestDeduplicateFirst(t *testing.T) {
+ r := fstest.NewRun(t)
+ skipIfCantDedupe(t, r.Fremote)
+
+ file1 := r.WriteUncheckedObject(context.Background(), "one", "This is one", t1)
+ file2 := r.WriteUncheckedObject(context.Background(), "one", "This is one A", t1)
+ file3 := r.WriteUncheckedObject(context.Background(), "one", "This is one BB", t1)
+ r.CheckWithDuplicates(t, file1, file2, file3)
+
+ err := operations.Deduplicate(context.Background(), r.Fremote, operations.DeduplicateFirst, false)
+ require.NoError(t, err)
+
+ // list until we get one object
+ var objects, size int64
+ for try := 1; try <= *fstest.ListRetries; try++ {
+ objects, size, _, err = operations.Count(context.Background(), r.Fremote)
+ require.NoError(t, err)
+ if objects == 1 {
+ break
+ }
+ time.Sleep(time.Second)
+ }
+ assert.Equal(t, int64(1), objects)
+ if size != file1.Size && size != file2.Size && size != file3.Size {
+ t.Errorf("Size not one of the object sizes %d", size)
+ }
+}
+
+func TestDeduplicateNewest(t *testing.T) {
+ r := fstest.NewRun(t)
+ skipIfCantDedupe(t, r.Fremote)
+ skipIfNoModTime(t, r.Fremote)
+
+ file1 := r.WriteUncheckedObject(context.Background(), "one", "This is one", t1)
+ file2 := r.WriteUncheckedObject(context.Background(), "one", "This is one too", t2)
+ file3 := r.WriteUncheckedObject(context.Background(), "one", "This is another one", t3)
+ r.CheckWithDuplicates(t, file1, file2, file3)
+
+ err := operations.Deduplicate(context.Background(), r.Fremote, operations.DeduplicateNewest, false)
+ require.NoError(t, err)
+
+ r.CheckRemoteItems(t, file3)
+}
+
+func TestDeduplicateNewestByHash(t *testing.T) {
+ r := fstest.NewRun(t)
+ skipIfNoHash(t, r.Fremote)
+ skipIfNoModTime(t, r.Fremote)
+ contents := random.String(100)
+
+ file1 := r.WriteObject(context.Background(), "one", contents, t1)
+ file2 := r.WriteObject(context.Background(), "also/one", contents, t2)
+ file3 := r.WriteObject(context.Background(), "another", contents, t3)
+ file4 := r.WriteObject(context.Background(), "not-one", "stuff", t3)
+ r.CheckRemoteItems(t, file1, file2, file3, file4)
+
+ err := operations.Deduplicate(context.Background(), r.Fremote, operations.DeduplicateNewest, true)
+ require.NoError(t, err)
+
+ r.CheckRemoteItems(t, file3, file4)
+}
+
+func TestDeduplicateOldest(t *testing.T) {
+ r := fstest.NewRun(t)
+ skipIfCantDedupe(t, r.Fremote)
+
+ file1 := r.WriteUncheckedObject(context.Background(), "one", "This is one", t1)
+ file2 := r.WriteUncheckedObject(context.Background(), "one", "This is one too", t2)
+ file3 := r.WriteUncheckedObject(context.Background(), "one", "This is another one", t3)
+ r.CheckWithDuplicates(t, file1, file2, file3)
+
+ err := operations.Deduplicate(context.Background(), r.Fremote, operations.DeduplicateOldest, false)
+ require.NoError(t, err)
+
+ r.CheckRemoteItems(t, file1)
+}
+
+func TestDeduplicateLargest(t *testing.T) {
+ r := fstest.NewRun(t)
+ skipIfCantDedupe(t, r.Fremote)
+
+ file1 := r.WriteUncheckedObject(context.Background(), "one", "This is one", t1)
+ file2 := r.WriteUncheckedObject(context.Background(), "one", "This is one too", t2)
+ file3 := r.WriteUncheckedObject(context.Background(), "one", "This is another one", t3)
+ r.CheckWithDuplicates(t, file1, file2, file3)
+
+ err := operations.Deduplicate(context.Background(), r.Fremote, operations.DeduplicateLargest, false)
+ require.NoError(t, err)
+
+ r.CheckRemoteItems(t, file3)
+}
+
+func TestDeduplicateSmallest(t *testing.T) {
+ r := fstest.NewRun(t)
+ skipIfCantDedupe(t, r.Fremote)
+
+ file1 := r.WriteUncheckedObject(context.Background(), "one", "This is one", t1)
+ file2 := r.WriteUncheckedObject(context.Background(), "one", "This is one too", t2)
+ file3 := r.WriteUncheckedObject(context.Background(), "one", "This is another one", t3)
+ r.CheckWithDuplicates(t, file1, file2, file3)
+
+ err := operations.Deduplicate(context.Background(), r.Fremote, operations.DeduplicateSmallest, false)
+ require.NoError(t, err)
+
+ r.CheckRemoteItems(t, file1)
+}
+
+func TestDeduplicateRename(t *testing.T) {
+ r := fstest.NewRun(t)
+ skipIfCantDedupe(t, r.Fremote)
+
+ file1 := r.WriteUncheckedObject(context.Background(), "one.txt", "This is one", t1)
+ file2 := r.WriteUncheckedObject(context.Background(), "one.txt", "This is one too", t2)
+ file3 := r.WriteUncheckedObject(context.Background(), "one.txt", "This is another one", t3)
+ file4 := r.WriteUncheckedObject(context.Background(), "one-1.txt", "This is not a duplicate", t1)
+ r.CheckWithDuplicates(t, file1, file2, file3, file4)
+
+ err := operations.Deduplicate(context.Background(), r.Fremote, operations.DeduplicateRename, false)
+ require.NoError(t, err)
+
+ require.NoError(t, walk.ListR(context.Background(), r.Fremote, "", true, -1, walk.ListObjects, func(entries fs.DirEntries) error {
+ entries.ForObject(func(o fs.Object) {
+ remote := o.Remote()
+ if remote != "one-1.txt" &&
+ remote != "one-2.txt" &&
+ remote != "one-3.txt" &&
+ remote != "one-4.txt" {
+ t.Errorf("Bad file name after rename %q", remote)
+ }
+ size := o.Size()
+ if size != file1.Size &&
+ size != file2.Size &&
+ size != file3.Size &&
+ size != file4.Size {
+ t.Errorf("Size not one of the object sizes %d", size)
+ }
+ if remote == "one-1.txt" && size != file4.Size {
+ t.Errorf("Existing non-duplicate file modified %q", remote)
+ }
+ })
+ return nil
+ }))
+}
+
+// This should really be a unit test, but the test framework there
+// doesn't have enough tools to make it easy
+func TestMergeDirs(t *testing.T) {
+ r := fstest.NewRun(t)
+
+ mergeDirs := r.Fremote.Features().MergeDirs
+ if mergeDirs == nil {
+ t.Skip("Can't merge directories")
+ }
+
+ file1 := r.WriteObject(context.Background(), "dupe1/one.txt", "This is one", t1)
+ file2 := r.WriteObject(context.Background(), "dupe2/two.txt", "This is one too", t2)
+ file3 := r.WriteObject(context.Background(), "dupe3/three.txt", "This is another one", t3)
+
+ objs, dirs, err := walk.GetAll(context.Background(), r.Fremote, "", true, 1)
+ require.NoError(t, err)
+ assert.Equal(t, 3, len(dirs))
+ assert.Equal(t, 0, len(objs))
+
+ err = mergeDirs(context.Background(), dirs)
+ require.NoError(t, err)
+
+ file2.Path = "dupe1/two.txt"
+ file3.Path = "dupe1/three.txt"
+ r.CheckRemoteItems(t, file1, file2, file3)
+
+ objs, dirs, err = walk.GetAll(context.Background(), r.Fremote, "", true, 1)
+ require.NoError(t, err)
+ assert.Equal(t, 1, len(dirs))
+ assert.Equal(t, 0, len(objs))
+ assert.Equal(t, "dupe1", dirs[0].Remote())
+}
diff --git a/fs/operations/listdirsorted_test.go b/fs/operations/listdirsorted_test.go
new file mode 100644
index 0000000..4719d6c
--- /dev/null
+++ b/fs/operations/listdirsorted_test.go
@@ -0,0 +1,126 @@
+package operations_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/fs/filter"
+ "github.com/rclone/rclone/fs/list"
+ "github.com/rclone/rclone/fstest"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// testListDirSorted is integration testing code in fs/list/list.go
+// which can't be tested there due to import loops.
+func testListDirSorted(t *testing.T, listFn func(ctx context.Context, f fs.Fs, includeAll bool, dir string) (entries fs.DirEntries, err error)) {
+ r := fstest.NewRun(t)
+
+ ctx := context.Background()
+ fi := filter.GetConfig(ctx)
+ fi.Opt.MaxSize = 10
+ defer func() {
+ fi.Opt.MaxSize = -1
+ }()
+
+ files := []fstest.Item{
+ r.WriteObject(context.Background(), "a.txt", "hello world", t1),
+ r.WriteObject(context.Background(), "zend.txt", "hello", t1),
+ r.WriteObject(context.Background(), "sub dir/hello world", "hello world", t1),
+ r.WriteObject(context.Background(), "sub dir/hello world2", "hello world", t1),
+ r.WriteObject(context.Background(), "sub dir/ignore dir/.ignore", "-", t1),
+ r.WriteObject(context.Background(), "sub dir/ignore dir/should be ignored", "to ignore", t1),
+ r.WriteObject(context.Background(), "sub dir/sub sub dir/hello world3", "hello world", t1),
+ }
+ r.CheckRemoteItems(t, files...)
+ var items fs.DirEntries
+ var err error
+
+ // Turn the DirEntry into a name, ending with a / if it is a
+ // dir
+ str := func(i int) string {
+ item := items[i]
+ name := item.Remote()
+ switch item.(type) {
+ case fs.Object:
+ case fs.Directory:
+ name += "/"
+ default:
+ t.Fatalf("Unknown type %+v", item)
+ }
+ return name
+ }
+
+ items, err = listFn(context.Background(), r.Fremote, true, "")
+ require.NoError(t, err)
+ require.Len(t, items, 3)
+ assert.Equal(t, "a.txt", str(0))
+ assert.Equal(t, "sub dir/", str(1))
+ assert.Equal(t, "zend.txt", str(2))
+
+ items, err = listFn(context.Background(), r.Fremote, false, "")
+ require.NoError(t, err)
+ require.Len(t, items, 2)
+ assert.Equal(t, "sub dir/", str(0))
+ assert.Equal(t, "zend.txt", str(1))
+
+ items, err = listFn(context.Background(), r.Fremote, true, "sub dir")
+ require.NoError(t, err)
+ require.Len(t, items, 4)
+ assert.Equal(t, "sub dir/hello world", str(0))
+ assert.Equal(t, "sub dir/hello world2", str(1))
+ assert.Equal(t, "sub dir/ignore dir/", str(2))
+ assert.Equal(t, "sub dir/sub sub dir/", str(3))
+
+ items, err = listFn(context.Background(), r.Fremote, false, "sub dir")
+ require.NoError(t, err)
+ require.Len(t, items, 2)
+ assert.Equal(t, "sub dir/ignore dir/", str(0))
+ assert.Equal(t, "sub dir/sub sub dir/", str(1))
+
+ // testing ignore file
+ fi.Opt.ExcludeFile = []string{".ignore"}
+
+ items, err = listFn(context.Background(), r.Fremote, false, "sub dir")
+ require.NoError(t, err)
+ require.Len(t, items, 1)
+ assert.Equal(t, "sub dir/sub sub dir/", str(0))
+
+ items, err = listFn(context.Background(), r.Fremote, false, "sub dir/ignore dir")
+ require.NoError(t, err)
+ require.Len(t, items, 0)
+
+ items, err = listFn(context.Background(), r.Fremote, true, "sub dir/ignore dir")
+ require.NoError(t, err)
+ require.Len(t, items, 2)
+ assert.Equal(t, "sub dir/ignore dir/.ignore", str(0))
+ assert.Equal(t, "sub dir/ignore dir/should be ignored", str(1))
+
+ fi.Opt.ExcludeFile = nil
+ items, err = listFn(context.Background(), r.Fremote, false, "sub dir/ignore dir")
+ require.NoError(t, err)
+ require.Len(t, items, 2)
+ assert.Equal(t, "sub dir/ignore dir/.ignore", str(0))
+ assert.Equal(t, "sub dir/ignore dir/should be ignored", str(1))
+}
+
+// TestListDirSorted is integration testing code in fs/list/list.go
+// which can't be tested there due to import loops.
+func TestListDirSorted(t *testing.T) {
+ testListDirSorted(t, list.DirSorted)
+}
+
+// TestListDirSortedFn is integration testing code in fs/list/list.go
+// which can't be tested there due to import loops.
+func TestListDirSortedFn(t *testing.T) {
+ listFn := func(ctx context.Context, f fs.Fs, includeAll bool, dir string) (entries fs.DirEntries, err error) {
+ callback := func(newEntries fs.DirEntries) error {
+ entries = append(entries, newEntries...)
+ return nil
+ }
+ err = list.DirSortedFn(ctx, f, includeAll, dir, callback, nil)
+ return entries, err
+ }
+ testListDirSorted(t, listFn)
+}
diff --git a/fs/operations/logger.go b/fs/operations/logger.go
new file mode 100644
index 0000000..0d48220
--- /dev/null
+++ b/fs/operations/logger.go
@@ -0,0 +1,384 @@
+package operations
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ mutex "sync"
+
+ "github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/fs/hash"
+ "github.com/spf13/pflag"
+)
+
+// Sigil represents the rune (-+=*!?) used by Logger to categorize files by their match/differ/missing status.
+type Sigil rune
+
+// String converts sigil to more human-readable string
+func (sigil Sigil) String() string {
+ switch sigil {
+ case '-':
+ return "MissingOnSrc"
+ case '+':
+ return "MissingOnDst"
+ case '=':
+ return "Match"
+ case '*':
+ return "Differ"
+ case '!':
+ return "Error"
+ // case '.':
+ // return "Completed"
+ case '?':
+ return "Other"
+ }
+ return "unknown"
+}
+
+// Writer directs traffic from sigil -> LoggerOpt.Writer
+func (sigil Sigil) Writer(opt LoggerOpt) io.Writer {
+ switch sigil {
+ case '-':
+ return opt.MissingOnSrc
+ case '+':
+ return opt.MissingOnDst
+ case '=':
+ return opt.Match
+ case '*':
+ return opt.Differ
+ case '!':
+ return opt.Error
+ }
+ return nil
+}
+
+// Sigil constants
+const (
+ MissingOnSrc Sigil = '-'
+ MissingOnDst Sigil = '+'
+ Match Sigil = '='
+ Differ Sigil = '*'
+ TransferError Sigil = '!'
+ Other Sigil = '?' // reserved but not currently used
+)
+
+// LoggerFn uses fs.DirEntry instead of fs.Object so it can include Dirs
+// For LoggerFn example, see bisync.WriteResults() or sync.SyncLoggerFn()
+// Usage example: s.logger(ctx, operations.Differ, src, dst, nil)
+type LoggerFn func(ctx context.Context, sigil Sigil, src, dst fs.DirEntry, err error)
+type loggerContextKey struct{}
+type loggerOptContextKey struct{}
+
+var loggerKey = loggerContextKey{}
+var loggerOptKey = loggerOptContextKey{}
+
+// LoggerOpt contains options for the Sync Logger functions
+// TODO: refactor Check in here too?
+type LoggerOpt struct {
+ // Fdst, Fsrc fs.Fs // fses to check
+ // Check checkFn // function to use for checking
+ // OneWay bool // one way only?
+ LoggerFn LoggerFn // function to use for logging
+ Combined io.Writer // a file with file names with leading sigils
+ MissingOnSrc io.Writer // files only in the destination
+ MissingOnDst io.Writer // files only in the source
+ Match io.Writer // matching files
+ Differ io.Writer // differing files
+ Error io.Writer // files with errors of some kind
+ DestAfter io.Writer // files that exist on the destination post-sync
+ JSON *bytes.Buffer // used by bisync to read/write struct as JSON
+ DeleteModeOff bool //affects whether Logger expects MissingOnSrc to be deleted
+
+ // lsf options for destAfter
+ ListFormat ListFormat
+ JSONOpt ListJSONOpt
+ LJ *listJSON
+ Format string
+ TimeFormat string
+ Separator string
+ DirSlash bool
+ // Recurse bool
+ HashType hash.Type
+ FilesOnly bool
+ DirsOnly bool
+ Csv bool
+ Absolute bool
+}
+
+// NewDefaultLoggerFn creates a logger function that writes the sigil and path to configured files that match the sigil
+func NewDefaultLoggerFn(opt *LoggerOpt) LoggerFn {
+ var lock mutex.Mutex
+
+ return func(ctx context.Context, sigil Sigil, src, dst fs.DirEntry, err error) {
+ lock.Lock()
+ defer lock.Unlock()
+
+ if err == fs.ErrorIsDir && !opt.FilesOnly && opt.DestAfter != nil {
+ opt.PrintDestAfter(ctx, sigil, src, dst, err)
+ return
+ }
+
+ _, srcOk := src.(fs.Object)
+ _, dstOk := dst.(fs.Object)
+ var filename string
+ if !srcOk && !dstOk {
+ return
+ } else if srcOk && !dstOk {
+ filename = src.String()
+ } else {
+ filename = dst.String()
+ }
+
+ if sigil.Writer(*opt) != nil {
+ SyncFprintf(sigil.Writer(*opt), "%s\n", filename)
+ }
+ if opt.Combined != nil {
+ SyncFprintf(opt.Combined, "%c %s\n", sigil, filename)
+ fs.Debugf(nil, "Sync Logger: %s: %c %s\n", sigil.String(), sigil, filename)
+ }
+ if opt.DestAfter != nil {
+ opt.PrintDestAfter(ctx, sigil, src, dst, err)
+ }
+ }
+}
+
+// WithLogger stores logger in ctx and returns a copy of ctx in which loggerKey = logger
+func WithLogger(ctx context.Context, logger LoggerFn) context.Context {
+ return context.WithValue(ctx, loggerKey, logger)
+}
+
+// WithLoggerOpt stores loggerOpt in ctx and returns a copy of ctx in which loggerOptKey = loggerOpt
+func WithLoggerOpt(ctx context.Context, loggerOpt LoggerOpt) context.Context {
+ return context.WithValue(ctx, loggerOptKey, loggerOpt)
+}
+
+// GetLogger attempts to retrieve LoggerFn from context, returns it if found, otherwise returns no-op function
+func GetLogger(ctx context.Context) (LoggerFn, bool) {
+ logger, ok := ctx.Value(loggerKey).(LoggerFn)
+ if !ok {
+ logger = func(ctx context.Context, sigil Sigil, src, dst fs.DirEntry, err error) {}
+ }
+ return logger, ok
+}
+
+// GetLoggerOpt attempts to retrieve LoggerOpt from context, returns it if found, otherwise returns NewLoggerOpt()
+func GetLoggerOpt(ctx context.Context) LoggerOpt {
+ loggerOpt, ok := ctx.Value(loggerOptKey).(LoggerOpt)
+ if ok {
+ return loggerOpt
+ }
+ return NewLoggerOpt()
+}
+
+// WithSyncLogger starts a new logger with the options passed in and saves it to ctx for retrieval later
+func WithSyncLogger(ctx context.Context, opt LoggerOpt) context.Context {
+ ctx = WithLoggerOpt(ctx, opt)
+ return WithLogger(ctx, func(ctx context.Context, sigil Sigil, src, dst fs.DirEntry, err error) {
+ if opt.LoggerFn != nil {
+ opt.LoggerFn(ctx, sigil, src, dst, err)
+ } else {
+ SyncFprintf(opt.Combined, "%c %s\n", sigil, dst.Remote())
+ }
+ })
+}
+
+// NewLoggerOpt returns a new LoggerOpt struct with defaults
+func NewLoggerOpt() LoggerOpt {
+ opt := LoggerOpt{
+ Combined: new(bytes.Buffer),
+ MissingOnSrc: new(bytes.Buffer),
+ MissingOnDst: new(bytes.Buffer),
+ Match: new(bytes.Buffer),
+ Differ: new(bytes.Buffer),
+ Error: new(bytes.Buffer),
+ DestAfter: new(bytes.Buffer),
+ JSON: new(bytes.Buffer),
+ }
+ return opt
+}
+
+// Winner predicts which side (src or dst) should end up winning out on the dst.
+type Winner struct {
+ Obj fs.DirEntry // the object that should exist on dst post-sync, if any
+ Side string // whether the winning object was from the src or dst
+ Err error // whether there's an error preventing us from predicting winner correctly (not whether there was a sync error more generally)
+}
+
+// WinningSide can be called in a LoggerFn to predict what the dest will look like post-sync
+//
+// This attempts to account for every case in which dst (intentionally) does not match src after a sync.
+//
+// Known issues / cases we can't confidently predict yet:
+//
+// --max-duration / CutoffModeHard
+// --compare-dest / --copy-dest (because equal() is called multiple times for the same file)
+// server-side moves of an entire dir at once (because we never get the individual file objects in the dir)
+// High-level retries, because there would be dupes (use --retries 1 to disable)
+// Possibly some error scenarios
+func WinningSide(ctx context.Context, sigil Sigil, src, dst fs.DirEntry, err error) Winner {
+ winner := Winner{nil, "none", nil}
+ opt := GetLoggerOpt(ctx)
+ ci := fs.GetConfig(ctx)
+
+ if err == fs.ErrorIsDir {
+ winner.Err = err
+ if sigil == MissingOnSrc {
+ if (opt.DeleteModeOff || ci.DryRun) && dst != nil {
+ winner.Obj = dst
+ winner.Side = "dst" // whatever's on dst will remain so after DryRun
+ return winner
+ }
+ return winner // none, because dst should just get deleted
+ }
+ if sigil == MissingOnDst && ci.DryRun {
+ return winner // none, because it does not currently exist on dst, and will still not exist after DryRun
+ } else if ci.DryRun && dst != nil {
+ winner.Obj = dst
+ winner.Side = "dst"
+ } else if src != nil {
+ winner.Obj = src
+ winner.Side = "src"
+ }
+ return winner
+ }
+
+ _, srcOk := src.(fs.Object)
+ _, dstOk := dst.(fs.Object)
+ if !srcOk && !dstOk {
+ return winner // none, because we don't have enough info to continue.
+ }
+
+ switch sigil {
+ case MissingOnSrc:
+ if opt.DeleteModeOff || ci.DryRun { // i.e. it's a copy, not sync (or it's a DryRun)
+ winner.Obj = dst
+ winner.Side = "dst" // whatever's on dst will remain so after DryRun
+ return winner
+ }
+ return winner // none, because dst should just get deleted
+ case Match, Differ, MissingOnDst:
+ if sigil == MissingOnDst && ci.DryRun {
+ return winner // none, because it does not currently exist on dst, and will still not exist after DryRun
+ }
+ winner.Obj = src
+ winner.Side = "src" // presume dst will end up matching src unless changed below
+ if sigil == Match && (ci.SizeOnly || ci.CheckSum || ci.IgnoreSize || ci.UpdateOlder || ci.NoUpdateModTime) {
+ winner.Obj = dst
+ winner.Side = "dst" // ignore any differences with src because of user flags
+ }
+ if ci.IgnoreTimes {
+ winner.Obj = src
+ winner.Side = "src" // copy src to dst unconditionally
+ }
+ if (sigil == Match || sigil == Differ) && (ci.IgnoreExisting || ci.Immutable) {
+ winner.Obj = dst
+ winner.Side = "dst" // dst should remain unchanged if it already exists (and we know it does because it's Match or Differ)
+ }
+ if ci.DryRun {
+ winner.Obj = dst
+ winner.Side = "dst" // dst should remain unchanged after DryRun (note that we handled MissingOnDst earlier)
+ }
+ return winner
+ case TransferError:
+ winner.Obj = dst
+ winner.Side = "dst" // usually, dst should not change if there's an error
+ if dst == nil {
+ winner.Obj = src
+ winner.Side = "src" // but if for some reason we have a src and not a dst, go with it
+ }
+ if winner.Obj != nil {
+ if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, errors.New("max transfer duration reached as set by --max-duration")) {
+ winner.Err = err // we can't confidently predict what survives if CutoffModeHard
+ }
+ return winner // we know at least one of the objects
+ }
+ }
+ // should only make it this far if it's TransferError and both src and dst are nil
+ winner.Side = "none"
+ winner.Err = fmt.Errorf("unknown case -- can't determine winner. %v", err)
+ fs.Debugf(winner.Obj, "%v", winner.Err)
+ return winner
+}
+
+// SetListFormat sets opt.ListFormat for destAfter
+// TODO: possibly refactor duplicate code from cmd/lsf, where this is mostly copied from
+func (opt *LoggerOpt) SetListFormat(ctx context.Context, cmdFlags *pflag.FlagSet) {
+ // Work out if the separatorFlag was supplied or not
+ separatorFlag := cmdFlags.Lookup("separator")
+ separatorFlagSupplied := separatorFlag != nil && separatorFlag.Changed
+ // Default the separator to , if using CSV
+ if opt.Csv && !separatorFlagSupplied {
+ opt.Separator = ","
+ }
+
+ var list ListFormat
+ list.SetSeparator(opt.Separator)
+ list.SetCSV(opt.Csv)
+ list.SetDirSlash(opt.DirSlash)
+ list.SetAbsolute(opt.Absolute)
+ var JSONOpt = ListJSONOpt{
+ NoModTime: true,
+ NoMimeType: true,
+ DirsOnly: opt.DirsOnly,
+ FilesOnly: opt.FilesOnly,
+ // Recurse: opt.Recurse,
+ }
+
+ for _, char := range opt.Format {
+ switch char {
+ case 'p':
+ list.AddPath()
+ case 't':
+ list.AddModTime(opt.TimeFormat)
+ JSONOpt.NoModTime = false
+ case 's':
+ list.AddSize()
+ case 'h':
+ list.AddHash(opt.HashType)
+ JSONOpt.ShowHash = true
+ JSONOpt.HashTypes = []string{opt.HashType.String()}
+ case 'i':
+ list.AddID()
+ case 'm':
+ list.AddMimeType()
+ JSONOpt.NoMimeType = false
+ case 'e':
+ list.AddEncrypted()
+ JSONOpt.ShowEncrypted = true
+ case 'o':
+ list.AddOrigID()
+ JSONOpt.ShowOrigIDs = true
+ case 'T':
+ list.AddTier()
+ case 'M':
+ list.AddMetadata()
+ JSONOpt.Metadata = true
+ default:
+ fs.Errorf(nil, "unknown format character %q", char)
+ }
+ }
+ opt.ListFormat = list
+ opt.JSONOpt = JSONOpt
+}
+
+// NewListJSON makes a new *listJSON for destAfter
+func (opt *LoggerOpt) NewListJSON(ctx context.Context, fdst fs.Fs, remote string) {
+ opt.LJ, _ = newListJSON(ctx, fdst, remote, &opt.JSONOpt)
+ //fs.Debugf(nil, "%v", opt.LJ)
+}
+
+// JSONEntry returns a *ListJSONItem for destAfter
+func (opt *LoggerOpt) JSONEntry(ctx context.Context, entry fs.DirEntry) (*ListJSONItem, error) {
+ return opt.LJ.entry(ctx, entry)
+}
+
+// PrintDestAfter writes a *ListJSONItem to opt.DestAfter
+func (opt *LoggerOpt) PrintDestAfter(ctx context.Context, sigil Sigil, src, dst fs.DirEntry, err error) {
+ entry := WinningSide(ctx, sigil, src, dst, err)
+ if entry.Obj != nil {
+ JSONEntry, _ := opt.JSONEntry(ctx, entry.Obj)
+ _, _ = fmt.Fprintln(opt.DestAfter, opt.ListFormat.Format(JSONEntry))
+ }
+}
diff --git a/fs/operations/lsjson.go b/fs/operations/lsjson.go
new file mode 100644
index 0000000..8e714ef
--- /dev/null
+++ b/fs/operations/lsjson.go
@@ -0,0 +1,348 @@
+package operations
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "path"
+ "strings"
+ "time"
+
+ "github.com/rclone/rclone/backend/crypt"
+ "github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/fs/accounting"
+ "github.com/rclone/rclone/fs/hash"
+ "github.com/rclone/rclone/fs/walk"
+)
+
+// ListJSONItem in the struct which gets marshalled for each line
+type ListJSONItem struct {
+ Path string
+ Name string
+ EncryptedPath string `json:",omitempty"`
+ Encrypted string `json:",omitempty"`
+ Size int64
+ MimeType string `json:",omitempty"`
+ ModTime Timestamp //`json:",omitempty"`
+ IsDir bool
+ Hashes map[string]string `json:",omitempty"`
+ ID string `json:",omitempty"`
+ OrigID string `json:",omitempty"`
+ Tier string `json:",omitempty"`
+ IsBucket bool `json:",omitempty"`
+ Metadata fs.Metadata `json:",omitempty"`
+}
+
+// Timestamp a time in the provided format
+type Timestamp struct {
+ When time.Time
+ Format string
+}
+
+// MarshalJSON turns a Timestamp into JSON
+func (t Timestamp) MarshalJSON() (out []byte, err error) {
+ if t.When.IsZero() {
+ return []byte(`""`), nil
+ }
+ return []byte(`"` + t.When.Format(t.Format) + `"`), nil
+}
+
+// Returns a time format for the given precision
+func formatForPrecision(precision time.Duration) string {
+ switch {
+ case precision <= time.Nanosecond:
+ return "2006-01-02T15:04:05.000000000Z07:00"
+ case precision <= 10*time.Nanosecond:
+ return "2006-01-02T15:04:05.00000000Z07:00"
+ case precision <= 100*time.Nanosecond:
+ return "2006-01-02T15:04:05.0000000Z07:00"
+ case precision <= time.Microsecond:
+ return "2006-01-02T15:04:05.000000Z07:00"
+ case precision <= 10*time.Microsecond:
+ return "2006-01-02T15:04:05.00000Z07:00"
+ case precision <= 100*time.Microsecond:
+ return "2006-01-02T15:04:05.0000Z07:00"
+ case precision <= time.Millisecond:
+ return "2006-01-02T15:04:05.000Z07:00"
+ case precision <= 10*time.Millisecond:
+ return "2006-01-02T15:04:05.00Z07:00"
+ case precision <= 100*time.Millisecond:
+ return "2006-01-02T15:04:05.0Z07:00"
+ }
+ return time.RFC3339
+}
+
+// ListJSONOpt describes the options for ListJSON
+type ListJSONOpt struct {
+ Recurse bool `json:"recurse"`
+ NoModTime bool `json:"noModTime"`
+ NoMimeType bool `json:"noMimeType"`
+ ShowEncrypted bool `json:"showEncrypted"`
+ ShowOrigIDs bool `json:"showOrigIDs"`
+ 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"
+}
+
+// state for ListJson
+type listJSON struct {
+ fsrc fs.Fs
+ remote string
+ format string
+ opt *ListJSONOpt
+ cipher *crypt.Cipher
+ hashTypes []hash.Type
+ dirs bool
+ files bool
+ canGetTier bool
+ isBucket bool
+ showHash bool
+}
+
+func newListJSON(ctx context.Context, fsrc fs.Fs, remote string, opt *ListJSONOpt) (*listJSON, error) {
+ lj := &listJSON{
+ fsrc: fsrc,
+ remote: remote,
+ opt: opt,
+ dirs: true,
+ files: true,
+ }
+ // Dirs Files
+ // !FilesOnly,!DirsOnly true true
+ // !FilesOnly,DirsOnly true false
+ // FilesOnly,!DirsOnly false true
+ // FilesOnly,DirsOnly true true
+ if !opt.FilesOnly && opt.DirsOnly {
+ lj.files = false
+ } else if opt.FilesOnly && !opt.DirsOnly {
+ lj.dirs = false
+ }
+ if opt.ShowEncrypted {
+ fsInfo, _, _, config, err := fs.ConfigFs(fs.ConfigStringFull(fsrc))
+ if err != nil {
+ return nil, fmt.Errorf("ListJSON failed to load config for crypt remote: %w", err)
+ }
+ if fsInfo.Name != "crypt" {
+ return nil, errors.New("the remote needs to be of type \"crypt\"")
+ }
+ lj.cipher, err = crypt.NewCipher(config)
+ if err != nil {
+ return nil, fmt.Errorf("ListJSON failed to make new crypt remote: %w", err)
+ }
+ }
+ features := fsrc.Features()
+ lj.canGetTier = features.GetTier
+ lj.format = formatForPrecision(fsrc.Precision())
+ lj.isBucket = features.BucketBased && remote == "" && fsrc.Root() == "" // if bucket-based remote listing the root mark directories as buckets
+ lj.showHash = opt.ShowHash
+ lj.hashTypes = fsrc.Hashes().Array()
+ if len(opt.HashTypes) != 0 {
+ lj.showHash = true
+ lj.hashTypes = []hash.Type{}
+ for _, hashType := range opt.HashTypes {
+ var ht hash.Type
+ err := ht.Set(hashType)
+ if err != nil {
+ return nil, err
+ }
+ lj.hashTypes = append(lj.hashTypes, ht)
+ }
+ }
+ return lj, nil
+}
+
+// Convert a single entry to JSON
+//
+// It may return nil if there is no entry to return
+func (lj *listJSON) entry(ctx context.Context, entry fs.DirEntry) (*ListJSONItem, error) {
+ switch entry.(type) {
+ case fs.Directory:
+ if lj.opt.FilesOnly {
+ return nil, nil
+ }
+ case fs.Object:
+ if lj.opt.DirsOnly {
+ return nil, nil
+ }
+ default:
+ fs.Errorf(nil, "Unknown type %T in listing", entry)
+ }
+
+ item := &ListJSONItem{
+ Path: entry.Remote(),
+ Name: path.Base(entry.Remote()),
+ Size: entry.Size(),
+ }
+ if entry.Remote() == "" {
+ item.Name = ""
+ }
+ if !lj.opt.NoModTime {
+ item.ModTime = Timestamp{When: entry.ModTime(ctx), Format: lj.format}
+ }
+ if !lj.opt.NoMimeType {
+ item.MimeType = fs.MimeTypeDirEntry(ctx, entry)
+ }
+ if lj.cipher != nil {
+ switch entry.(type) {
+ case fs.Directory:
+ item.EncryptedPath = lj.cipher.EncryptDirName(entry.Remote())
+ case fs.Object:
+ item.EncryptedPath = lj.cipher.EncryptFileName(entry.Remote())
+ default:
+ fs.Errorf(nil, "Unknown type %T in listing", entry)
+ }
+ item.Encrypted = path.Base(item.EncryptedPath)
+ }
+ if lj.opt.Metadata {
+ metadata, err := fs.GetMetadata(ctx, entry)
+ if err != nil {
+ fs.Errorf(entry, "Failed to read metadata: %v", err)
+ } else if metadata != nil {
+ item.Metadata = metadata
+ }
+ }
+ if do, ok := entry.(fs.IDer); ok {
+ item.ID = do.ID()
+ }
+ if o, ok := entry.(fs.Object); lj.opt.ShowOrigIDs && ok {
+ if do, ok := fs.UnWrapObject(o).(fs.IDer); ok {
+ item.OrigID = do.ID()
+ }
+ }
+ switch x := entry.(type) {
+ case fs.Directory:
+ item.IsDir = true
+ item.IsBucket = lj.isBucket
+ case fs.Object:
+ item.IsDir = false
+ if lj.showHash {
+ item.Hashes = make(map[string]string)
+ for _, hashType := range lj.hashTypes {
+ hash, err := x.Hash(ctx, hashType)
+ if err != nil {
+ fs.Errorf(x, "Failed to read hash: %v", err)
+ } else if hash != "" {
+ item.Hashes[hashType.String()] = hash
+ }
+ }
+ }
+ if lj.canGetTier {
+ if do, ok := x.(fs.GetTierer); ok {
+ item.Tier = do.GetTier()
+ }
+ }
+ default:
+ fs.Errorf(nil, "Unknown type %T in listing in ListJSON", entry)
+ }
+ return item, nil
+}
+
+// ListJSON lists fsrc using the options in opt calling callback for each item
+func ListJSON(ctx context.Context, fsrc fs.Fs, remote string, opt *ListJSONOpt, callback func(*ListJSONItem) error) error {
+ lj, err := newListJSON(ctx, fsrc, remote, opt)
+ if err != nil {
+ return err
+ }
+ err = walk.ListR(ctx, fsrc, remote, false, ConfigMaxDepth(ctx, lj.opt.Recurse), walk.ListAll, func(entries fs.DirEntries) (err error) {
+ for _, entry := range entries {
+ item, err := lj.entry(ctx, entry)
+ if err != nil {
+ return fmt.Errorf("creating entry failed in ListJSON: %w", err)
+ }
+ if item != nil {
+ err = callback(item)
+ if err != nil {
+ return fmt.Errorf("callback failed in ListJSON: %w", err)
+ }
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ return fmt.Errorf("error in ListJSON: %w", err)
+ }
+ return nil
+}
+
+// StatJSON returns a single JSON stat entry for the fsrc, remote path
+//
+// The item returned may be nil if it is not found or excluded with DirsOnly/FilesOnly
+func StatJSON(ctx context.Context, fsrc fs.Fs, remote string, opt *ListJSONOpt) (item *ListJSONItem, err error) {
+ // FIXME this could me more efficient we had a new primitive
+ // NewDirEntry() which returned an Object or a Directory
+ lj, err := newListJSON(ctx, fsrc, remote, opt)
+ if err != nil {
+ return nil, err
+ }
+
+ // Root is always a directory. When we have a NewDirEntry
+ // primitive we need to call it, but for now this will do.
+ if remote == "" {
+ if !lj.dirs {
+ return nil, nil
+ }
+ // Check the root directory exists
+ entries, err := fsrc.List(ctx, "")
+ accounting.Stats(ctx).Listed(int64(len(entries)))
+ if err != nil {
+ return nil, err
+ }
+ return lj.entry(ctx, fs.NewDir("", time.Now()))
+ }
+
+ // Could be a file or a directory here
+ if lj.files && !strings.HasSuffix(remote, "/") {
+ // NewObject can return the sentinel errors ErrorObjectNotFound or ErrorIsDir
+ // ErrorObjectNotFound can mean the source is a directory or not found
+ obj, err := fsrc.NewObject(ctx, remote)
+ if err == fs.ErrorObjectNotFound {
+ if !lj.dirs {
+ return nil, nil
+ }
+ } else if err == fs.ErrorIsDir {
+ if !lj.dirs {
+ return nil, nil
+ }
+ // This could return a made up ListJSONItem here
+ // but that wouldn't have the IDs etc in
+ } else if err != nil {
+ if !lj.dirs {
+ return nil, err
+ }
+ } else {
+ return lj.entry(ctx, obj)
+ }
+ }
+ // Must be a directory here
+ //
+ // Remove trailing / as rclone listings won't have them
+ remote = strings.TrimRight(remote, "/")
+ parent := path.Dir(remote)
+ if parent == "." || parent == "/" {
+ parent = ""
+ }
+ entries, err := fsrc.List(ctx, parent)
+ accounting.Stats(ctx).Listed(int64(len(entries)))
+ if err == fs.ErrorDirNotFound {
+ return nil, nil
+ } else if err != nil {
+ return nil, err
+ }
+ equal := func(a, b string) bool { return a == b }
+ if fsrc.Features().CaseInsensitive {
+ equal = strings.EqualFold
+ }
+ var foundEntry fs.DirEntry
+ for _, entry := range entries {
+ if equal(entry.Remote(), remote) {
+ foundEntry = entry
+ break
+ }
+ }
+ if foundEntry == nil {
+ return nil, nil
+ }
+ return lj.entry(ctx, foundEntry)
+}
diff --git a/fs/operations/lsjson_test.go b/fs/operations/lsjson_test.go
new file mode 100644
index 0000000..75d4728
--- /dev/null
+++ b/fs/operations/lsjson_test.go
@@ -0,0 +1,406 @@
+package operations_test
+
+import (
+ "context"
+ "sort"
+ "testing"
+ "time"
+
+ "github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/fs/operations"
+ "github.com/rclone/rclone/fstest"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// Compare a and b in a file system independent way
+func compareListJSONItem(t *testing.T, a, b *operations.ListJSONItem, precision time.Duration) {
+ assert.Equal(t, a.Path, b.Path, "Path")
+ assert.Equal(t, a.Name, b.Name, "Name")
+ // assert.Equal(t, a.EncryptedPath, b.EncryptedPath, "EncryptedPath")
+ // assert.Equal(t, a.Encrypted, b.Encrypted, "Encrypted")
+ if !a.IsDir {
+ assert.Equal(t, a.Size, b.Size, "Size")
+ }
+ // assert.Equal(t, a.MimeType, a.Mib.MimeType, "MimeType")
+ if !a.IsDir {
+ fstest.AssertTimeEqualWithPrecision(t, "ListJSON", a.ModTime.When, b.ModTime.When, precision)
+ }
+ assert.Equal(t, a.IsDir, b.IsDir, "IsDir")
+ // assert.Equal(t, a.Hashes, a.b.Hashes, "Hashes")
+ // assert.Equal(t, a.ID, b.ID, "ID")
+ // assert.Equal(t, a.OrigID, a.b.OrigID, "OrigID")
+ // assert.Equal(t, a.Tier, b.Tier, "Tier")
+ // assert.Equal(t, a.IsBucket, a.Isb.IsBucket, "IsBucket")
+}
+
+func TestListJSON(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ file1 := r.WriteBoth(ctx, "file1", "file1", t1)
+ file2 := r.WriteBoth(ctx, "sub/file2", "sub/file2", t2)
+
+ r.CheckRemoteItems(t, file1, file2)
+ precision := fs.GetModifyWindow(ctx, r.Fremote)
+
+ for _, test := range []struct {
+ name string
+ remote string
+ opt operations.ListJSONOpt
+ want []*operations.ListJSONItem
+ }{
+ {
+ name: "Default",
+ opt: operations.ListJSONOpt{},
+ want: []*operations.ListJSONItem{{
+ Path: "file1",
+ Name: "file1",
+ Size: 5,
+ ModTime: operations.Timestamp{When: t1},
+ IsDir: false,
+ }, {
+ Path: "sub",
+ Name: "sub",
+ IsDir: true,
+ }},
+ }, {
+ name: "FilesOnly",
+ opt: operations.ListJSONOpt{
+ FilesOnly: true,
+ },
+ want: []*operations.ListJSONItem{{
+ Path: "file1",
+ Name: "file1",
+ Size: 5,
+ ModTime: operations.Timestamp{When: t1},
+ IsDir: false,
+ }},
+ }, {
+ name: "DirsOnly",
+ opt: operations.ListJSONOpt{
+ DirsOnly: true,
+ },
+ want: []*operations.ListJSONItem{{
+ Path: "sub",
+ Name: "sub",
+ IsDir: true,
+ }},
+ }, {
+ name: "Recurse",
+ opt: operations.ListJSONOpt{
+ Recurse: true,
+ },
+ want: []*operations.ListJSONItem{{
+ Path: "file1",
+ Name: "file1",
+ Size: 5,
+ ModTime: operations.Timestamp{When: t1},
+ IsDir: false,
+ }, {
+ Path: "sub",
+ Name: "sub",
+ IsDir: true,
+ }, {
+ Path: "sub/file2",
+ Name: "file2",
+ Size: 9,
+ ModTime: operations.Timestamp{When: t2},
+ IsDir: false,
+ }},
+ }, {
+ name: "SubDir",
+ remote: "sub",
+ opt: operations.ListJSONOpt{},
+ want: []*operations.ListJSONItem{{
+ Path: "sub/file2",
+ Name: "file2",
+ Size: 9,
+ ModTime: operations.Timestamp{When: t2},
+ IsDir: false,
+ }},
+ }, {
+ name: "NoModTime",
+ opt: operations.ListJSONOpt{
+ FilesOnly: true,
+ NoModTime: true,
+ },
+ want: []*operations.ListJSONItem{{
+ Path: "file1",
+ Name: "file1",
+ Size: 5,
+ ModTime: operations.Timestamp{When: time.Time{}},
+ IsDir: false,
+ }},
+ }, {
+ name: "NoMimeType",
+ opt: operations.ListJSONOpt{
+ FilesOnly: true,
+ NoMimeType: true,
+ },
+ want: []*operations.ListJSONItem{{
+ Path: "file1",
+ Name: "file1",
+ Size: 5,
+ ModTime: operations.Timestamp{When: t1},
+ IsDir: false,
+ }},
+ }, {
+ name: "ShowHash",
+ opt: operations.ListJSONOpt{
+ FilesOnly: true,
+ ShowHash: true,
+ },
+ want: []*operations.ListJSONItem{{
+ Path: "file1",
+ Name: "file1",
+ Size: 5,
+ ModTime: operations.Timestamp{When: t1},
+ IsDir: false,
+ }},
+ }, {
+ name: "HashTypes",
+ opt: operations.ListJSONOpt{
+ FilesOnly: true,
+ ShowHash: true,
+ HashTypes: []string{"MD5"},
+ },
+ want: []*operations.ListJSONItem{{
+ Path: "file1",
+ Name: "file1",
+ Size: 5,
+ ModTime: operations.Timestamp{When: t1},
+ IsDir: false,
+ }},
+ }, {
+ name: "Metadata",
+ opt: operations.ListJSONOpt{
+ FilesOnly: false,
+ Metadata: true,
+ },
+ want: []*operations.ListJSONItem{{
+ Path: "file1",
+ Name: "file1",
+ Size: 5,
+ ModTime: operations.Timestamp{When: t1},
+ IsDir: false,
+ }, {
+ Path: "sub",
+ Name: "sub",
+ IsDir: true,
+ }},
+ },
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ var got []*operations.ListJSONItem
+ require.NoError(t, operations.ListJSON(ctx, r.Fremote, test.remote, &test.opt, func(item *operations.ListJSONItem) error {
+ got = append(got, item)
+ return nil
+ }))
+ sort.Slice(got, func(i, j int) bool {
+ return got[i].Path < got[j].Path
+ })
+ require.Equal(t, len(test.want), len(got), "Wrong number of results")
+ for i := range test.want {
+ compareListJSONItem(t, test.want[i], got[i], precision)
+ if test.opt.NoMimeType {
+ assert.Equal(t, "", got[i].MimeType)
+ } else {
+ assert.NotEqual(t, "", got[i].MimeType)
+ }
+ if test.opt.Metadata {
+ features := r.Fremote.Features()
+ if features.ReadMetadata && !got[i].IsDir {
+ assert.Greater(t, len(got[i].Metadata), 0, "Expecting metadata for file")
+ }
+ if features.ReadDirMetadata && got[i].IsDir {
+ assert.Greater(t, len(got[i].Metadata), 0, "Expecting metadata for dir")
+ }
+ }
+ if test.opt.ShowHash {
+ hashes := got[i].Hashes
+ assert.NotNil(t, hashes)
+ if len(test.opt.HashTypes) > 0 && len(hashes) > 0 {
+ assert.Equal(t, 1, len(hashes))
+ }
+ if hashes["crc32"] != "" {
+ assert.Equal(t, "9ee760e5", hashes["crc32"])
+ }
+ if hashes["dropbox"] != "" {
+ assert.Equal(t, "f4d62afeaee6f35d3efdd8c66623360395165473bcc958f835343eb3f542f983", hashes["dropbox"])
+ }
+ if hashes["mailru"] != "" {
+ assert.Equal(t, "66696c6531000000000000000000000000000000", hashes["mailru"])
+ }
+ if hashes["md5"] != "" {
+ assert.Equal(t, "826e8142e6baabe8af779f5f490cf5f5", hashes["md5"])
+ }
+ if hashes["quickxor"] != "" {
+ assert.Equal(t, "6648031bca100300000000000500000000000000", hashes["quickxor"])
+ }
+ if hashes["sha1"] != "" {
+ assert.Equal(t, "60b27f004e454aca81b0480209cce5081ec52390", hashes["sha1"])
+ }
+ if hashes["sha256"] != "" {
+ assert.Equal(t, "c147efcfc2d7ea666a9e4f5187b115c90903f0fc896a56df9a6ef5d8f3fc9f31", hashes["sha256"])
+ }
+ if hashes["whirlpool"] != "" {
+ assert.Equal(t, "02fa11755b6470bfc5aab6d94cde5cf2939474fb5b0ebbf8ddf3d32bf06aa438eb92eac097047c02017dc1c317ee83fa8a2717ca4d544b4ee75b3231d1c466b0", hashes["whirlpool"])
+ }
+ } else {
+ assert.Nil(t, got[i].Hashes)
+ }
+ }
+ })
+ }
+}
+
+func TestStatJSON(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ file1 := r.WriteBoth(ctx, "file1", "file1", t1)
+ file2 := r.WriteBoth(ctx, "sub/file2", "sub/file2", t2)
+
+ r.CheckRemoteItems(t, file1, file2)
+ precision := fs.GetModifyWindow(ctx, r.Fremote)
+
+ for _, test := range []struct {
+ name string
+ remote string
+ opt operations.ListJSONOpt
+ want *operations.ListJSONItem
+ }{
+ {
+ name: "Root",
+ remote: "",
+ opt: operations.ListJSONOpt{},
+ want: &operations.ListJSONItem{
+ Path: "",
+ Name: "",
+ IsDir: true,
+ },
+ }, {
+ name: "RootFilesOnly",
+ remote: "",
+ opt: operations.ListJSONOpt{
+ FilesOnly: true,
+ },
+ want: nil,
+ }, {
+ name: "RootDirsOnly",
+ remote: "",
+ opt: operations.ListJSONOpt{
+ DirsOnly: true,
+ },
+ want: &operations.ListJSONItem{
+ Path: "",
+ Name: "",
+ IsDir: true,
+ },
+ }, {
+ name: "Dir",
+ remote: "sub",
+ opt: operations.ListJSONOpt{},
+ want: &operations.ListJSONItem{
+ Path: "sub",
+ Name: "sub",
+ IsDir: true,
+ },
+ }, {
+ name: "DirWithTrailingSlash",
+ remote: "sub/",
+ opt: operations.ListJSONOpt{},
+ want: &operations.ListJSONItem{
+ Path: "sub",
+ Name: "sub",
+ IsDir: true,
+ },
+ }, {
+ name: "File",
+ remote: "file1",
+ opt: operations.ListJSONOpt{},
+ want: &operations.ListJSONItem{
+ Path: "file1",
+ Name: "file1",
+ Size: 5,
+ ModTime: operations.Timestamp{When: t1},
+ IsDir: false,
+ },
+ }, {
+ name: "NotFound",
+ remote: "notfound",
+ opt: operations.ListJSONOpt{},
+ want: nil,
+ }, {
+ name: "DirFilesOnly",
+ remote: "sub",
+ opt: operations.ListJSONOpt{
+ FilesOnly: true,
+ },
+ want: nil,
+ }, {
+ name: "FileFilesOnly",
+ remote: "file1",
+ opt: operations.ListJSONOpt{
+ FilesOnly: true,
+ },
+ want: &operations.ListJSONItem{
+ Path: "file1",
+ Name: "file1",
+ Size: 5,
+ ModTime: operations.Timestamp{When: t1},
+ IsDir: false,
+ },
+ }, {
+ name: "NotFoundFilesOnly",
+ remote: "notfound",
+ opt: operations.ListJSONOpt{
+ FilesOnly: true,
+ },
+ want: nil,
+ }, {
+ name: "DirDirsOnly",
+ remote: "sub",
+ opt: operations.ListJSONOpt{
+ DirsOnly: true,
+ },
+ want: &operations.ListJSONItem{
+ Path: "sub",
+ Name: "sub",
+ IsDir: true,
+ },
+ }, {
+ name: "FileDirsOnly",
+ remote: "file1",
+ opt: operations.ListJSONOpt{
+ DirsOnly: true,
+ },
+ want: nil,
+ }, {
+ name: "NotFoundDirsOnly",
+ remote: "notfound",
+ opt: operations.ListJSONOpt{
+ DirsOnly: true,
+ },
+ want: nil,
+ },
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ got, err := operations.StatJSON(ctx, r.Fremote, test.remote, &test.opt)
+ require.NoError(t, err)
+ if test.want == nil {
+ assert.Nil(t, got)
+ return
+ }
+ require.NotNil(t, got)
+ compareListJSONItem(t, test.want, got, precision)
+ })
+ }
+
+ t.Run("RootNotFound", func(t *testing.T) {
+ f, err := fs.NewFs(ctx, r.FremoteName+"/notfound")
+ require.NoError(t, err)
+ _, err = operations.StatJSON(ctx, f, "", &operations.ListJSONOpt{})
+ // This should return an error except for bucket based remotes
+ assert.True(t, err != nil || f.Features().BucketBased, "Need an error for non bucket based backends")
+ })
+}
diff --git a/fs/operations/multithread.go b/fs/operations/multithread.go
new file mode 100644
index 0000000..9abd5f7
--- /dev/null
+++ b/fs/operations/multithread.go
@@ -0,0 +1,380 @@
+package operations
+
+import (
+ "bufio"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "time"
+
+ "github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/fs/accounting"
+ "github.com/rclone/rclone/lib/atexit"
+ "github.com/rclone/rclone/lib/multipart"
+ "github.com/rclone/rclone/lib/pool"
+ "golang.org/x/sync/errgroup"
+)
+
+const (
+ multithreadChunkSize = 64 << 10
+)
+
+// Return a boolean as to whether we should use multi thread copy for
+// this transfer
+func doMultiThreadCopy(ctx context.Context, f fs.Fs, src fs.Object) bool {
+ ci := fs.GetConfig(ctx)
+
+ // Disable multi thread if...
+
+ // ...it isn't configured
+ if ci.MultiThreadStreams <= 1 {
+ return false
+ }
+ // ...if the source doesn't support it
+ if src.Fs().Features().NoMultiThreading {
+ return false
+ }
+ // ...size of object is less than cutoff
+ if src.Size() < int64(ci.MultiThreadCutoff) {
+ return false
+ }
+ // ...destination doesn't support it
+ dstFeatures := f.Features()
+ if dstFeatures.OpenChunkWriter == nil && dstFeatures.OpenWriterAt == nil {
+ return false
+ }
+ // ...if --multi-thread-streams not in use and source and
+ // destination are both local
+ if !ci.MultiThreadSet && dstFeatures.IsLocal && src.Fs().Features().IsLocal {
+ return false
+ }
+ return true
+}
+
+// state for a multi-thread copy
+type multiThreadCopyState struct {
+ ctx context.Context
+ partSize int64
+ size int64
+ src fs.Object
+ acc *accounting.Account
+ numChunks int
+ noBuffering bool // set to read the input without buffering
+}
+
+// Copy a single chunk into place
+func (mc *multiThreadCopyState) copyChunk(ctx context.Context, chunk int, writer fs.ChunkWriter) (err error) {
+ defer func() {
+ if err != nil {
+ fs.Debugf(mc.src, "multi-thread copy: chunk %d/%d failed: %v", chunk+1, mc.numChunks, err)
+ }
+ }()
+ start := int64(chunk) * mc.partSize
+ if start >= mc.size {
+ return nil
+ }
+ end := min(start+mc.partSize, mc.size)
+ size := end - start
+
+ // Reserve the memory first so we don't open the source and wait for memory buffers for ages
+ var rw *pool.RW
+ if !mc.noBuffering {
+ rw = multipart.NewRW().Reserve(size)
+ defer fs.CheckClose(rw, &err)
+ }
+
+ fs.Debugf(mc.src, "multi-thread copy: chunk %d/%d (%d-%d) size %v starting", chunk+1, mc.numChunks, start, end, fs.SizeSuffix(size))
+
+ rc, err := Open(ctx, mc.src, &fs.RangeOption{Start: start, End: end - 1})
+ if err != nil {
+ return fmt.Errorf("multi-thread copy: failed to open source: %w", err)
+ }
+ defer fs.CheckClose(rc, &err)
+
+ var rs io.ReadSeeker
+ if mc.noBuffering {
+ // Read directly if we are sure we aren't going to seek
+ // and account with accounting
+ rc.SetAccounting(mc.acc.AccountRead)
+ rs = rc
+ } else {
+ // Read the chunk into buffered reader
+ _, err = io.CopyN(rw, rc, size)
+ if err != nil {
+ return fmt.Errorf("multi-thread copy: failed to read chunk: %w", err)
+ }
+ // Account as we go
+ rw.SetAccounting(mc.acc.AccountRead)
+ rs = rw
+ }
+
+ // Write the chunk
+ bytesWritten, err := writer.WriteChunk(ctx, chunk, rs)
+ if err != nil {
+ return fmt.Errorf("multi-thread copy: failed to write chunk: %w", err)
+ }
+
+ fs.Debugf(mc.src, "multi-thread copy: chunk %d/%d (%d-%d) size %v finished", chunk+1, mc.numChunks, start, end, fs.SizeSuffix(bytesWritten))
+ return nil
+}
+
+// Given a file size and a chunkSize
+// it returns the number of chunks, so that chunkSize * numChunks >= size
+func calculateNumChunks(size int64, chunkSize int64) int {
+ numChunks := size / chunkSize
+ if size%chunkSize != 0 {
+ numChunks++
+ }
+ return int(numChunks)
+}
+
+// Copy src to (f, remote) using streams download threads. It tries to use the OpenChunkWriter feature
+// and if that's not available it creates an adapter using OpenWriterAt
+func multiThreadCopy(ctx context.Context, f fs.Fs, remote string, src fs.Object, concurrency int, tr *accounting.Transfer, options ...fs.OpenOption) (newDst fs.Object, err error) {
+ openChunkWriter := f.Features().OpenChunkWriter
+ ci := fs.GetConfig(ctx)
+ noBuffering := false
+ usingOpenWriterAt := false
+ if openChunkWriter == nil {
+ openWriterAt := f.Features().OpenWriterAt
+ if openWriterAt == nil {
+ return nil, errors.New("multi-thread copy: neither OpenChunkWriter nor OpenWriterAt supported")
+ }
+ openChunkWriter = openChunkWriterFromOpenWriterAt(openWriterAt, int64(ci.MultiThreadChunkSize), int64(ci.MultiThreadWriteBufferSize), f)
+ // If we are using OpenWriterAt we don't seek the chunks so don't need to buffer
+ fs.Debugf(src, "multi-thread copy: disabling buffering because destination uses OpenWriterAt")
+ noBuffering = true
+ usingOpenWriterAt = true
+ } else if src.Fs().Features().IsLocal {
+ // If the source fs is local we don't need to buffer
+ fs.Debugf(src, "multi-thread copy: disabling buffering because source is local disk")
+ noBuffering = true
+ } else if f.Features().ChunkWriterDoesntSeek {
+ // If the destination Fs promises not to seek its chunks
+ // (except for retries) then we don't need buffering.
+ fs.Debugf(src, "multi-thread copy: disabling buffering because destination has set ChunkWriterDoesntSeek")
+ noBuffering = true
+ }
+
+ if src.Size() < 0 {
+ return nil, fmt.Errorf("multi-thread copy: can't copy unknown sized file")
+ }
+ if src.Size() == 0 {
+ return nil, fmt.Errorf("multi-thread copy: can't copy zero sized file")
+ }
+
+ info, chunkWriter, err := openChunkWriter(ctx, remote, src, options...)
+ if err != nil {
+ return nil, fmt.Errorf("multi-thread copy: failed to open chunk writer: %w", err)
+ }
+
+ uploadCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+ uploadedOK := false
+ defer atexit.OnError(&err, func() {
+ cancel()
+ if info.LeavePartsOnError || uploadedOK {
+ return
+ }
+ fs.Debugf(src, "multi-thread copy: cancelling transfer on exit")
+ abortErr := chunkWriter.Abort(ctx)
+ if abortErr != nil {
+ fs.Debugf(src, "multi-thread copy: abort failed: %v", abortErr)
+ }
+ })()
+
+ if info.ChunkSize > src.Size() {
+ fs.Debugf(src, "multi-thread copy: chunk size %v was bigger than source file size %v", fs.SizeSuffix(info.ChunkSize), fs.SizeSuffix(src.Size()))
+ info.ChunkSize = src.Size()
+ }
+
+ // Use the backend concurrency if it is higher than --multi-thread-streams or if --multi-thread-streams wasn't set explicitly
+ if !ci.MultiThreadSet || info.Concurrency > concurrency {
+ fs.Debugf(src, "multi-thread copy: using backend concurrency of %d instead of --multi-thread-streams %d", info.Concurrency, concurrency)
+ concurrency = info.Concurrency
+ }
+
+ numChunks := calculateNumChunks(src.Size(), info.ChunkSize)
+ if concurrency > numChunks {
+ fs.Debugf(src, "multi-thread copy: number of streams %d was bigger than number of chunks %d", concurrency, numChunks)
+ concurrency = numChunks
+ }
+
+ if concurrency < 1 {
+ concurrency = 1
+ }
+
+ g, gCtx := errgroup.WithContext(uploadCtx)
+ g.SetLimit(concurrency)
+
+ mc := &multiThreadCopyState{
+ ctx: gCtx,
+ size: src.Size(),
+ src: src,
+ partSize: info.ChunkSize,
+ numChunks: numChunks,
+ noBuffering: noBuffering,
+ }
+
+ // Make accounting
+ mc.acc = tr.Account(gCtx, nil)
+
+ fs.Debugf(src, "Starting multi-thread copy with %d chunks of size %v with %v parallel streams", mc.numChunks, fs.SizeSuffix(mc.partSize), concurrency)
+ for chunk := range mc.numChunks {
+ // Fail fast, in case an errgroup managed function returns an error
+ if gCtx.Err() != nil {
+ break
+ }
+ chunk := chunk
+ g.Go(func() error {
+ return mc.copyChunk(gCtx, chunk, chunkWriter)
+ })
+ }
+
+ err = g.Wait()
+ if err != nil {
+ return nil, err
+ }
+ err = chunkWriter.Close(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("multi-thread copy: failed to close object after copy: %w", err)
+ }
+ uploadedOK = true // file is definitely uploaded OK so no need to abort
+
+ obj, err := f.NewObject(ctx, remote)
+ if err != nil {
+ return nil, fmt.Errorf("multi-thread copy: failed to find object after copy: %w", err)
+ }
+
+ // OpenWriterAt doesn't set metadata so we need to set it on completion
+ if usingOpenWriterAt {
+ setModTime := true
+ if ci.Metadata {
+ do, ok := obj.(fs.SetMetadataer)
+ if ok {
+ meta, err := fs.GetMetadataOptions(ctx, f, src, options)
+ if err != nil {
+ return nil, fmt.Errorf("multi-thread copy: failed to read metadata from source object: %w", err)
+ }
+ if _, foundMeta := meta["mtime"]; !foundMeta {
+ meta.Set("mtime", src.ModTime(ctx).Format(time.RFC3339Nano))
+ }
+ err = do.SetMetadata(ctx, meta)
+ if err != nil {
+ return nil, fmt.Errorf("multi-thread copy: failed to set metadata: %w", err)
+ }
+ setModTime = false
+ } else {
+ fs.Errorf(obj, "multi-thread copy: can't set metadata as SetMetadata isn't implemented in: %v", f)
+ }
+ }
+ if setModTime {
+ err = obj.SetModTime(ctx, src.ModTime(ctx))
+ switch err {
+ case nil, fs.ErrorCantSetModTime, fs.ErrorCantSetModTimeWithoutDelete:
+ default:
+ return nil, fmt.Errorf("multi-thread copy: failed to set modification time: %w", err)
+ }
+ }
+ }
+
+ fs.Debugf(src, "Finished multi-thread copy with %d parts of size %v", mc.numChunks, fs.SizeSuffix(mc.partSize))
+ return obj, nil
+}
+
+// writerAtChunkWriter converts a WriterAtCloser into a ChunkWriter
+type writerAtChunkWriter struct {
+ remote string
+ size int64
+ writerAt fs.WriterAtCloser
+ chunkSize int64
+ chunks int
+ writeBufferSize int64
+ f fs.Fs
+ closed bool
+}
+
+// WriteChunk writes chunkNumber from reader
+func (w *writerAtChunkWriter) WriteChunk(ctx context.Context, chunkNumber int, reader io.ReadSeeker) (int64, error) {
+ fs.Debugf(w.remote, "writing chunk %v", chunkNumber)
+
+ bytesToWrite := w.chunkSize
+ if chunkNumber == (w.chunks-1) && w.size%w.chunkSize != 0 {
+ bytesToWrite = w.size % w.chunkSize
+ }
+
+ var writer io.Writer = io.NewOffsetWriter(w.writerAt, int64(chunkNumber)*w.chunkSize)
+ if w.writeBufferSize > 0 {
+ writer = bufio.NewWriterSize(writer, int(w.writeBufferSize))
+ }
+ n, err := io.Copy(writer, reader)
+ if err != nil {
+ return -1, err
+ }
+ if n != bytesToWrite {
+ return -1, fmt.Errorf("expected to write %v bytes for chunk %v, but wrote %v bytes", bytesToWrite, chunkNumber, n)
+ }
+ // if we were buffering, flush to disk
+ switch w := writer.(type) {
+ case *bufio.Writer:
+ err = w.Flush()
+ if err != nil {
+ return -1, fmt.Errorf("multi-thread copy: flush failed: %w", err)
+ }
+ }
+ return n, nil
+}
+
+// Close the chunk writing
+func (w *writerAtChunkWriter) Close(ctx context.Context) error {
+ if w.closed {
+ return nil
+ }
+ w.closed = true
+ return w.writerAt.Close()
+}
+
+// Abort the chunk writing
+func (w *writerAtChunkWriter) Abort(ctx context.Context) error {
+ err := w.Close(ctx)
+ if err != nil {
+ fs.Errorf(w.remote, "multi-thread copy: failed to close file before aborting: %v", err)
+ }
+ obj, err := w.f.NewObject(ctx, w.remote)
+ if err != nil {
+ return fmt.Errorf("multi-thread copy: failed to find temp file when aborting chunk writer: %w", err)
+ }
+ return obj.Remove(ctx)
+}
+
+// openChunkWriterFromOpenWriterAt adapts an OpenWriterAtFn into an OpenChunkWriterFn using chunkSize and writeBufferSize
+func openChunkWriterFromOpenWriterAt(openWriterAt fs.OpenWriterAtFn, chunkSize int64, writeBufferSize int64, f fs.Fs) fs.OpenChunkWriterFn {
+ return func(ctx context.Context, remote string, src fs.ObjectInfo, options ...fs.OpenOption) (info fs.ChunkWriterInfo, writer fs.ChunkWriter, err error) {
+ ci := fs.GetConfig(ctx)
+
+ writerAt, err := openWriterAt(ctx, remote, src.Size())
+ if err != nil {
+ return info, nil, err
+ }
+
+ if writeBufferSize > 0 {
+ fs.Debugf(src.Remote(), "multi-thread copy: write buffer set to %v", writeBufferSize)
+ }
+
+ chunkWriter := &writerAtChunkWriter{
+ remote: remote,
+ size: src.Size(),
+ chunkSize: chunkSize,
+ chunks: calculateNumChunks(src.Size(), chunkSize),
+ writerAt: writerAt,
+ writeBufferSize: writeBufferSize,
+ f: f,
+ }
+ info = fs.ChunkWriterInfo{
+ ChunkSize: chunkSize,
+ Concurrency: ci.MultiThreadStreams,
+ }
+ return info, chunkWriter, nil
+ }
+}
diff --git a/fs/operations/multithread_test.go b/fs/operations/multithread_test.go
new file mode 100644
index 0000000..d3a07ae
--- /dev/null
+++ b/fs/operations/multithread_test.go
@@ -0,0 +1,333 @@
+package operations
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/rclone/rclone/fs/accounting"
+ "github.com/rclone/rclone/fs/hash"
+ "github.com/rclone/rclone/fs/object"
+ "github.com/rclone/rclone/fstest/mockfs"
+ "github.com/rclone/rclone/fstest/mockobject"
+ "github.com/rclone/rclone/lib/random"
+
+ "github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/fstest"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDoMultiThreadCopy(t *testing.T) {
+ ctx := context.Background()
+ ci := fs.GetConfig(ctx)
+ f, err := mockfs.NewFs(ctx, "potato", "", nil)
+ require.NoError(t, err)
+ src := mockobject.New("file.txt").WithContent([]byte(random.String(100)), mockobject.SeekModeNone)
+ srcFs, err := mockfs.NewFs(ctx, "sausage", "", nil)
+ require.NoError(t, err)
+ src.SetFs(srcFs)
+
+ oldStreams := ci.MultiThreadStreams
+ oldCutoff := ci.MultiThreadCutoff
+ oldIsSet := ci.MultiThreadSet
+ defer func() {
+ ci.MultiThreadStreams = oldStreams
+ ci.MultiThreadCutoff = oldCutoff
+ ci.MultiThreadSet = oldIsSet
+ }()
+
+ ci.MultiThreadStreams, ci.MultiThreadCutoff = 4, 50
+ ci.MultiThreadSet = false
+
+ nullWriterAt := func(ctx context.Context, remote string, size int64) (fs.WriterAtCloser, error) {
+ panic("don't call me")
+ }
+ f.Features().OpenWriterAt = nullWriterAt
+
+ assert.True(t, doMultiThreadCopy(ctx, f, src))
+
+ ci.MultiThreadStreams = 0
+ assert.False(t, doMultiThreadCopy(ctx, f, src))
+ ci.MultiThreadStreams = 1
+ assert.False(t, doMultiThreadCopy(ctx, f, src))
+ ci.MultiThreadStreams = 2
+ assert.True(t, doMultiThreadCopy(ctx, f, src))
+
+ ci.MultiThreadCutoff = 200
+ assert.False(t, doMultiThreadCopy(ctx, f, src))
+ ci.MultiThreadCutoff = 101
+ assert.False(t, doMultiThreadCopy(ctx, f, src))
+ ci.MultiThreadCutoff = 100
+ assert.True(t, doMultiThreadCopy(ctx, f, src))
+
+ f.Features().OpenWriterAt = nil
+ assert.False(t, doMultiThreadCopy(ctx, f, src))
+ f.Features().OpenWriterAt = nullWriterAt
+ assert.True(t, doMultiThreadCopy(ctx, f, src))
+
+ f.Features().IsLocal = true
+ srcFs.Features().IsLocal = true
+ assert.False(t, doMultiThreadCopy(ctx, f, src))
+ ci.MultiThreadSet = true
+ assert.True(t, doMultiThreadCopy(ctx, f, src))
+ ci.MultiThreadSet = false
+ assert.False(t, doMultiThreadCopy(ctx, f, src))
+ srcFs.Features().IsLocal = false
+ assert.True(t, doMultiThreadCopy(ctx, f, src))
+ srcFs.Features().IsLocal = true
+ assert.False(t, doMultiThreadCopy(ctx, f, src))
+ f.Features().IsLocal = false
+ assert.True(t, doMultiThreadCopy(ctx, f, src))
+ srcFs.Features().IsLocal = false
+ assert.True(t, doMultiThreadCopy(ctx, f, src))
+
+ srcFs.Features().NoMultiThreading = true
+ assert.False(t, doMultiThreadCopy(ctx, f, src))
+ srcFs.Features().NoMultiThreading = false
+ assert.True(t, doMultiThreadCopy(ctx, f, src))
+}
+
+func TestMultithreadCalculateNumChunks(t *testing.T) {
+ for _, test := range []struct {
+ size int64
+ chunkSize int64
+ wantNumChunks int
+ }{
+ {size: 1, chunkSize: multithreadChunkSize, wantNumChunks: 1},
+ {size: 1 << 20, chunkSize: 1, wantNumChunks: 1 << 20},
+ {size: 1 << 20, chunkSize: 2, wantNumChunks: 1 << 19},
+ {size: (1 << 20) + 1, chunkSize: 2, wantNumChunks: (1 << 19) + 1},
+ {size: (1 << 20) - 1, chunkSize: 2, wantNumChunks: 1 << 19},
+ } {
+ t.Run(fmt.Sprintf("%+v", test), func(t *testing.T) {
+ mc := &multiThreadCopyState{}
+ mc.numChunks = calculateNumChunks(test.size, test.chunkSize)
+ assert.Equal(t, test.wantNumChunks, mc.numChunks)
+ })
+ }
+}
+
+// Skip if not multithread, returning the chunkSize otherwise
+func skipIfNotMultithread(ctx context.Context, t *testing.T, r *fstest.Run) int {
+ features := r.Fremote.Features()
+ if features.OpenChunkWriter == nil && features.OpenWriterAt == nil {
+ t.Skip("multithread writing not supported")
+ }
+
+ // Only support one hash for the local backend otherwise we end up spending a huge amount of CPU on hashing!
+ if r.Fremote.Features().IsLocal {
+ oldHashes := hash.SupportOnly([]hash.Type{r.Fremote.Hashes().GetOne()})
+ t.Cleanup(func() {
+ _ = hash.SupportOnly(oldHashes)
+ })
+ }
+
+ ci := fs.GetConfig(ctx)
+ chunkSize := int(ci.MultiThreadChunkSize)
+ if features.OpenChunkWriter != nil {
+ //OpenChunkWriter func(ctx context.Context, remote string, src ObjectInfo, options ...OpenOption) (info ChunkWriterInfo, writer ChunkWriter, err error)
+ const fileName = "chunksize-probe"
+ src := object.NewStaticObjectInfo(fileName, time.Now(), int64(100*fs.Mebi), true, nil, nil)
+ info, writer, err := features.OpenChunkWriter(ctx, fileName, src)
+ require.NoError(t, err)
+ chunkSize = int(info.ChunkSize)
+ err = writer.Abort(ctx)
+ require.NoError(t, err)
+ }
+ return chunkSize
+}
+
+func TestMultithreadCopy(t *testing.T) {
+ r := fstest.NewRun(t)
+ ctx := context.Background()
+ chunkSize := skipIfNotMultithread(ctx, t, r)
+ // Check every other transfer for metadata
+ checkMetadata := false
+ ctx, ci := fs.AddConfig(ctx)
+
+ for _, upload := range []bool{false, true} {
+ for _, test := range []struct {
+ size int
+ streams int
+ }{
+ {size: chunkSize*2 - 1, streams: 2},
+ {size: chunkSize * 2, streams: 2},
+ {size: chunkSize*2 + 1, streams: 2},
+ } {
+ checkMetadata = !checkMetadata
+ ci.Metadata = checkMetadata
+ fileName := fmt.Sprintf("test-multithread-copy-%v-%d-%d", upload, test.size, test.streams)
+ t.Run(fmt.Sprintf("upload=%v,size=%v,streams=%v", upload, test.size, test.streams), func(t *testing.T) {
+ if *fstest.SizeLimit > 0 && int64(test.size) > *fstest.SizeLimit {
+ t.Skipf("exceeded file size limit %d > %d", test.size, *fstest.SizeLimit)
+ }
+ var (
+ contents = random.String(test.size)
+ t1 = fstest.Time("2001-02-03T04:05:06.499999999Z")
+ file1 fstest.Item
+ src, dst fs.Object
+ err error
+ testMetadata = fs.Metadata{
+ // System metadata supported by all backends
+ "mtime": t1.Format(time.RFC3339Nano),
+ // User metadata
+ "potato": "jersey",
+ }
+ )
+
+ var fSrc, fDst fs.Fs
+ if upload {
+ file1 = r.WriteFile(fileName, contents, t1)
+ r.CheckRemoteItems(t)
+ r.CheckLocalItems(t, file1)
+ fDst, fSrc = r.Fremote, r.Flocal
+ } else {
+ file1 = r.WriteObject(ctx, fileName, contents, t1)
+ r.CheckRemoteItems(t, file1)
+ r.CheckLocalItems(t)
+ fDst, fSrc = r.Flocal, r.Fremote
+ }
+ src, err = fSrc.NewObject(ctx, fileName)
+ require.NoError(t, err)
+
+ do, canSetMetadata := src.(fs.SetMetadataer)
+ if checkMetadata && canSetMetadata {
+ // Set metadata on the source if required
+ err := do.SetMetadata(ctx, testMetadata)
+ if err == fs.ErrorNotImplemented {
+ canSetMetadata = false
+ } else {
+ require.NoError(t, err)
+ fstest.CheckEntryMetadata(ctx, t, r.Flocal, src, testMetadata)
+ }
+ }
+
+ accounting.GlobalStats().ResetCounters()
+ tr := accounting.GlobalStats().NewTransfer(src, nil)
+
+ defer func() {
+ tr.Done(ctx, err)
+ }()
+
+ dst, err = multiThreadCopy(ctx, fDst, fileName, src, test.streams, tr)
+ require.NoError(t, err)
+
+ assert.Equal(t, src.Size(), dst.Size())
+ assert.Equal(t, fileName, dst.Remote())
+ fstest.CheckListingWithPrecision(t, fSrc, []fstest.Item{file1}, nil, fs.GetModifyWindow(ctx, fDst, fSrc))
+ fstest.CheckListingWithPrecision(t, fDst, []fstest.Item{file1}, nil, fs.GetModifyWindow(ctx, fDst, fSrc))
+
+ if checkMetadata && canSetMetadata && fDst.Features().ReadMetadata {
+ fstest.CheckEntryMetadata(ctx, t, fDst, dst, testMetadata)
+ }
+
+ require.NoError(t, dst.Remove(ctx))
+ require.NoError(t, src.Remove(ctx))
+
+ })
+ }
+ }
+}
+
+type errorObject struct {
+ fs.Object
+ size int64
+ wg *sync.WaitGroup
+}
+
+// Open opens the file for read. Call Close() on the returned io.ReadCloser
+//
+// Remember this is called multiple times whenever the backend seeks (eg having read checksum)
+func (o errorObject) Open(ctx context.Context, options ...fs.OpenOption) (io.ReadCloser, error) {
+ fs.Debugf(nil, "Open with options = %v", options)
+ rc, err := o.Object.Open(ctx, options...)
+ if err != nil {
+ return nil, err
+ }
+ // Return an error reader for the second segment
+ for _, option := range options {
+ if ropt, ok := option.(*fs.RangeOption); ok {
+ end := ropt.End + 1
+ if end >= o.size {
+ // Give the other chunks a chance to start
+ time.Sleep(time.Second)
+ // Wait for chunks to upload first
+ o.wg.Wait()
+ fs.Debugf(nil, "Returning error reader")
+ return errorReadCloser{rc}, nil
+ }
+ }
+ }
+ o.wg.Add(1)
+ return wgReadCloser{rc, o.wg}, nil
+}
+
+type errorReadCloser struct {
+ io.ReadCloser
+}
+
+func (rc errorReadCloser) Read(p []byte) (n int, err error) {
+ fs.Debugf(nil, "BOOM: simulated read failure")
+ return 0, errors.New("BOOM: simulated read failure")
+}
+
+type wgReadCloser struct {
+ io.ReadCloser
+ wg *sync.WaitGroup
+}
+
+func (rc wgReadCloser) Close() (err error) {
+ rc.wg.Done()
+ return rc.ReadCloser.Close()
+}
+
+// Make sure aborting the multi-thread copy doesn't overwrite an existing file.
+func TestMultithreadCopyAbort(t *testing.T) {
+ r := fstest.NewRun(t)
+ ctx := context.Background()
+ chunkSize := skipIfNotMultithread(ctx, t, r)
+ size := 2*chunkSize + 1
+
+ if *fstest.SizeLimit > 0 && int64(size) > *fstest.SizeLimit {
+ t.Skipf("exceeded file size limit %d > %d", size, *fstest.SizeLimit)
+ }
+
+ // first write a canary file which we are trying not to overwrite
+ const fileName = "test-multithread-abort"
+ contents := random.String(100)
+ t1 := fstest.Time("2001-02-03T04:05:06.499999999Z")
+ canary := r.WriteObject(ctx, fileName, contents, t1)
+ r.CheckRemoteItems(t, canary)
+
+ // Now write a local file to upload
+ file1 := r.WriteFile(fileName, random.String(size), t1)
+ r.CheckLocalItems(t, file1)
+
+ src, err := r.Flocal.NewObject(ctx, fileName)
+ require.NoError(t, err)
+ accounting.GlobalStats().ResetCounters()
+ tr := accounting.GlobalStats().NewTransfer(src, nil)
+
+ defer func() {
+ tr.Done(ctx, err)
+ }()
+ wg := new(sync.WaitGroup)
+ dst, err := multiThreadCopy(ctx, r.Fremote, fileName, errorObject{src, int64(size), wg}, 1, tr)
+ assert.Error(t, err)
+ assert.Nil(t, dst)
+
+ if r.Fremote.Features().PartialUploads {
+ r.CheckRemoteItems(t)
+
+ } else {
+ r.CheckRemoteItems(t, canary)
+ o, err := r.Fremote.NewObject(ctx, fileName)
+ require.NoError(t, err)
+ require.NoError(t, o.Remove(ctx))
+ }
+}
diff --git a/fs/operations/operations.go b/fs/operations/operations.go
new file mode 100644
index 0000000..1ada8f8
--- /dev/null
+++ b/fs/operations/operations.go
@@ -0,0 +1,2742 @@
+// Package operations does generic operations on filesystems and objects
+package operations
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/csv"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "mime"
+ "net/http"
+ "os"
+ "path"
+ "path/filepath"
+ "runtime"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/fs/accounting"
+ "github.com/rclone/rclone/fs/cache"
+ "github.com/rclone/rclone/fs/config"
+ "github.com/rclone/rclone/fs/filter"
+ "github.com/rclone/rclone/fs/fserrors"
+ "github.com/rclone/rclone/fs/fshttp"
+ "github.com/rclone/rclone/fs/hash"
+ "github.com/rclone/rclone/fs/object"
+ "github.com/rclone/rclone/fs/walk"
+ "github.com/rclone/rclone/lib/atexit"
+ "github.com/rclone/rclone/lib/errcount"
+ "github.com/rclone/rclone/lib/pacer"
+ "github.com/rclone/rclone/lib/random"
+ "github.com/rclone/rclone/lib/readers"
+ "github.com/rclone/rclone/lib/transform"
+ "golang.org/x/sync/errgroup"
+ "golang.org/x/text/unicode/norm"
+)
+
+// CheckHashes checks the two files to see if they have common
+// known hash types and compares them
+//
+// Returns.
+//
+// equal - which is equality of the hashes
+//
+// hash - the HashType. This is HashNone if either of the hashes were
+// unset or a compatible hash couldn't be found.
+//
+// err - may return an error which will already have been logged
+//
+// If an error is returned it will return equal as false
+func CheckHashes(ctx context.Context, src fs.ObjectInfo, dst fs.Object) (equal bool, ht hash.Type, err error) {
+ common := src.Fs().Hashes().Overlap(dst.Fs().Hashes())
+ // fs.Debugf(nil, "Shared hashes: %v", common)
+ if common.Count() == 0 {
+ return true, hash.None, nil
+ }
+ equal, ht, _, _, err = checkHashes(ctx, src, dst, common.GetOne())
+ return equal, ht, err
+}
+
+var errNoHash = errors.New("no hash available")
+
+// checkHashes does the work of CheckHashes but takes a hash.Type and
+// returns the effective hash type used.
+func checkHashes(ctx context.Context, src fs.ObjectInfo, dst fs.Object, ht hash.Type) (equal bool, htOut hash.Type, srcHash, dstHash string, err error) {
+ // Calculate hashes in parallel
+ g, ctx := errgroup.WithContext(ctx)
+ var srcErr, dstErr error
+ g.Go(func() (err error) {
+ srcHash, srcErr = src.Hash(ctx, ht)
+ if srcErr != nil {
+ return srcErr
+ }
+ if srcHash == "" {
+ fs.Debugf(src, "Src hash empty - aborting Dst hash check")
+ return errNoHash
+ }
+ return nil
+ })
+ g.Go(func() (err error) {
+ dstHash, dstErr = dst.Hash(ctx, ht)
+ if dstErr != nil {
+ return dstErr
+ }
+ if dstHash == "" {
+ fs.Debugf(dst, "Dst hash empty - aborting Src hash check")
+ return errNoHash
+ }
+ return nil
+ })
+ err = g.Wait()
+ if err == errNoHash {
+ return true, hash.None, srcHash, dstHash, nil
+ }
+ if srcErr != nil {
+ err = fs.CountError(ctx, srcErr)
+ fs.Errorf(src, "Failed to calculate src hash: %v", err)
+ }
+ if dstErr != nil {
+ err = fs.CountError(ctx, dstErr)
+ fs.Errorf(dst, "Failed to calculate dst hash: %v", err)
+ }
+ if err != nil {
+ return false, ht, srcHash, dstHash, err
+ }
+ if srcHash != dstHash {
+ fs.Debugf(src, "%v = %s (%v)", ht, srcHash, src.Fs())
+ fs.Debugf(dst, "%v = %s (%v)", ht, dstHash, dst.Fs())
+ return false, ht, srcHash, dstHash, nil
+ }
+ fs.Debugf(src, "%v = %s OK", ht, srcHash)
+ return true, ht, srcHash, dstHash, nil
+}
+
+// Equal checks to see if the src and dst objects are equal by looking at
+// size, mtime and hash
+//
+// If the src and dst size are different then it is considered to be
+// not equal. If --size-only is in effect then this is the only check
+// that is done. If --ignore-size is in effect then this check is
+// skipped and the files are considered the same size.
+//
+// If the size is the same and the mtime is the same then it is
+// considered to be equal. This check is skipped if using --checksum.
+//
+// If the size is the same and mtime is different, unreadable or
+// --checksum is set and the hash is the same then the file is
+// considered to be equal. In this case the mtime on the dst is
+// updated if --checksum is not set.
+//
+// Otherwise the file is considered to be not equal including if there
+// were errors reading info.
+func Equal(ctx context.Context, src fs.ObjectInfo, dst fs.Object) bool {
+ return equal(ctx, src, dst, defaultEqualOpt(ctx))
+}
+
+// DirsEqual is like Equal but for dirs instead of objects.
+// It returns true if two dirs should be considered "equal" for the purposes of syncCopyMove
+// (in other words, true == "skip updating modtime/metadata for this dir".)
+// Unlike Equal, it does not consider size or checksum, as these do not apply to directories.
+func DirsEqual(ctx context.Context, src, dst fs.Directory, opt DirsEqualOpt) (equal bool) {
+ if dst == nil {
+ return false
+ }
+ ci := fs.GetConfig(ctx)
+ if ci.SizeOnly || ci.Immutable || ci.IgnoreExisting || opt.ModifyWindow == fs.ModTimeNotSupported {
+ return true
+ }
+ if ci.IgnoreTimes {
+ return false
+ }
+ if !(opt.SetDirModtime || opt.SetDirMetadata) {
+ return true
+ }
+ srcModTime, dstModTime := src.ModTime(ctx), dst.ModTime(ctx)
+ if srcModTime.IsZero() || dstModTime.IsZero() {
+ return false
+ }
+ dt := dstModTime.Sub(srcModTime)
+ if dt < opt.ModifyWindow && dt > -opt.ModifyWindow {
+ fs.Debugf(dst, "Directory modification time the same (differ by %s, within tolerance %s)", dt, opt.ModifyWindow)
+ return true
+ }
+ if ci.UpdateOlder && dt >= opt.ModifyWindow {
+ fs.Debugf(dst, "Destination directory is newer than source, skipping")
+ return true
+ }
+ return false
+}
+
+// sizeDiffers compare the size of src and dst taking into account the
+// various ways of ignoring sizes
+func sizeDiffers(ctx context.Context, src, dst fs.ObjectInfo) bool {
+ ci := fs.GetConfig(ctx)
+ if ci.IgnoreSize || src.Size() < 0 || dst.Size() < 0 {
+ return false
+ }
+ if src.Size() == dst.Size() {
+ fs.Debugf(dst, "size = %d OK", dst.Size())
+ return false
+ }
+ fs.Debugf(src, "size = %d (%v)", src.Size(), src.Fs())
+ fs.Debugf(dst, "size = %d (%v)", dst.Size(), dst.Fs())
+ return true
+}
+
+var checksumWarning sync.Once
+
+// options for equal function()
+type equalOpt struct {
+ sizeOnly bool // if set only check size
+ checkSum bool // if set check checksum+size instead of modtime+size
+ updateModTime bool // if set update the modtime if hashes identical and checking with modtime+size
+ forceModTimeMatch bool // if set assume modtimes match
+}
+
+// default set of options for equal()
+func defaultEqualOpt(ctx context.Context) equalOpt {
+ ci := fs.GetConfig(ctx)
+ return equalOpt{
+ sizeOnly: ci.SizeOnly,
+ checkSum: ci.CheckSum,
+ updateModTime: !ci.NoUpdateModTime,
+ forceModTimeMatch: false,
+ }
+}
+
+// DirsEqualOpt represents options for DirsEqual function()
+type DirsEqualOpt struct {
+ ModifyWindow time.Duration // Max time diff to be considered the same
+ SetDirModtime bool // whether to consider dir modtime
+ SetDirMetadata bool // whether to consider dir metadata
+}
+
+var modTimeUploadOnce sync.Once
+
+// emit a log if we are about to upload a file to set its modification time
+func logModTimeUpload(dst fs.Object) {
+ modTimeUploadOnce.Do(func() {
+ fs.Logf(dst.Fs(), "Forced to upload files to set modification times on this backend.")
+ })
+}
+
+// EqualFn allows replacing Equal() with a custom function during NeedTransfer()
+type (
+ EqualFn func(ctx context.Context, src fs.ObjectInfo, dst fs.Object) bool
+ equalFnContextKey struct{}
+)
+
+var equalFnKey = equalFnContextKey{}
+
+// WithEqualFn stores equalFn in ctx and returns a copy of ctx in which equalFnKey = equalFn
+func WithEqualFn(ctx context.Context, equalFn EqualFn) context.Context {
+ return context.WithValue(ctx, equalFnKey, equalFn)
+}
+
+func equal(ctx context.Context, src fs.ObjectInfo, dst fs.Object, opt equalOpt) bool {
+ ci := fs.GetConfig(ctx)
+ logger, _ := GetLogger(ctx)
+ if sizeDiffers(ctx, src, dst) {
+ fs.Debug(src, "Sizes differ")
+ logger(ctx, Differ, src, dst, nil)
+ return false
+ }
+ if opt.sizeOnly {
+ fs.Debugf(src, "Sizes identical")
+ logger(ctx, Match, src, dst, nil)
+ return true
+ }
+
+ // Assert: Size is equal or being ignored
+
+ // If checking checksum and not modtime
+ if opt.checkSum {
+ // Check the hash
+ same, ht, _ := CheckHashes(ctx, src, dst)
+ if !same {
+ fs.Debugf(src, "%v differ", ht)
+ logger(ctx, Differ, src, dst, nil)
+ return false
+ }
+ if ht == hash.None {
+ common := src.Fs().Hashes().Overlap(dst.Fs().Hashes())
+ if common.Count() == 0 {
+ checksumWarning.Do(func() {
+ fs.Logf(dst.Fs(), "--checksum is in use but the source and destination have no hashes in common; falling back to --size-only")
+ })
+ }
+ fs.Debugf(src, "Size of src and dst objects identical")
+ } else {
+ fs.Debugf(src, "Size and %v of src and dst objects identical", ht)
+ }
+ logger(ctx, Match, src, dst, nil)
+ return true
+ }
+
+ srcModTime := src.ModTime(ctx)
+ if !opt.forceModTimeMatch {
+ // Sizes the same so check the mtime
+ modifyWindow := fs.GetModifyWindow(ctx, src.Fs(), dst.Fs())
+ if modifyWindow == fs.ModTimeNotSupported {
+ fs.Debugf(src, "Sizes identical")
+ logger(ctx, Match, src, dst, nil)
+ return true
+ }
+ dstModTime := dst.ModTime(ctx)
+ dt := dstModTime.Sub(srcModTime)
+ if dt < modifyWindow && dt > -modifyWindow {
+ fs.Debugf(src, "Size and modification time the same (differ by %s, within tolerance %s)", dt, modifyWindow)
+ logger(ctx, Match, src, dst, nil)
+ return true
+ }
+
+ fs.Debugf(src, "Modification times differ by %s: %v, %v", dt, srcModTime, dstModTime)
+ }
+
+ // Check if the hashes are the same
+ same, ht, _ := CheckHashes(ctx, src, dst)
+ if !same {
+ fs.Debugf(src, "%v differ", ht)
+ logger(ctx, Differ, src, dst, nil)
+ return false
+ }
+ if ht == hash.None && !ci.RefreshTimes {
+ // if couldn't check hash, return that they differ
+ logger(ctx, Differ, src, dst, nil)
+ return false
+ }
+
+ // mod time differs but hash is the same to reset mod time if required
+ if opt.updateModTime {
+ if !SkipDestructive(ctx, src, "update modification time") {
+ // Size and hash the same but mtime different
+ // Error if objects are treated as immutable
+ if ci.Immutable {
+ fs.Errorf(dst, "Timestamp mismatch between immutable objects")
+ logger(ctx, Differ, src, dst, nil)
+ return false
+ }
+ // Update the mtime of the dst object here
+ err := dst.SetModTime(ctx, srcModTime)
+ if errors.Is(err, fs.ErrorCantSetModTime) {
+ logModTimeUpload(dst)
+ fs.Infof(dst, "src and dst identical but can't set mod time without re-uploading")
+ logger(ctx, Differ, src, dst, nil)
+ return false
+ } else if errors.Is(err, fs.ErrorCantSetModTimeWithoutDelete) {
+ logModTimeUpload(dst)
+ fs.Infof(dst, "src and dst identical but can't set mod time without deleting and re-uploading")
+ // Remove the file if BackupDir isn't set. If BackupDir is set we would rather have the old file
+ // put in the BackupDir than deleted which is what will happen if we don't delete it.
+ if ci.BackupDir == "" {
+ err = dst.Remove(ctx)
+ if err != nil {
+ fs.Errorf(dst, "failed to delete before re-upload: %v", err)
+ }
+ }
+ logger(ctx, Differ, src, dst, nil)
+ return false
+ } else if err != nil {
+ err = fs.CountError(ctx, err)
+ fs.Errorf(dst, "Failed to set modification time: %v", err)
+ } else {
+ fs.Infof(src, "Updated modification time in destination")
+ }
+ }
+ }
+ logger(ctx, Match, src, dst, nil)
+ return true
+}
+
+// CommonHash returns a single hash.Type and a HashOption with that
+// type which is in common between the two fs.Fs.
+func CommonHash(ctx context.Context, fa, fb fs.Info) (hash.Type, *fs.HashesOption) {
+ ci := fs.GetConfig(ctx)
+ // work out which hash to use - limit to 1 hash in common
+ var common hash.Set
+ hashType := hash.None
+ if !ci.IgnoreChecksum {
+ common = fb.Hashes().Overlap(fa.Hashes())
+ if common.Count() > 0 {
+ hashType = common.GetOne()
+ common = hash.Set(hashType)
+ }
+ }
+ return hashType, &fs.HashesOption{Hashes: common}
+}
+
+// SameObject returns true if src and dst could be pointing to the
+// same object.
+func SameObject(src, dst fs.Object) bool {
+ srcFs, dstFs := src.Fs(), dst.Fs()
+ if !SameConfig(srcFs, dstFs) {
+ // If same remote type then check ID of objects if available
+ doSrcID, srcIDOK := src.(fs.IDer)
+ doDstID, dstIDOK := dst.(fs.IDer)
+ if srcIDOK && dstIDOK && SameRemoteType(srcFs, dstFs) {
+ srcID, dstID := doSrcID.ID(), doDstID.ID()
+ if srcID != "" && srcID == dstID {
+ return true
+ }
+ }
+ return false
+ }
+ srcPath := path.Join(srcFs.Root(), src.Remote())
+ dstPath := path.Join(dstFs.Root(), dst.Remote())
+ if srcFs.Features().IsLocal && dstFs.Features().IsLocal && runtime.GOOS == "darwin" {
+ if norm.NFC.String(srcPath) == norm.NFC.String(dstPath) {
+ return true
+ }
+ }
+ if dst.Fs().Features().CaseInsensitive {
+ srcPath = strings.ToLower(srcPath)
+ dstPath = strings.ToLower(dstPath)
+ }
+ return srcPath == dstPath
+}
+
+// Move src object to dst or fdst if nil. If dst is nil then it uses
+// remote as the name of the new object.
+//
+// Note that you must check the destination does not exist before
+// calling this and pass it as dst. If you pass dst=nil and the
+// destination does exist then this may create duplicates or return
+// errors.
+//
+// It returns the destination object if possible. Note that this may
+// be nil.
+//
+// This is accounted as a check.
+func Move(ctx context.Context, fdst fs.Fs, dst fs.Object, remote string, src fs.Object) (newDst fs.Object, err error) {
+ return move(ctx, fdst, dst, remote, src, false)
+}
+
+// MoveTransfer moves src object to dst or fdst if nil. If dst is nil
+// then it uses remote as the name of the new object.
+//
+// This is identical to Move but is accounted as a transfer.
+func MoveTransfer(ctx context.Context, fdst fs.Fs, dst fs.Object, remote string, src fs.Object) (newDst fs.Object, err error) {
+ return move(ctx, fdst, dst, remote, src, true)
+}
+
+// move - see Move for help
+func move(ctx context.Context, fdst fs.Fs, dst fs.Object, remote string, src fs.Object, isTransfer bool) (newDst fs.Object, err error) {
+ origRemote := remote // avoid double-transform on fallback to copy
+ remote = transform.Path(ctx, remote, false)
+ ci := fs.GetConfig(ctx)
+ newDst = dst
+ if ci.DryRun && dst != nil && SameObject(src, dst) && src.Remote() == transform.Path(ctx, dst.Remote(), false) {
+ return // avoid SkipDestructive log for objects that won't really be moved
+ }
+ var tr *accounting.Transfer
+ if isTransfer {
+ tr = accounting.Stats(ctx).NewTransfer(src, fdst)
+ } else {
+ tr = accounting.Stats(ctx).NewCheckingTransfer(src, "moving")
+ }
+ defer func() {
+ if err == nil {
+ accounting.Stats(ctx).Renames(1)
+ }
+ tr.Done(ctx, err)
+ }()
+ action := "move"
+ if remote != src.Remote() {
+ action += " to " + remote
+ }
+ if SkipDestructive(ctx, src, action) {
+ in := tr.Account(ctx, nil)
+ in.DryRun(src.Size())
+ return newDst, nil
+ }
+ // See if we have Move available
+ if doMove := fdst.Features().Move; doMove != nil && (SameConfig(src.Fs(), fdst) || (SameRemoteType(src.Fs(), fdst) && (fdst.Features().ServerSideAcrossConfigs || ci.ServerSideAcrossConfigs))) {
+ // Delete destination if it exists and is not the same file as src (could be same file while seemingly different if the remote is case insensitive)
+ if dst != nil {
+ remote = transform.Path(ctx, dst.Remote(), false)
+ if !SameObject(src, dst) {
+ err = DeleteFile(ctx, dst)
+ if err != nil {
+ return newDst, err
+ }
+ } else if src.Remote() == remote {
+ return newDst, nil
+ } else if needsMoveCaseInsensitive(fdst, fdst, remote, src.Remote(), false) {
+ doMove = func(ctx context.Context, src fs.Object, remote string) (fs.Object, error) {
+ return MoveCaseInsensitive(ctx, fdst, fdst, remote, src.Remote(), false, src)
+ }
+ }
+ } else if needsMoveCaseInsensitive(fdst, fdst, remote, src.Remote(), false) {
+ doMove = func(ctx context.Context, src fs.Object, remote string) (fs.Object, error) {
+ return MoveCaseInsensitive(ctx, fdst, fdst, remote, src.Remote(), false, src)
+ }
+ }
+ // Move dst <- src
+ in := tr.Account(ctx, nil) // account the transfer
+ in.ServerSideTransferStart()
+ newDst, err = doMove(ctx, src, remote)
+ switch err {
+ case nil:
+ if newDst != nil && src.String() != newDst.String() {
+ fs.Infof(src, "Moved (server-side) to: %s", newDst.String())
+ } else {
+ fs.Infof(src, "Moved (server-side)")
+ }
+ in.ServerSideMoveEnd(newDst.Size()) // account the bytes for the server-side transfer
+ _ = in.Close()
+ return newDst, nil
+ case fs.ErrorCantMove:
+ fs.Debugf(src, "Can't move, switching to copy")
+ _ = in.Close()
+ default:
+ err = fs.CountError(ctx, err)
+ fs.Errorf(src, "Couldn't move: %v", err)
+ _ = in.Close()
+ return newDst, err
+ }
+ }
+ // Move not found or didn't work so copy dst <- src
+ if origRemote != remote {
+ dst = nil
+ }
+ newDst, err = Copy(ctx, fdst, dst, origRemote, src)
+ if err != nil {
+ fs.Errorf(src, "Not deleting source as copy failed: %v", err)
+ return newDst, err
+ }
+ // Delete src if no error on copy
+ return newDst, DeleteFile(ctx, src)
+}
+
+// CanServerSideMove returns true if fdst support server-side moves or
+// server-side copies
+//
+// Some remotes simulate rename by server-side copy and delete, so include
+// remotes that implements either Mover or Copier.
+func CanServerSideMove(fdst fs.Fs) bool {
+ canMove := fdst.Features().Move != nil
+ canCopy := fdst.Features().Copy != nil
+ return canMove || canCopy
+}
+
+// SuffixName adds the current --suffix to the remote, obeying
+// --suffix-keep-extension if set
+func SuffixName(ctx context.Context, remote string) string {
+ ci := fs.GetConfig(ctx)
+ if ci.Suffix == "" {
+ return remote
+ }
+ if ci.SuffixKeepExtension {
+ return transform.SuffixKeepExtension(remote, ci.Suffix)
+ }
+ return remote + ci.Suffix
+}
+
+// DeleteFileWithBackupDir deletes a single file respecting --dry-run
+// and accumulating stats and errors.
+//
+// If backupDir is set then it moves the file to there instead of
+// deleting
+func DeleteFileWithBackupDir(ctx context.Context, dst fs.Object, backupDir fs.Fs) (err error) {
+ tr := accounting.Stats(ctx).NewCheckingTransfer(dst, "deleting")
+ defer func() {
+ tr.Done(ctx, err)
+ }()
+ err = accounting.Stats(ctx).DeleteFile(ctx, dst.Size())
+ if err != nil {
+ return err
+ }
+ action, actioned := "delete", "Deleted"
+ if backupDir != nil {
+ action, actioned = "move into backup dir", "Moved into backup dir"
+ }
+ skip := SkipDestructive(ctx, dst, action)
+ if skip {
+ // do nothing
+ } else if backupDir != nil {
+ err = MoveBackupDir(ctx, backupDir, dst)
+ } else {
+ err = dst.Remove(ctx)
+ }
+ if err != nil {
+ fs.Errorf(dst, "Couldn't %s: %v", action, err)
+ err = fs.CountError(ctx, err)
+ } else if !skip {
+ fs.Infof(dst, "%s", actioned)
+ }
+ return err
+}
+
+// DeleteFile deletes a single file respecting --dry-run and accumulating stats and errors.
+//
+// If useBackupDir is set and --backup-dir is in effect then it moves
+// the file to there instead of deleting
+func DeleteFile(ctx context.Context, dst fs.Object) (err error) {
+ return DeleteFileWithBackupDir(ctx, dst, nil)
+}
+
+// DeleteFilesWithBackupDir removes all the files passed in the
+// channel
+//
+// If backupDir is set the files will be placed into that directory
+// instead of being deleted.
+func DeleteFilesWithBackupDir(ctx context.Context, toBeDeleted fs.ObjectsChan, backupDir fs.Fs) error {
+ var wg sync.WaitGroup
+ ci := fs.GetConfig(ctx)
+ wg.Add(ci.Checkers)
+ var errorCount atomic.Int32
+ var fatalErrorCount atomic.Int32
+
+ for range ci.Checkers {
+ go func() {
+ defer wg.Done()
+ for dst := range toBeDeleted {
+ err := DeleteFileWithBackupDir(ctx, dst, backupDir)
+ if err != nil {
+ errorCount.Add(1)
+ logger, _ := GetLogger(ctx)
+ logger(ctx, TransferError, nil, dst, err)
+ if fserrors.IsFatalError(err) {
+ fs.Errorf(dst, "Got fatal error on delete: %s", err)
+ fatalErrorCount.Add(1)
+ return
+ }
+ }
+ }
+ }()
+ }
+ fs.Debugf(nil, "Waiting for deletions to finish")
+ wg.Wait()
+ if errorCount.Load() > 0 {
+ err := fmt.Errorf("failed to delete %d files", errorCount.Load())
+ if fatalErrorCount.Load() > 0 {
+ return fserrors.FatalError(err)
+ }
+ return err
+ }
+ return nil
+}
+
+// DeleteFiles removes all the files passed in the channel
+func DeleteFiles(ctx context.Context, toBeDeleted fs.ObjectsChan) error {
+ return DeleteFilesWithBackupDir(ctx, toBeDeleted, nil)
+}
+
+// ReadFile reads the object into memory and accounts it
+func ReadFile(ctx context.Context, o fs.Object) (b []byte, err error) {
+ tr := accounting.Stats(ctx).NewTransfer(o, nil)
+ defer func() {
+ tr.Done(ctx, err)
+ }()
+ in0, err := Open(ctx, o)
+ if err != nil {
+ return nil, fmt.Errorf("failed to open %v: %w", o, err)
+ }
+ in := tr.Account(ctx, in0).WithBuffer() // account and buffer the transfer
+ defer fs.CheckClose(in, &err) // closes in0 also
+ b, err = io.ReadAll(in)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read %v: %w", o, err)
+ }
+ return b, nil
+}
+
+// SameRemoteType returns true if fdst and fsrc are the same type
+func SameRemoteType(fdst, fsrc fs.Info) bool {
+ return fmt.Sprintf("%T", fdst) == fmt.Sprintf("%T", fsrc)
+}
+
+// SameConfig returns true if fdst and fsrc are using the same config
+// file entry
+func SameConfig(fdst, fsrc fs.Info) bool {
+ return fdst.Name() == fsrc.Name()
+}
+
+// SameConfigArr returns true if any of []fsrcs has same config file entry with fdst
+func SameConfigArr(fdst fs.Info, fsrcs []fs.Fs) bool {
+ for _, fsrc := range fsrcs {
+ if fdst.Name() == fsrc.Name() {
+ return true
+ }
+ }
+ return false
+}
+
+// Same returns true if fdst and fsrc point to the same underlying Fs
+func Same(fdst, fsrc fs.Info) bool {
+ return SameConfig(fdst, fsrc) && strings.Trim(fdst.Root(), "/") == strings.Trim(fsrc.Root(), "/")
+}
+
+// fixRoot returns the Root with a trailing / if not empty.
+//
+// It returns a case folded version for case insensitive file systems
+func fixRoot(f fs.Info) (s string, folded string) {
+ s = strings.Trim(filepath.ToSlash(f.Root()), "/")
+ if s != "" {
+ s += "/"
+ }
+ folded = s
+ if f.Features().CaseInsensitive {
+ folded = strings.ToLower(s)
+ }
+ return s, folded
+}
+
+// OverlappingFilterCheck returns true if fdst and fsrc point to the same
+// underlying Fs and they overlap without fdst being excluded by any filter rule.
+func OverlappingFilterCheck(ctx context.Context, fdst fs.Fs, fsrc fs.Fs) bool {
+ if !SameConfig(fdst, fsrc) {
+ return false
+ }
+ fdstRoot, fdstRootFolded := fixRoot(fdst)
+ fsrcRoot, fsrcRootFolded := fixRoot(fsrc)
+ if fdstRootFolded == fsrcRootFolded {
+ return true
+ } else if strings.HasPrefix(fdstRootFolded, fsrcRootFolded) {
+ fdstRelative := fdstRoot[len(fsrcRoot):]
+ return filterCheck(ctx, fsrc, fdstRelative)
+ } else if strings.HasPrefix(fsrcRootFolded, fdstRootFolded) {
+ fsrcRelative := fsrcRoot[len(fdstRoot):]
+ return filterCheck(ctx, fdst, fsrcRelative)
+ }
+ return false
+}
+
+// filterCheck checks if dir is included in f
+func filterCheck(ctx context.Context, f fs.Fs, dir string) bool {
+ fi := filter.GetConfig(ctx)
+ includeDirectory := fi.IncludeDirectory(ctx, f)
+ include, err := includeDirectory(dir)
+ if err != nil {
+ fs.Errorf(f, "Failed to discover whether directory is included: %v", err)
+ return true
+ }
+ return include
+}
+
+// SameDir returns true if fdst and fsrc point to the same
+// underlying Fs and they are the same directory.
+func SameDir(fdst, fsrc fs.Info) bool {
+ if !SameConfig(fdst, fsrc) {
+ return false
+ }
+ _, fdstRootFolded := fixRoot(fdst)
+ _, fsrcRootFolded := fixRoot(fsrc)
+ return fdstRootFolded == fsrcRootFolded
+}
+
+// Retry runs fn up to maxTries times if it returns a retriable error
+func Retry(ctx context.Context, o any, maxTries int, fn func() error) (err error) {
+ for tries := 1; tries <= maxTries; tries++ {
+ // Call the function which might error
+ err = fn()
+ if err == nil {
+ break
+ }
+ // End if ctx is in error
+ if fserrors.ContextError(ctx, &err) {
+ break
+ }
+ // Retry if err returned a retry error
+ if fserrors.IsRetryError(err) || fserrors.ShouldRetry(err) {
+ fs.Debugf(o, "Received error: %v - low level retry %d/%d", err, tries, maxTries)
+ continue
+ } else if t, ok := pacer.IsRetryAfter(err); ok {
+ fs.Debugf(o, "Sleeping for %v (as indicated by the server) to obey Retry-After error: %v", t, err)
+ time.Sleep(t)
+ continue
+ }
+ break
+ }
+ return err
+}
+
+// ListFn lists the Fs to the supplied function
+//
+// Lists in parallel which may get them out of order
+func ListFn(ctx context.Context, f fs.Fs, fn func(fs.Object)) error {
+ ci := fs.GetConfig(ctx)
+ return walk.ListR(ctx, f, "", false, ci.MaxDepth, walk.ListObjects, func(entries fs.DirEntries) error {
+ entries.ForObject(fn)
+ return nil
+ })
+}
+
+// StdoutMutex mutex for synchronized output on stdout
+var StdoutMutex sync.Mutex
+
+// SyncPrintf is a global var holding the Printf function so that it
+// can be overridden.
+//
+// This writes to stdout holding the StdoutMutex. If you are going to
+// override it and write to os.Stdout then you should hold the
+// StdoutMutex too.
+var SyncPrintf = func(format string, a ...any) {
+ StdoutMutex.Lock()
+ defer StdoutMutex.Unlock()
+ fmt.Printf(format, a...)
+}
+
+// SyncFprintf - Synchronized fmt.Fprintf
+//
+// Ignores errors from Fprintf.
+//
+// Prints to stdout if w is nil
+func SyncFprintf(w io.Writer, format string, a ...any) {
+ if w == nil || w == os.Stdout {
+ SyncPrintf(format, a...)
+ } else {
+ StdoutMutex.Lock()
+ defer StdoutMutex.Unlock()
+ _, _ = fmt.Fprintf(w, format, a...)
+ }
+}
+
+// SizeString make string representation of size for output
+//
+// Optional human-readable format including a binary suffix
+func SizeString(size int64, humanReadable bool) string {
+ if humanReadable {
+ if size < 0 {
+ return "-" + fs.SizeSuffix(-size).String()
+ }
+ return fs.SizeSuffix(size).String()
+ }
+ return strconv.FormatInt(size, 10)
+}
+
+// SizeStringField make string representation of size for output in fixed width field
+//
+// Optional human-readable format including a binary suffix
+// Argument rawWidth is used to format field with of raw value. When humanReadable
+// option the width is hard coded to 9, since SizeSuffix strings have precision 3
+// and longest value will be "999.999Ei". This way the width can be optimized
+// depending to the humanReadable option. To always use a longer width the return
+// value can always be fed into another format string with a specific field with.
+func SizeStringField(size int64, humanReadable bool, rawWidth int) string {
+ str := SizeString(size, humanReadable)
+ if humanReadable {
+ return fmt.Sprintf("%9s", str)
+ }
+ return fmt.Sprintf("%[2]*[1]s", str, rawWidth)
+}
+
+// CountString make string representation of count for output
+//
+// Optional human-readable format including a decimal suffix
+func CountString(count int64, humanReadable bool) string {
+ if humanReadable {
+ if count < 0 {
+ return "-" + fs.CountSuffix(-count).String()
+ }
+ return fs.CountSuffix(count).String()
+ }
+ return strconv.FormatInt(count, 10)
+}
+
+// CountStringField make string representation of count for output in fixed width field
+//
+// Similar to SizeStringField, but human readable with decimal prefix and field width 8
+// since there is no 'i' in the decimal prefix symbols (e.g. "999.999E")
+func CountStringField(count int64, humanReadable bool, rawWidth int) string {
+ str := CountString(count, humanReadable)
+ if humanReadable {
+ return fmt.Sprintf("%8s", str)
+ }
+ return fmt.Sprintf("%[2]*[1]s", str, rawWidth)
+}
+
+// List the Fs to the supplied writer
+//
+// Shows size and path - obeys includes and excludes.
+//
+// Lists in parallel which may get them out of order
+func List(ctx context.Context, f fs.Fs, w io.Writer) error {
+ ci := fs.GetConfig(ctx)
+ return ListFn(ctx, f, func(o fs.Object) {
+ SyncFprintf(w, "%s %s\n", SizeStringField(o.Size(), ci.HumanReadable, 9), o.Remote())
+ })
+}
+
+// ListLong lists the Fs to the supplied writer
+//
+// Shows size, mod time and path - obeys includes and excludes.
+//
+// Lists in parallel which may get them out of order
+func ListLong(ctx context.Context, f fs.Fs, w io.Writer) error {
+ ci := fs.GetConfig(ctx)
+ return ListFn(ctx, f, func(o fs.Object) {
+ tr := accounting.Stats(ctx).NewCheckingTransfer(o, "listing")
+ defer func() {
+ tr.Done(ctx, nil)
+ }()
+ modTime := o.ModTime(ctx)
+ SyncFprintf(w, "%s %s %s\n", SizeStringField(o.Size(), ci.HumanReadable, 9), modTime.Local().Format("2006-01-02 15:04:05.000000000"), o.Remote())
+ })
+}
+
+// HashSum returns the human-readable hash for ht passed in. This may
+// be UNSUPPORTED or ERROR. If it isn't returning a valid hash it will
+// return an error.
+func HashSum(ctx context.Context, ht hash.Type, base64Encoded bool, downloadFlag bool, o fs.Object) (string, error) {
+ var sum string
+ var err error
+
+ // If downloadFlag is true, download and hash the file.
+ // If downloadFlag is false, call o.Hash asking the remote for the hash
+ if downloadFlag {
+ // Setup: Define accounting, open the file with NewReOpen to provide restarts, account for the transfer, and setup a multi-hasher with the appropriate type
+ // Execution: io.Copy file to hasher, get hash and encode in hex
+
+ tr := accounting.Stats(ctx).NewTransfer(o, nil)
+ defer func() {
+ tr.Done(ctx, err)
+ }()
+
+ // Open with NewReOpen to provide restarts
+ var options []fs.OpenOption
+ for _, option := range fs.GetConfig(ctx).DownloadHeaders {
+ options = append(options, option)
+ }
+ var in io.ReadCloser
+ in, err = Open(ctx, o, options...)
+ if err != nil {
+ return "ERROR", fmt.Errorf("failed to open file %v: %w", o, err)
+ }
+
+ // Account and buffer the transfer
+ in = tr.Account(ctx, in).WithBuffer()
+
+ // Setup hasher
+ hasher, err := hash.NewMultiHasherTypes(hash.NewHashSet(ht))
+ if err != nil {
+ return "UNSUPPORTED", fmt.Errorf("hash unsupported: %w", err)
+ }
+
+ // Copy to hasher, downloading the file and passing directly to hash
+ _, err = io.Copy(hasher, in)
+ if err != nil {
+ return "ERROR", fmt.Errorf("failed to copy file to hasher: %w", err)
+ }
+
+ // Get hash as hex or base64 encoded string
+ sum, err = hasher.SumString(ht, base64Encoded)
+ if err != nil {
+ return "ERROR", fmt.Errorf("hasher returned an error: %w", err)
+ }
+ } else {
+ tr := accounting.Stats(ctx).NewCheckingTransfer(o, "hashing")
+ defer func() {
+ tr.Done(ctx, err)
+ }()
+
+ sum, err = o.Hash(ctx, ht)
+ if base64Encoded {
+ hexBytes, _ := hex.DecodeString(sum)
+ sum = base64.URLEncoding.EncodeToString(hexBytes)
+ }
+ if err == hash.ErrUnsupported {
+ return "", fmt.Errorf("hash unsupported: %w", err)
+ }
+ if err != nil {
+ return "", fmt.Errorf("failed to get hash %v from backend: %w", ht, err)
+ }
+ }
+
+ return sum, nil
+}
+
+// HashLister does an md5sum equivalent for the hash type passed in
+// Updated to handle both standard hex encoding and base64
+// Updated to perform multiple hashes concurrently
+func HashLister(ctx context.Context, ht hash.Type, outputBase64 bool, downloadFlag bool, f fs.Fs, w io.Writer) error {
+ width := hash.Width(ht, outputBase64)
+ // Use --checkers concurrency unless downloading in which case use --transfers
+ concurrency := fs.GetConfig(ctx).Checkers
+ if downloadFlag {
+ concurrency = fs.GetConfig(ctx).Transfers
+ }
+ concurrencyControl := make(chan struct{}, concurrency)
+ var wg sync.WaitGroup
+ err := ListFn(ctx, f, func(o fs.Object) {
+ wg.Add(1)
+ concurrencyControl <- struct{}{}
+ go func() {
+ defer func() {
+ <-concurrencyControl
+ wg.Done()
+ }()
+ sum, err := HashSum(ctx, ht, outputBase64, downloadFlag, o)
+ if err != nil {
+ fs.Errorf(o, "%v", fs.CountError(ctx, err))
+ return
+ }
+ SyncFprintf(w, "%*s %s\n", width, sum, o.Remote())
+ }()
+ })
+ wg.Wait()
+ return err
+}
+
+// HashSumStream outputs a line compatible with md5sum to w based on the
+// input stream in and the hash type ht passed in. If outputBase64 is
+// set then the hash will be base64 instead of hexadecimal.
+func HashSumStream(ht hash.Type, outputBase64 bool, in io.ReadCloser, w io.Writer) error {
+ hasher, err := hash.NewMultiHasherTypes(hash.NewHashSet(ht))
+ if err != nil {
+ return fmt.Errorf("hash unsupported: %w", err)
+ }
+ written, err := io.Copy(hasher, in)
+ fs.Debugf(nil, "Creating %s hash of %d bytes read from input stream", ht, written)
+ if err != nil {
+ return fmt.Errorf("failed to copy input to hasher: %w", err)
+ }
+ sum, err := hasher.SumString(ht, outputBase64)
+ if err != nil {
+ return fmt.Errorf("hasher returned an error: %w", err)
+ }
+ width := hash.Width(ht, outputBase64)
+ SyncFprintf(w, "%*s -\n", width, sum)
+ return nil
+}
+
+// Count counts the objects and their sizes in the Fs
+//
+// Obeys includes and excludes
+func Count(ctx context.Context, f fs.Fs) (objects int64, size int64, sizelessObjects int64, err error) {
+ err = ListFn(ctx, f, func(o fs.Object) {
+ atomic.AddInt64(&objects, 1)
+ objectSize := o.Size()
+ if objectSize < 0 {
+ atomic.AddInt64(&sizelessObjects, 1)
+ } else if objectSize > 0 {
+ atomic.AddInt64(&size, objectSize)
+ }
+ })
+ return
+}
+
+// ConfigMaxDepth returns the depth to use for a recursive or non recursive listing.
+func ConfigMaxDepth(ctx context.Context, recursive bool) int {
+ ci := fs.GetConfig(ctx)
+ depth := ci.MaxDepth
+ if !recursive && depth < 0 {
+ depth = 1
+ }
+ return depth
+}
+
+// ListDir lists the directories/buckets/containers in the Fs to the supplied writer
+func ListDir(ctx context.Context, f fs.Fs, w io.Writer) error {
+ ci := fs.GetConfig(ctx)
+ return walk.ListR(ctx, f, "", false, ConfigMaxDepth(ctx, false), walk.ListDirs, func(entries fs.DirEntries) error {
+ entries.ForDir(func(dir fs.Directory) {
+ if dir != nil {
+ SyncFprintf(w, "%s %13s %s %s\n", SizeStringField(dir.Size(), ci.HumanReadable, 12), dir.ModTime(ctx).Local().Format("2006-01-02 15:04:05"), CountStringField(dir.Items(), ci.HumanReadable, 9), dir.Remote())
+ }
+ })
+ return nil
+ })
+}
+
+// Mkdir makes a destination directory or container
+func Mkdir(ctx context.Context, f fs.Fs, dir string) error {
+ if SkipDestructive(ctx, fs.LogDirName(f, dir), "make directory") {
+ return nil
+ }
+ fs.Infof(fs.LogDirName(f, dir), "Making directory")
+ err := f.Mkdir(ctx, dir)
+ if err != nil {
+ err = fs.CountError(ctx, err)
+ return err
+ }
+ return nil
+}
+
+// MkdirMetadata makes a destination directory or container with metadata
+//
+// If the destination Fs doesn't support this it will fall back to
+// Mkdir and in this case newDst will be nil.
+func MkdirMetadata(ctx context.Context, f fs.Fs, dir string, metadata fs.Metadata) (newDst fs.Directory, err error) {
+ do := f.Features().MkdirMetadata
+ if do == nil {
+ return nil, Mkdir(ctx, f, dir)
+ }
+ logName := fs.LogDirName(f, dir)
+ if SkipDestructive(ctx, logName, "make directory") {
+ return nil, nil
+ }
+ fs.Debugf(fs.LogDirName(f, dir), "Making directory with metadata")
+ newDst, err = do(ctx, dir, metadata)
+ if err != nil {
+ err = fs.CountError(ctx, err)
+ return nil, err
+ }
+ if mtime, ok := metadata["mtime"]; ok {
+ fs.Infof(logName, "Made directory with metadata (mtime=%s)", mtime)
+ } else {
+ fs.Infof(logName, "Made directory with metadata")
+ }
+ return newDst, err
+}
+
+// MkdirModTime makes a destination directory or container with modtime
+//
+// It will try to make the directory with MkdirMetadata and if that
+// succeeds it will return a non-nil newDst. In all other cases newDst
+// will be nil.
+//
+// If the directory was created with MkDir then it will attempt to use
+// Fs.DirSetModTime to update the directory modtime if available.
+func MkdirModTime(ctx context.Context, f fs.Fs, dir string, modTime time.Time) (newDst fs.Directory, err error) {
+ logName := fs.LogDirName(f, dir)
+ if SkipDestructive(ctx, logName, "make directory") {
+ return nil, nil
+ }
+ metadata := fs.Metadata{
+ "mtime": modTime.Format(time.RFC3339Nano),
+ }
+ newDst, err = MkdirMetadata(ctx, f, dir, metadata)
+ if err != nil {
+ return nil, err
+ }
+ if newDst != nil {
+ // The directory was created and we have logged already
+ return newDst, nil
+ }
+ // The directory was created with Mkdir then we should try to set the time
+ if do := f.Features().DirSetModTime; do != nil {
+ err = do(ctx, dir, modTime)
+ }
+ fs.Infof(logName, "Made directory with modification time %v", modTime)
+ return newDst, err
+}
+
+// TryRmdir removes a container but not if not empty. It doesn't
+// count errors but may return one.
+func TryRmdir(ctx context.Context, f fs.Fs, dir string) error {
+ accounting.Stats(ctx).DeletedDirs(1)
+ if SkipDestructive(ctx, fs.LogDirName(f, dir), "remove directory") {
+ return nil
+ }
+ fs.Infof(fs.LogDirName(f, dir), "Removing directory")
+ return f.Rmdir(ctx, dir)
+}
+
+// Rmdir removes a container but not if not empty
+func Rmdir(ctx context.Context, f fs.Fs, dir string) error {
+ err := TryRmdir(ctx, f, dir)
+ if err != nil {
+ err = fs.CountError(ctx, err)
+ return err
+ }
+ return err
+}
+
+// Purge removes a directory and all of its contents
+func Purge(ctx context.Context, f fs.Fs, dir string) (err error) {
+ doFallbackPurge := true
+ if doPurge := f.Features().Purge; doPurge != nil {
+ doFallbackPurge = false
+ accounting.Stats(ctx).DeletedDirs(1)
+ if SkipDestructive(ctx, fs.LogDirName(f, dir), "purge directory") {
+ return nil
+ }
+ err = doPurge(ctx, dir)
+ if errors.Is(err, fs.ErrorCantPurge) {
+ doFallbackPurge = true
+ }
+ }
+ if doFallbackPurge {
+ // DeleteFiles and Rmdir observe --dry-run
+ err = DeleteFiles(ctx, listToChan(ctx, f, dir))
+ if err != nil {
+ return err
+ }
+ err = Rmdirs(ctx, f, dir, false)
+ }
+ if err != nil {
+ err = fs.CountError(ctx, err)
+ return err
+ }
+ return nil
+}
+
+// Delete removes all the contents of a container. Unlike Purge, it
+// obeys includes and excludes.
+func Delete(ctx context.Context, f fs.Fs) error {
+ ci := fs.GetConfig(ctx)
+ delChan := make(fs.ObjectsChan, ci.Checkers)
+ delErr := make(chan error, 1)
+ go func() {
+ delErr <- DeleteFiles(ctx, delChan)
+ }()
+ err := ListFn(ctx, f, func(o fs.Object) {
+ delChan <- o
+ })
+ close(delChan)
+ delError := <-delErr
+ if err == nil {
+ err = delError
+ }
+ return err
+}
+
+// RemoveExisting removes an existing file in a safe way so that it
+// can be restored if the operation fails.
+//
+// This first detects if there is an existing file and renames it to a
+// temporary name if there is.
+//
+// The returned cleanup function should be called on a defer statement
+// with a pointer to the error returned. It will revert the changes if
+// there is an error or delete the existing file if not.
+func RemoveExisting(ctx context.Context, f fs.Fs, remote string, operation string) (cleanup func(*error), err error) {
+ existingObj, err := f.NewObject(ctx, remote)
+ if err != nil {
+ return func(*error) {}, nil
+ }
+ doMove := f.Features().Move
+ if doMove == nil {
+ return nil, fmt.Errorf("%s: destination file exists already and can't rename", operation)
+ }
+
+ // Avoid making the leaf name longer if it's already lengthy to avoid
+ // trouble with file name length limits.
+ suffix := "." + random.String(8)
+ var remoteSaved string
+ if len(path.Base(remote)) > 100 {
+ remoteSaved = TruncateString(remote, len(remote)-len(suffix)) + suffix
+ } else {
+ remoteSaved = remote + suffix
+ }
+
+ fs.Debugf(existingObj, "%s: renaming existing object to %q before starting", operation, remoteSaved)
+ existingObj, err = doMove(ctx, existingObj, remoteSaved)
+ if err != nil {
+ return nil, fmt.Errorf("%s: failed to rename existing file: %w", operation, err)
+ }
+ return func(perr *error) {
+ if *perr == nil {
+ fs.Debugf(existingObj, "%s: removing renamed existing file after operation", operation)
+ err := existingObj.Remove(ctx)
+ if err != nil {
+ *perr = fmt.Errorf("%s: failed to remove renamed existing file: %w", operation, err)
+ }
+ } else {
+ fs.Debugf(existingObj, "%s: renaming existing back after failed operation", operation)
+ _, renameErr := doMove(ctx, existingObj, remote)
+ if renameErr != nil {
+ fs.Errorf(existingObj, "%s: failed to restore existing file after failed operation: %v", operation, renameErr)
+ }
+ }
+ }, nil
+}
+
+// listToChan will transfer all objects in the listing to the output
+//
+// If an error occurs, the error will be logged, and it will close the
+// channel.
+//
+// If the error was ErrorDirNotFound then it will be ignored
+func listToChan(ctx context.Context, f fs.Fs, dir string) fs.ObjectsChan {
+ ci := fs.GetConfig(ctx)
+ o := make(fs.ObjectsChan, ci.Checkers)
+ go func() {
+ defer close(o)
+ err := walk.ListR(ctx, f, dir, true, ci.MaxDepth, walk.ListObjects, func(entries fs.DirEntries) error {
+ entries.ForObject(func(obj fs.Object) {
+ o <- obj
+ })
+ return nil
+ })
+ if err != nil && err != fs.ErrorDirNotFound {
+ err = fmt.Errorf("failed to list: %w", err)
+ err = fs.CountError(ctx, err)
+ fs.Errorf(nil, "%v", err)
+ }
+ }()
+ return o
+}
+
+// CleanUp removes the trash for the Fs
+func CleanUp(ctx context.Context, f fs.Fs) error {
+ doCleanUp := f.Features().CleanUp
+ if doCleanUp == nil {
+ return fmt.Errorf("%v doesn't support cleanup", f)
+ }
+ if SkipDestructive(ctx, f, "clean up old files") {
+ return nil
+ }
+ return doCleanUp(ctx)
+}
+
+// wrap a Reader and a Closer together into a ReadCloser
+type readCloser struct {
+ io.Reader
+ io.Closer
+}
+
+// Cat any files to the io.Writer
+//
+// if offset == 0 it will be ignored
+// if offset > 0 then the file will be seeked to that offset
+// if offset < 0 then the file will be seeked that far from the end
+//
+// if count < 0 then it will be ignored
+// if count >= 0 then only that many characters will be output
+func Cat(ctx context.Context, f fs.Fs, w io.Writer, offset, count int64, sep []byte) error {
+ var mu sync.Mutex
+ ci := fs.GetConfig(ctx)
+ return ListFn(ctx, f, func(o fs.Object) {
+ var err error
+ tr := accounting.Stats(ctx).NewTransfer(o, nil)
+ defer func() {
+ tr.Done(ctx, err)
+ }()
+ opt := fs.RangeOption{Start: offset, End: -1}
+ size := o.Size()
+ if opt.Start < 0 {
+ opt.Start += size
+ }
+ if count >= 0 {
+ opt.End = opt.Start + count - 1
+ }
+ var options []fs.OpenOption
+ if opt.Start > 0 || opt.End >= 0 {
+ options = append(options, &opt)
+ }
+ for _, option := range ci.DownloadHeaders {
+ options = append(options, option)
+ }
+ var in io.ReadCloser
+ in, err = Open(ctx, o, options...)
+ if err != nil {
+ err = fs.CountError(ctx, err)
+ fs.Errorf(o, "Failed to open: %v", err)
+ return
+ }
+ if count >= 0 {
+ in = &readCloser{Reader: &io.LimitedReader{R: in, N: count}, Closer: in}
+ }
+ in = tr.Account(ctx, in).WithBuffer() // account and buffer the transfer
+ // take the lock just before we output stuff, so at the last possible moment
+ mu.Lock()
+ defer mu.Unlock()
+ _, err = io.Copy(w, in)
+ if err != nil {
+ err = fs.CountError(ctx, err)
+ fs.Errorf(o, "Failed to send to output: %v", err)
+ }
+ if len(sep) > 0 {
+ _, err = w.Write(sep)
+ if err != nil {
+ err = fs.CountError(ctx, err)
+ fs.Errorf(o, "Failed to send separator to output: %v", err)
+ }
+ }
+ })
+}
+
+// Rcat reads data from the Reader until EOF and uploads it to a file on remote
+//
+// in is closed at the end of the transfer
+func Rcat(ctx context.Context, fdst fs.Fs, dstFileName string, in io.ReadCloser, modTime time.Time, meta fs.Metadata) (dst fs.Object, err error) {
+ return rcatSrc(ctx, fdst, dstFileName, in, modTime, meta, nil)
+}
+
+// rcatSrc reads data from the Reader until EOF and uploads it to a file on remote
+//
+// in is closed at the end of the transfer
+//
+// Pass in fsrc if known or nil if not
+func rcatSrc(ctx context.Context, fdst fs.Fs, dstFileName string, in io.ReadCloser, modTime time.Time, meta fs.Metadata, fsrc fs.Fs) (dst fs.Object, err error) {
+ if SkipDestructive(ctx, dstFileName, "upload from pipe") {
+ // prevents "broken pipe" errors
+ _, err = io.Copy(io.Discard, in)
+ return nil, err
+ }
+
+ ci := fs.GetConfig(ctx)
+ tr := accounting.Stats(ctx).NewTransferRemoteSize(dstFileName, -1, nil, fdst)
+ defer func() {
+ tr.Done(ctx, err)
+ }()
+ var streamIn io.Reader = tr.Account(ctx, in).WithBuffer()
+
+ readCounter := readers.NewCountingReader(streamIn)
+ var trackingIn io.Reader
+ var hasher *hash.MultiHasher
+ var options []fs.OpenOption
+ if !ci.IgnoreChecksum {
+ hashes := hash.NewHashSet(fdst.Hashes().GetOne()) // just pick one hash
+ hashOption := &fs.HashesOption{Hashes: hashes}
+ options = append(options, hashOption)
+ hasher, err = hash.NewMultiHasherTypes(hashes)
+ if err != nil {
+ return nil, err
+ }
+ trackingIn = io.TeeReader(readCounter, hasher)
+ } else {
+ trackingIn = readCounter
+ }
+ for _, option := range ci.UploadHeaders {
+ options = append(options, option)
+ }
+ if ci.MetadataSet != nil {
+ options = append(options, fs.MetadataOption(ci.MetadataSet))
+ }
+
+ // get the sums from the hasher if in use, or nil
+ getSums := func() (sums map[hash.Type]string) {
+ if hasher != nil {
+ sums = hasher.Sums()
+ }
+ return sums
+ }
+
+ // Read the start of the input and check if it is small enough for direct upload
+ buf := make([]byte, ci.StreamingUploadCutoff)
+ fileIsSmall := false
+ if n, err := io.ReadFull(trackingIn, buf); err == io.EOF || err == io.ErrUnexpectedEOF {
+ fileIsSmall = true
+ buf = buf[:n]
+ }
+
+ // Read the data we have already read in buf and any further unread
+ streamIn = io.MultiReader(bytes.NewReader(buf), trackingIn)
+
+ doPutStream := fdst.Features().PutStream
+
+ // Upload the input
+ if fileIsSmall || doPutStream == nil {
+ var rs io.ReadSeeker
+ if fileIsSmall {
+ fs.Debugf(fdst, "File to upload is small (%d bytes), uploading instead of streaming", len(buf))
+ rs = bytes.NewReader(buf)
+ } else {
+ fs.Debugf(fdst, "Target remote doesn't support streaming uploads, creating temporary local FS to spool file")
+ spool, err := os.CreateTemp("", "rclone-spool")
+ if err != nil {
+ return nil, fmt.Errorf("failed to create temporary spool file: %v", err)
+ }
+ fileName := spool.Name()
+ defer func() {
+ err := spool.Close()
+ if err != nil {
+ fs.Errorf(fileName, "Failed to close temporary spool file: %v", err)
+ }
+ err = os.Remove(fileName)
+ if err != nil {
+ fs.Errorf(fileName, "Failed to delete temporary spool file: %v", err)
+ }
+ }()
+ _, err = io.Copy(spool, streamIn)
+ if err != nil {
+ return nil, fmt.Errorf("failed to copy to temporary spool file: %v", err)
+ }
+ rs = spool
+ }
+ // Upload with Put with retries - since we have downloaded the file we know the size, and the hashes
+ sums := getSums()
+ size := int64(readCounter.BytesRead())
+ objInfo := object.NewStaticObjectInfo(dstFileName, modTime, size, false, sums, fsrc).WithMetadata(meta)
+ err = Retry(ctx, objInfo, ci.LowLevelRetries, func() error {
+ _, err = rs.Seek(0, io.SeekStart)
+ if err != nil {
+ return fmt.Errorf("failed to rewind temporary spool file: %v", err)
+ }
+ dst, err = fdst.Put(ctx, rs, objInfo, options...)
+ return err
+ })
+ } else {
+ // Upload with PutStream with no retries
+ objInfo := object.NewStaticObjectInfo(dstFileName, modTime, -1, false, nil, fsrc).WithMetadata(meta)
+ dst, err = doPutStream(ctx, streamIn, objInfo, options...)
+ }
+ if err != nil {
+ return dst, err
+ }
+
+ // Check transfer
+ sums := getSums()
+ opt := defaultEqualOpt(ctx)
+ if sums != nil {
+ // force --checksum on if we have hashes
+ opt.checkSum = true
+ }
+ src := object.NewStaticObjectInfo(dstFileName, modTime, int64(readCounter.BytesRead()), false, sums, fdst).WithMetadata(meta)
+ if !equal(ctx, src, dst, opt) {
+ err = fmt.Errorf("corrupted on transfer")
+ err = fs.CountError(ctx, err)
+ fs.Errorf(dst, "%v", err)
+ return dst, err
+ }
+ return dst, nil
+}
+
+// PublicLink adds a "readable by anyone with link" permission on the given file or folder.
+func PublicLink(ctx context.Context, f fs.Fs, remote string, expire fs.Duration, unlink bool) (string, error) {
+ doPublicLink := f.Features().PublicLink
+ if doPublicLink == nil {
+ return "", fmt.Errorf("%v doesn't support public links", f)
+ }
+ return doPublicLink(ctx, remote, expire, unlink)
+}
+
+// Rmdirs removes any empty directories (or directories only
+// containing empty directories) under f, including f.
+//
+// Rmdirs obeys the filters
+func Rmdirs(ctx context.Context, f fs.Fs, dir string, leaveRoot bool) error {
+ ci := fs.GetConfig(ctx)
+ fi := filter.GetConfig(ctx)
+ dirEmpty := make(map[string]bool)
+ dirEmpty[dir] = !leaveRoot
+ err := walk.Walk(ctx, f, dir, false, ci.MaxDepth, func(dirPath string, entries fs.DirEntries, err error) error {
+ if err != nil {
+ err = fs.CountError(ctx, err)
+ fs.Errorf(f, "Failed to list %q: %v", dirPath, err)
+ return nil
+ }
+ for _, entry := range entries {
+ switch x := entry.(type) {
+ case fs.Directory:
+ // add a new directory as empty
+ dir := x.Remote()
+ _, found := dirEmpty[dir]
+ if !found {
+ dirEmpty[dir] = true
+ }
+ case fs.Object:
+ // mark the parents of the file as being non-empty
+ dir := x.Remote()
+ for dir != "" {
+ dir = path.Dir(dir)
+ if dir == "." || dir == "/" {
+ dir = ""
+ }
+ empty, found := dirEmpty[dir]
+ // End if we reach a directory which is non-empty
+ if found && !empty {
+ break
+ }
+ dirEmpty[dir] = false
+ }
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ return fmt.Errorf("failed to rmdirs: %w", err)
+ }
+
+ // Group directories to delete by level
+ var toDelete [][]string
+ for dir, empty := range dirEmpty {
+ if empty {
+ // If a filter matches the directory then that
+ // directory is a candidate for deletion
+ if fi.IncludeRemote(dir + "/") {
+ level := strings.Count(dir, "/") + 1
+ // The root directory "" is at the top level
+ if dir == "" {
+ level = 0
+ }
+ if len(toDelete) < level+1 {
+ toDelete = append(toDelete, make([][]string, level+1-len(toDelete))...)
+ }
+ toDelete[level] = append(toDelete[level], dir)
+ }
+ }
+ }
+
+ errCount := errcount.New()
+ // Delete all directories at the same level in parallel
+ for level := len(toDelete) - 1; level >= 0; level-- {
+ dirs := toDelete[level]
+ if len(dirs) == 0 {
+ continue
+ }
+ fs.Debugf(nil, "removing %d level %d directories", len(dirs), level)
+ sort.Strings(dirs)
+ g, gCtx := errgroup.WithContext(ctx)
+ g.SetLimit(ci.Checkers)
+ for _, dir := range dirs {
+ // End early if error
+ if gCtx.Err() != nil {
+ break
+ }
+ dir := dir
+ g.Go(func() error {
+ err := TryRmdir(gCtx, f, dir)
+ if err != nil {
+ err = fs.CountError(ctx, err)
+ fs.Errorf(dir, "Failed to rmdir: %v", err)
+ errCount.Add(err)
+ }
+ return nil // don't return errors, just count them
+ })
+ }
+ err := g.Wait()
+ if err != nil {
+ return err
+ }
+ }
+ return errCount.Err("failed to remove directories")
+}
+
+// GetCompareDest sets up --compare-dest
+func GetCompareDest(ctx context.Context) (CompareDest []fs.Fs, err error) {
+ ci := fs.GetConfig(ctx)
+ CompareDest, err = cache.GetArr(ctx, ci.CompareDest)
+ if err != nil {
+ return nil, fserrors.FatalError(fmt.Errorf("failed to make fs for --compare-dest %q: %w", ci.CompareDest, err))
+ }
+ return CompareDest, nil
+}
+
+// compareDest checks --compare-dest to see if src needs to
+// be copied
+//
+// Returns True if src is in --compare-dest
+func compareDest(ctx context.Context, dst, src fs.Object, CompareDest fs.Fs) (NoNeedTransfer bool, err error) {
+ var remote string
+ if dst == nil {
+ remote = src.Remote()
+ } else {
+ remote = dst.Remote()
+ }
+ CompareDestFile, err := CompareDest.NewObject(ctx, remote)
+ switch err {
+ case fs.ErrorObjectNotFound:
+ return false, nil
+ case nil:
+ break
+ default:
+ return false, err
+ }
+ opt := defaultEqualOpt(ctx)
+ opt.updateModTime = false
+ if equal(ctx, src, CompareDestFile, opt) {
+ fs.Debugf(src, "Destination found in --compare-dest, skipping")
+ return true, nil
+ }
+ return false, nil
+}
+
+// GetCopyDest sets up --copy-dest
+func GetCopyDest(ctx context.Context, fdst fs.Fs) (CopyDest []fs.Fs, err error) {
+ ci := fs.GetConfig(ctx)
+ CopyDest, err = cache.GetArr(ctx, ci.CopyDest)
+ if err != nil {
+ return nil, fserrors.FatalError(fmt.Errorf("failed to make fs for --copy-dest %q: %w", ci.CopyDest, err))
+ }
+ if !SameConfigArr(fdst, CopyDest) {
+ return nil, fserrors.FatalError(errors.New("parameter to --copy-dest has to be on the same remote as destination"))
+ }
+ for _, cf := range CopyDest {
+ if cf.Features().Copy == nil {
+ return nil, fserrors.FatalError(errors.New("can't use --copy-dest on a remote which doesn't support server side copy"))
+ }
+ }
+
+ return CopyDest, nil
+}
+
+// copyDest checks --copy-dest to see if src needs to
+// be copied
+//
+// Returns True if src was copied from --copy-dest
+func copyDest(ctx context.Context, fdst fs.Fs, dst, src fs.Object, CopyDest, backupDir fs.Fs) (NoNeedTransfer bool, err error) {
+ var remote string
+ if dst == nil {
+ remote = src.Remote()
+ } else {
+ remote = dst.Remote()
+ }
+ CopyDestFile, err := CopyDest.NewObject(ctx, remote)
+ switch err {
+ case fs.ErrorObjectNotFound:
+ return false, nil
+ case nil:
+ break
+ default:
+ return false, err
+ }
+ opt := defaultEqualOpt(ctx)
+ opt.updateModTime = false
+ if equal(ctx, src, CopyDestFile, opt) {
+ if dst == nil || !Equal(ctx, src, dst) {
+ if dst != nil && backupDir != nil {
+ err = MoveBackupDir(ctx, backupDir, dst)
+ if err != nil {
+ return false, fmt.Errorf("moving to --backup-dir failed: %w", err)
+ }
+ // If successful zero out the dstObj as it is no longer there
+ dst = nil
+ }
+ _, err := Copy(ctx, fdst, dst, remote, CopyDestFile)
+ if err != nil {
+ fs.Errorf(src, "Destination found in --copy-dest, error copying")
+ return false, nil
+ }
+ fs.Debugf(src, "Destination found in --copy-dest, using server-side copy")
+ return true, nil
+ }
+ fs.Debugf(src, "Unchanged skipping")
+ return true, nil
+ }
+ fs.Debugf(src, "Destination not found in --copy-dest")
+ return false, nil
+}
+
+// CompareOrCopyDest checks --compare-dest and --copy-dest to see if src
+// does not need to be copied
+//
+// Returns True if src does not need to be copied
+func CompareOrCopyDest(ctx context.Context, fdst fs.Fs, dst, src fs.Object, CompareOrCopyDest []fs.Fs, backupDir fs.Fs) (NoNeedTransfer bool, err error) {
+ ci := fs.GetConfig(ctx)
+ if len(ci.CompareDest) > 0 {
+ for _, compareF := range CompareOrCopyDest {
+ NoNeedTransfer, err := compareDest(ctx, dst, src, compareF)
+ if NoNeedTransfer || err != nil {
+ return NoNeedTransfer, err
+ }
+ }
+ } else if len(ci.CopyDest) > 0 {
+ for _, copyF := range CompareOrCopyDest {
+ NoNeedTransfer, err := copyDest(ctx, fdst, dst, src, copyF, backupDir)
+ if NoNeedTransfer || err != nil {
+ return NoNeedTransfer, err
+ }
+ }
+ }
+ return false, nil
+}
+
+// NeedTransfer checks to see if src needs to be copied to dst using
+// the current config.
+//
+// Returns a flag which indicates whether the file needs to be
+// transferred or not.
+func NeedTransfer(ctx context.Context, dst, src fs.Object) bool {
+ ci := fs.GetConfig(ctx)
+ logger, _ := GetLogger(ctx)
+ if dst == nil {
+ fs.Debugf(src, "Need to transfer - File not found at Destination")
+ logger(ctx, MissingOnDst, src, nil, nil)
+ return true
+ }
+ // If we should ignore existing files, don't transfer
+ if ci.IgnoreExisting {
+ fs.Debugf(src, "Destination exists, skipping")
+ logger(ctx, Match, src, dst, nil)
+ return false
+ }
+ // If we should upload unconditionally
+ if ci.IgnoreTimes {
+ fs.Debugf(src, "Transferring unconditionally as --ignore-times is in use")
+ logger(ctx, Differ, src, dst, nil)
+ return true
+ }
+ // If UpdateOlder is in effect, skip if dst is newer than src
+ if ci.UpdateOlder {
+ srcModTime := src.ModTime(ctx)
+ dstModTime := dst.ModTime(ctx)
+ dt := dstModTime.Sub(srcModTime)
+ // If have a mutually agreed precision then use that
+ modifyWindow := fs.GetModifyWindow(ctx, dst.Fs(), src.Fs())
+ if modifyWindow == fs.ModTimeNotSupported {
+ // Otherwise use 1 second as a safe default as
+ // the resolution of the time a file was
+ // uploaded.
+ modifyWindow = time.Second
+ }
+ switch {
+ case dt >= modifyWindow:
+ fs.Debugf(src, "Destination is newer than source, skipping")
+ logger(ctx, Match, src, dst, nil)
+ return false
+ case dt <= -modifyWindow:
+ // force --checksum on for the check and do update modtimes by default
+ opt := defaultEqualOpt(ctx)
+ opt.forceModTimeMatch = true
+ if equal(ctx, src, dst, opt) {
+ fs.Debugf(src, "Unchanged skipping")
+ return false
+ }
+ default:
+ // Do a size only compare unless --checksum is set
+ opt := defaultEqualOpt(ctx)
+ opt.sizeOnly = !ci.CheckSum
+ if equal(ctx, src, dst, opt) {
+ fs.Debugf(src, "Destination mod time is within %v of source and files identical, skipping", modifyWindow)
+ return false
+ }
+ fs.Debugf(src, "Destination mod time is within %v of source but files differ, transferring", modifyWindow)
+ }
+ } else {
+ // Check to see if changed or not
+ equalFn, ok := ctx.Value(equalFnKey).(EqualFn)
+ if ok {
+ return !equalFn(ctx, src, dst)
+ }
+ if Equal(ctx, src, dst) && !SameObject(src, dst) {
+ fs.Debugf(src, "Unchanged skipping")
+ return false
+ }
+ }
+ return true
+}
+
+// RcatSize reads data from the Reader until EOF and uploads it to a file on remote.
+// Pass in size >=0 if known, <0 if not known
+func RcatSize(ctx context.Context, fdst fs.Fs, dstFileName string, in io.ReadCloser, size int64, modTime time.Time, meta fs.Metadata) (dst fs.Object, err error) {
+ var obj fs.Object
+
+ if size >= 0 {
+ var err error
+ // Size known use Put
+ tr := accounting.Stats(ctx).NewTransferRemoteSize(dstFileName, size, nil, fdst)
+ defer func() {
+ tr.Done(ctx, err)
+ }()
+ body := io.NopCloser(in) // we let the server close the body
+ in := tr.Account(ctx, body) // account the transfer (no buffering)
+
+ if SkipDestructive(ctx, dstFileName, "upload from pipe") {
+ // prevents "broken pipe" errors
+ _, err = io.Copy(io.Discard, in)
+ return nil, err
+ }
+
+ info := object.NewStaticObjectInfo(dstFileName, modTime, size, true, nil, fdst).WithMetadata(meta)
+ obj, err = fdst.Put(ctx, in, info)
+ if err != nil {
+ fs.Errorf(dstFileName, "Post request put error: %v", err)
+
+ return nil, err
+ }
+ } else {
+ // Size unknown use Rcat
+ obj, err = Rcat(ctx, fdst, dstFileName, in, modTime, meta)
+ if err != nil {
+ fs.Errorf(dstFileName, "Post request rcat error: %v", err)
+
+ return nil, err
+ }
+ }
+
+ return obj, nil
+}
+
+// copyURLFunc is called from CopyURLFn
+type copyURLFunc func(ctx context.Context, dstFileName string, in io.ReadCloser, size int64, modTime time.Time) (err error)
+
+// copyURLFn copies the data from the url to the function supplied
+func copyURLFn(ctx context.Context, dstFileName string, url string, autoFilename, dstFileNameFromHeader bool, fn copyURLFunc) (err error) {
+ client := fshttp.NewClient(ctx)
+ resp, err := client.Get(url)
+ if err != nil {
+ return err
+ }
+ defer fs.CheckClose(resp.Body, &err)
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return fmt.Errorf("CopyURL failed: %s", resp.Status)
+ }
+ modTime, err := http.ParseTime(resp.Header.Get("Last-Modified"))
+ if err != nil {
+ modTime = time.Now()
+ }
+ if autoFilename {
+ if dstFileNameFromHeader {
+ _, params, err := mime.ParseMediaType(resp.Header.Get("Content-Disposition"))
+ headerFilename := path.Base(strings.ReplaceAll(params["filename"], "\\", "/"))
+ if err != nil || headerFilename == "" {
+ return fmt.Errorf("CopyURL failed: filename not found in the Content-Disposition header")
+ }
+ fs.Debugf(headerFilename, "filename found in Content-Disposition header.")
+ return fn(ctx, headerFilename, resp.Body, resp.ContentLength, modTime)
+ }
+
+ dstFileName = path.Base(resp.Request.URL.Path)
+ if dstFileName == "." || dstFileName == "/" {
+ return fmt.Errorf("CopyURL failed: file name wasn't found in url")
+ }
+ fs.Debugf(dstFileName, "File name found in url")
+ }
+ return fn(ctx, dstFileName, resp.Body, resp.ContentLength, modTime)
+}
+
+// CopyURL copies the data from the url to (fdst, dstFileName)
+func CopyURL(ctx context.Context, fdst fs.Fs, dstFileName string, url string, autoFilename, dstFileNameFromHeader bool, noClobber bool) (dst fs.Object, err error) {
+ err = copyURLFn(ctx, dstFileName, url, autoFilename, dstFileNameFromHeader, func(ctx context.Context, dstFileName string, in io.ReadCloser, size int64, modTime time.Time) (err error) {
+ if noClobber {
+ _, err = fdst.NewObject(ctx, dstFileName)
+ if err == nil {
+ return errors.New("CopyURL failed: file already exist")
+ }
+ }
+ dst, err = RcatSize(ctx, fdst, dstFileName, in, size, modTime, nil)
+ return err
+ })
+ return dst, err
+}
+
+// CopyURLToWriter copies the data from the url to the io.Writer supplied
+func CopyURLToWriter(ctx context.Context, url string, out io.Writer) (err error) {
+ return copyURLFn(ctx, "", url, false, false, func(ctx context.Context, dstFileName string, in io.ReadCloser, size int64, modTime time.Time) (err error) {
+ _, err = io.Copy(out, in)
+ return err
+ })
+}
+
+// BackupDir returns the correctly configured --backup-dir
+func BackupDir(ctx context.Context, fdst fs.Fs, fsrc fs.Fs, srcFileName string) (backupDir fs.Fs, err error) {
+ ci := fs.GetConfig(ctx)
+ if ci.BackupDir != "" {
+ backupDir, err = cache.Get(ctx, ci.BackupDir)
+ if err != nil {
+ return nil, fserrors.FatalError(fmt.Errorf("failed to make fs for --backup-dir %q: %w", ci.BackupDir, err))
+ }
+ if !SameConfig(fdst, backupDir) {
+ return nil, fserrors.FatalError(errors.New("parameter to --backup-dir has to be on the same remote as destination"))
+ }
+ if srcFileName == "" {
+ if OverlappingFilterCheck(ctx, backupDir, fdst) {
+ return nil, fserrors.FatalError(errors.New("destination and parameter to --backup-dir mustn't overlap"))
+ }
+ if OverlappingFilterCheck(ctx, backupDir, fsrc) {
+ return nil, fserrors.FatalError(errors.New("source and parameter to --backup-dir mustn't overlap"))
+ }
+ } else if ci.Suffix == "" {
+ if SameDir(fdst, backupDir) {
+ return nil, fserrors.FatalError(errors.New("destination and parameter to --backup-dir mustn't be the same"))
+ }
+ if SameDir(fsrc, backupDir) {
+ return nil, fserrors.FatalError(errors.New("source and parameter to --backup-dir mustn't be the same"))
+ }
+ }
+ } else if ci.Suffix != "" {
+ // --backup-dir is not set but --suffix is - use the destination as the backupDir
+ backupDir = fdst
+ } else {
+ return nil, fserrors.FatalError(errors.New("internal error: BackupDir called when --backup-dir and --suffix both empty"))
+ }
+ if !CanServerSideMove(backupDir) {
+ return nil, fserrors.FatalError(errors.New("can't use --backup-dir on a remote which doesn't support server-side move or copy"))
+ }
+ return backupDir, nil
+}
+
+// MoveBackupDir moves a file to the backup dir
+func MoveBackupDir(ctx context.Context, backupDir fs.Fs, dst fs.Object) (err error) {
+ remoteWithSuffix := SuffixName(ctx, dst.Remote())
+ overwritten, _ := backupDir.NewObject(ctx, remoteWithSuffix)
+ _, err = Move(ctx, backupDir, overwritten, remoteWithSuffix, dst)
+ return err
+}
+
+// needsMoveCaseInsensitive returns true if moveCaseInsensitive is needed
+func needsMoveCaseInsensitive(fdst fs.Fs, fsrc fs.Fs, dstFileName string, srcFileName string, cp bool) bool {
+ dstFilePath := path.Join(fdst.Root(), dstFileName)
+ srcFilePath := path.Join(fsrc.Root(), srcFileName)
+ if !cp && fdst.Name() == fsrc.Name() && dstFileName != srcFileName && norm.NFC.String(dstFilePath) == norm.NFC.String(srcFilePath) {
+ return true
+ }
+ return !cp && fdst.Name() == fsrc.Name() && fdst.Features().CaseInsensitive && dstFileName != srcFileName && strings.EqualFold(dstFilePath, srcFilePath)
+}
+
+// MoveCaseInsensitive handles changing case of a file on a case insensitive remote.
+// This will move the file to a temporary name then
+// move it back to the intended destination. This is required
+// to avoid issues with certain remotes and avoid file deletion.
+// returns nil, nil if !needsMoveCaseInsensitive.
+// this does not account a transfer -- the caller should do that if desired.
+func MoveCaseInsensitive(ctx context.Context, fdst fs.Fs, fsrc fs.Fs, dstFileName string, srcFileName string, cp bool, srcObj fs.Object) (newDst fs.Object, err error) {
+ logger, _ := GetLogger(ctx)
+
+ // Choose operations
+ Op := MoveTransfer
+ if cp {
+ Op = Copy
+ }
+
+ if SkipDestructive(ctx, srcFileName, "rename to "+dstFileName) {
+ // avoid fatalpanic on --dry-run (trying to access non-existent tmpObj)
+ return nil, nil
+ }
+ // Create random name to temporarily move file to
+ tmpObjName := dstFileName + "-rclone-move-" + random.String(8)
+ tmpObjFail, err := fdst.NewObject(ctx, tmpObjName)
+ if err != fs.ErrorObjectNotFound {
+ if err == nil {
+ logger(ctx, TransferError, nil, tmpObjFail, err)
+ return nil, errors.New("found an already existing file with a randomly generated name. Try the operation again")
+ }
+ logger(ctx, TransferError, nil, tmpObjFail, err)
+ return nil, fmt.Errorf("error while attempting to move file to a temporary location: %w", err)
+ }
+ fs.Debugf(srcObj, "moving to %v", tmpObjName)
+ tmpObj, err := Op(ctx, fdst, nil, tmpObjName, srcObj)
+ if err != nil {
+ logger(ctx, TransferError, srcObj, tmpObj, err)
+ return nil, fmt.Errorf("error while moving file to temporary location: %w", err)
+ }
+ fs.Debugf(srcObj, "moving to %v", dstFileName)
+ newDst, err = Op(ctx, fdst, nil, dstFileName, tmpObj)
+ logger(ctx, MissingOnDst, tmpObj, nil, err)
+ return newDst, err
+}
+
+// moveOrCopyFile moves or copies a single file possibly to a new name
+func moveOrCopyFile(ctx context.Context, fdst fs.Fs, fsrc fs.Fs, dstFileName string, srcFileName string, cp bool, allowOverlap bool) (err error) {
+ ci := fs.GetConfig(ctx)
+ logger, usingLogger := GetLogger(ctx)
+ dstFilePath := path.Join(fdst.Root(), dstFileName)
+ srcFilePath := path.Join(fsrc.Root(), srcFileName)
+ if fdst.Name() == fsrc.Name() && dstFilePath == srcFilePath && !allowOverlap {
+ fs.Debugf(fdst, "don't need to copy/move %s, it is already at target location", dstFileName)
+ if usingLogger {
+ srcObj, _ := fsrc.NewObject(ctx, srcFileName)
+ dstObj, _ := fsrc.NewObject(ctx, dstFileName)
+ logger(ctx, Match, srcObj, dstObj, nil)
+ }
+ return nil
+ }
+
+ // Choose operations
+ Op := MoveTransfer
+ if cp {
+ Op = Copy
+ }
+
+ // Find src object
+ srcObj, err := fsrc.NewObject(ctx, srcFileName)
+ if err != nil {
+ logger(ctx, TransferError, srcObj, nil, err)
+ return err
+ }
+
+ // Find dst object if it exists
+ var dstObj fs.Object
+ if !ci.NoCheckDest {
+ dstObj, err = fdst.NewObject(ctx, dstFileName)
+ if errors.Is(err, fs.ErrorObjectNotFound) {
+ dstObj = nil
+ } else if err != nil {
+ logger(ctx, TransferError, nil, dstObj, err)
+ return err
+ }
+ }
+
+ // Special case for changing case of a file on a case insensitive remote
+ // This will move the file to a temporary name then
+ // move it back to the intended destination. This is required
+ // to avoid issues with certain remotes and avoid file deletion.
+ if needsMoveCaseInsensitive(fdst, fsrc, dstFileName, srcFileName, cp) {
+ tr := accounting.Stats(ctx).NewTransfer(srcObj, fdst)
+ defer func() {
+ tr.Done(ctx, err)
+ }()
+ _, err = MoveCaseInsensitive(ctx, fdst, fsrc, dstFileName, srcFileName, cp, srcObj)
+ return err
+ }
+
+ var backupDir fs.Fs
+ var copyDestDir []fs.Fs
+ if ci.BackupDir != "" || ci.Suffix != "" {
+ backupDir, err = BackupDir(ctx, fdst, fsrc, srcFileName)
+ if err != nil {
+ return fmt.Errorf("creating Fs for --backup-dir failed: %w", err)
+ }
+ }
+ if len(ci.CompareDest) > 0 {
+ copyDestDir, err = GetCompareDest(ctx)
+ if err != nil {
+ return err
+ }
+ } else if len(ci.CopyDest) > 0 {
+ copyDestDir, err = GetCopyDest(ctx, fdst)
+ if err != nil {
+ return err
+ }
+ }
+ needTransfer := NeedTransfer(ctx, dstObj, srcObj)
+ if needTransfer {
+ NoNeedTransfer, err := CompareOrCopyDest(ctx, fdst, dstObj, srcObj, copyDestDir, backupDir)
+ if err != nil {
+ return err
+ }
+ if NoNeedTransfer {
+ needTransfer = false
+ }
+ }
+ if needTransfer {
+ // If destination already exists, then we must move it into --backup-dir if required
+ if dstObj != nil && backupDir != nil {
+ err = MoveBackupDir(ctx, backupDir, dstObj)
+ if err != nil {
+ logger(ctx, TransferError, dstObj, nil, err)
+ return fmt.Errorf("moving to --backup-dir failed: %w", err)
+ }
+ // If successful zero out the dstObj as it is no longer there
+ logger(ctx, MissingOnDst, dstObj, nil, nil)
+ dstObj = nil
+ }
+
+ _, err = Op(ctx, fdst, dstObj, dstFileName, srcObj)
+ } else if !cp {
+ if ci.IgnoreExisting {
+ fs.Debugf(srcObj, "Not removing source file as destination file exists and --ignore-existing is set")
+ logger(ctx, Match, srcObj, dstObj, nil)
+ } else if !SameObject(srcObj, dstObj) {
+ err = DeleteFile(ctx, srcObj)
+ logger(ctx, Differ, srcObj, dstObj, nil)
+ }
+ }
+ return err
+}
+
+// MoveFile moves a single file possibly to a new name
+//
+// This is treated as a transfer.
+func MoveFile(ctx context.Context, fdst fs.Fs, fsrc fs.Fs, dstFileName string, srcFileName string) (err error) {
+ return moveOrCopyFile(ctx, fdst, fsrc, dstFileName, srcFileName, false, false)
+}
+
+// TransformFile transforms a file in place using --name-transform
+//
+// This is treated as a transfer.
+func TransformFile(ctx context.Context, fdst fs.Fs, srcFileName string) (err error) {
+ return moveOrCopyFile(ctx, fdst, fdst, srcFileName, srcFileName, false, true)
+}
+
+// SetTier changes tier of object in remote
+func SetTier(ctx context.Context, fsrc fs.Fs, tier string) error {
+ return ListFn(ctx, fsrc, func(o fs.Object) {
+ objImpl, ok := o.(fs.SetTierer)
+ if !ok {
+ fs.Errorf(fsrc, "Remote object does not implement SetTier")
+ return
+ }
+ err := objImpl.SetTier(tier)
+ if err != nil {
+ fs.Errorf(fsrc, "Failed to do SetTier, %v", err)
+ }
+ })
+}
+
+// SetTierFile changes tier of a single file in remote
+func SetTierFile(ctx context.Context, o fs.Object, tier string) error {
+ do, ok := o.(fs.SetTierer)
+ if !ok {
+ return errors.New("remote object does not implement SetTier")
+ }
+ err := do.SetTier(tier)
+ if err != nil {
+ fs.Errorf(o, "Failed to do SetTier, %v", err)
+ return err
+ }
+ return nil
+}
+
+// TouchDir touches every file in directory with time t
+func TouchDir(ctx context.Context, f fs.Fs, remote string, t time.Time, recursive bool) error {
+ ci := fs.GetConfig(ctx)
+ g, gCtx := errgroup.WithContext(ctx)
+ g.SetLimit(ci.Transfers)
+ err := walk.ListR(ctx, f, remote, false, ConfigMaxDepth(ctx, recursive), walk.ListObjects, func(entries fs.DirEntries) error {
+ entries.ForObject(func(o fs.Object) {
+ if !SkipDestructive(ctx, o, "touch") {
+ g.Go(func() error {
+ fs.Debugf(f, "Touching %q", o.Remote())
+ err := o.SetModTime(gCtx, t)
+ if err != nil {
+ err = fmt.Errorf("failed to touch: %w", err)
+ err = fs.CountError(gCtx, err)
+ fs.Errorf(o, "%v", err)
+ }
+ return nil
+ })
+ }
+ })
+ return nil
+ })
+ _ = g.Wait()
+ return err
+}
+
+// ListFormat defines files information print format
+type ListFormat struct {
+ separator string
+ dirSlash bool
+ absolute bool
+ output []func(entry *ListJSONItem) string
+ csv *csv.Writer
+ buf bytes.Buffer
+}
+
+// SetSeparator changes separator in struct
+func (l *ListFormat) SetSeparator(separator string) {
+ l.separator = separator
+}
+
+// SetDirSlash defines if slash should be printed
+func (l *ListFormat) SetDirSlash(dirSlash bool) {
+ l.dirSlash = dirSlash
+}
+
+// SetAbsolute prints a leading slash in front of path names
+func (l *ListFormat) SetAbsolute(absolute bool) {
+ l.absolute = absolute
+}
+
+// SetCSV defines if the output should be csv
+//
+// Note that you should call SetSeparator before this if you want a
+// custom separator
+func (l *ListFormat) SetCSV(useCSV bool) {
+ if useCSV {
+ l.csv = csv.NewWriter(&l.buf)
+ if l.separator != "" {
+ l.csv.Comma = []rune(l.separator)[0]
+ }
+ } else {
+ l.csv = nil
+ }
+}
+
+// SetOutput sets functions used to create files information
+func (l *ListFormat) SetOutput(output []func(entry *ListJSONItem) string) {
+ l.output = output
+}
+
+// AddModTime adds file's Mod Time to output
+func (l *ListFormat) AddModTime(timeFormat string) {
+ switch timeFormat {
+ case "":
+ l.AppendOutput(func(entry *ListJSONItem) string {
+ return entry.ModTime.When.Local().Format("2006-01-02 15:04:05")
+ })
+ case "unix":
+ l.AppendOutput(func(entry *ListJSONItem) string {
+ return fmt.Sprint(entry.ModTime.When.Unix())
+ })
+ case "unixnano":
+ l.AppendOutput(func(entry *ListJSONItem) string {
+ return fmt.Sprint(entry.ModTime.When.UnixNano())
+ })
+ default:
+ timeFormat = transform.TimeFormat(timeFormat)
+ l.AppendOutput(func(entry *ListJSONItem) string {
+ return entry.ModTime.When.Local().Format(timeFormat)
+ })
+ }
+}
+
+// AddSize adds file's size to output
+func (l *ListFormat) AddSize() {
+ l.AppendOutput(func(entry *ListJSONItem) string {
+ return strconv.FormatInt(entry.Size, 10)
+ })
+}
+
+// normalisePath makes sure the path has the correct slashes for the current mode
+func (l *ListFormat) normalisePath(entry *ListJSONItem, remote string) string {
+ if l.absolute && !strings.HasPrefix(remote, "/") {
+ remote = "/" + remote
+ }
+ if entry.IsDir && l.dirSlash {
+ remote += "/"
+ }
+ return remote
+}
+
+// AddPath adds path to file to output
+func (l *ListFormat) AddPath() {
+ l.AppendOutput(func(entry *ListJSONItem) string {
+ return l.normalisePath(entry, entry.Path)
+ })
+}
+
+// AddEncrypted adds the encrypted path to file to output
+func (l *ListFormat) AddEncrypted() {
+ l.AppendOutput(func(entry *ListJSONItem) string {
+ return l.normalisePath(entry, entry.Encrypted)
+ })
+}
+
+// AddHash adds the hash of the type given to the output
+func (l *ListFormat) AddHash(ht hash.Type) {
+ hashName := ht.String()
+ l.AppendOutput(func(entry *ListJSONItem) string {
+ if entry.IsDir {
+ return ""
+ }
+ return entry.Hashes[hashName]
+ })
+}
+
+// AddID adds file's ID to the output if known
+func (l *ListFormat) AddID() {
+ l.AppendOutput(func(entry *ListJSONItem) string {
+ return entry.ID
+ })
+}
+
+// AddOrigID adds file's Original ID to the output if known
+func (l *ListFormat) AddOrigID() {
+ l.AppendOutput(func(entry *ListJSONItem) string {
+ return entry.OrigID
+ })
+}
+
+// AddTier adds file's Tier to the output if known
+func (l *ListFormat) AddTier() {
+ l.AppendOutput(func(entry *ListJSONItem) string {
+ return entry.Tier
+ })
+}
+
+// AddMimeType adds file's MimeType to the output if known
+func (l *ListFormat) AddMimeType() {
+ l.AppendOutput(func(entry *ListJSONItem) string {
+ return entry.MimeType
+ })
+}
+
+// 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)
+}
+
+// Format prints information about the DirEntry in the format defined
+func (l *ListFormat) Format(entry *ListJSONItem) (result string) {
+ var out []string
+ for _, fun := range l.output {
+ out = append(out, fun(entry))
+ }
+ if l.csv != nil {
+ l.buf.Reset()
+ _ = l.csv.Write(out) // can't fail writing to bytes.Buffer
+ l.csv.Flush()
+ result = strings.TrimRight(l.buf.String(), "\n")
+ } else {
+ result = strings.Join(out, l.separator)
+ }
+ return result
+}
+
+// FormatForLSFPrecision Returns a time format for the given precision
+func FormatForLSFPrecision(precision time.Duration) string {
+ switch {
+ case precision <= time.Nanosecond:
+ return "2006-01-02 15:04:05.000000000"
+ case precision <= 10*time.Nanosecond:
+ return "2006-01-02 15:04:05.00000000"
+ case precision <= 100*time.Nanosecond:
+ return "2006-01-02 15:04:05.0000000"
+ case precision <= time.Microsecond:
+ return "2006-01-02 15:04:05.000000"
+ case precision <= 10*time.Microsecond:
+ return "2006-01-02 15:04:05.00000"
+ case precision <= 100*time.Microsecond:
+ return "2006-01-02 15:04:05.0000"
+ case precision <= time.Millisecond:
+ return "2006-01-02 15:04:05.000"
+ case precision <= 10*time.Millisecond:
+ return "2006-01-02 15:04:05.00"
+ case precision <= 100*time.Millisecond:
+ return "2006-01-02 15:04:05.0"
+ }
+ return "2006-01-02 15:04:05"
+}
+
+// DirMove renames srcRemote to dstRemote
+//
+// It does this by loading the directory tree into memory (using ListR
+// if available) and doing renames in parallel.
+func DirMove(ctx context.Context, f fs.Fs, srcRemote, dstRemote string) (err error) {
+ ci := fs.GetConfig(ctx)
+
+ if SkipDestructive(ctx, srcRemote, "dirMove") {
+ accounting.Stats(ctx).Renames(1)
+ return nil
+ }
+
+ // Use DirMove if possible
+ if doDirMove := f.Features().DirMove; doDirMove != nil {
+ err = doDirMove(ctx, f, srcRemote, dstRemote)
+ if err == nil {
+ accounting.Stats(ctx).Renames(1)
+ }
+ if err != fs.ErrorCantDirMove && err != fs.ErrorDirExists {
+ return err
+ }
+ fs.Infof(f, "Can't DirMove - falling back to file moves: %v", err)
+ }
+
+ // Load the directory tree into memory
+ tree, err := walk.NewDirTree(ctx, f, srcRemote, true, -1)
+ if err != nil {
+ return fmt.Errorf("RenameDir tree walk: %w", err)
+ }
+
+ // Get the directories in sorted order
+ dirs := tree.Dirs()
+
+ // Make the destination directories - must be done in order not in parallel
+ for _, dir := range dirs {
+ dstPath := dstRemote + dir[len(srcRemote):]
+ err := f.Mkdir(ctx, dstPath)
+ if err != nil {
+ return fmt.Errorf("RenameDir mkdir: %w", err)
+ }
+ }
+
+ // Rename the files in parallel
+ type rename struct {
+ o fs.Object
+ newPath string
+ }
+ renames := make(chan rename, ci.Checkers)
+ g, gCtx := errgroup.WithContext(context.Background())
+ for range ci.Checkers {
+ g.Go(func() error {
+ for job := range renames {
+ dstOverwritten, _ := f.NewObject(gCtx, job.newPath)
+ _, err := Move(gCtx, f, dstOverwritten, job.newPath, job.o)
+ if err != nil {
+ return err
+ }
+ select {
+ case <-gCtx.Done():
+ return gCtx.Err()
+ default:
+ }
+
+ }
+ return nil
+ })
+ }
+ for dir, entries := range tree {
+ dstPath := dstRemote + dir[len(srcRemote):]
+ for _, entry := range entries {
+ if o, ok := entry.(fs.Object); ok {
+ renames <- rename{o, path.Join(dstPath, path.Base(o.Remote()))}
+ }
+ }
+ }
+ close(renames)
+ err = g.Wait()
+ if err != nil {
+ return fmt.Errorf("RenameDir renames: %w", err)
+ }
+
+ // Remove the source directories in reverse order
+ for i := len(dirs) - 1; i >= 0; i-- {
+ err := f.Rmdir(ctx, dirs[i])
+ if err != nil {
+ return fmt.Errorf("RenameDir rmdir: %w", err)
+ }
+ }
+
+ return nil
+}
+
+// DirMoveCaseInsensitive does DirMove in two steps (to temp name, then real name)
+// which is necessary for some case-insensitive backends
+func DirMoveCaseInsensitive(ctx context.Context, f fs.Fs, srcRemote, dstRemote string) (err error) {
+ tmpDstRemote := dstRemote + "-rclone-move-" + random.String(8)
+ err = DirMove(ctx, f, srcRemote, tmpDstRemote)
+ if err != nil {
+ return err
+ }
+ return DirMove(ctx, f, tmpDstRemote, dstRemote)
+}
+
+// FsInfo provides information about a remote
+type FsInfo struct {
+ // Name of the remote (as passed into NewFs)
+ Name string
+
+ // Root of the remote (as passed into NewFs)
+ Root string
+
+ // String returns a description of the FS
+ String string
+
+ // Precision of the ModTimes in this Fs in Nanoseconds
+ Precision time.Duration
+
+ // Returns the supported hash types of the filesystem
+ Hashes []string
+
+ // 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: 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
+}
+
+var (
+ interactiveMu sync.Mutex // protects the following variables
+ skipped = map[string]bool{}
+)
+
+// skipDestructiveChoose asks the user which action to take
+//
+// Call with interactiveMu held
+func skipDestructiveChoose(ctx context.Context, subject any, action string) (skip bool) {
+ // Lock the StdoutMutex - must not call fs.Log anything
+ // otherwise it will deadlock with --interactive --progress
+ StdoutMutex.Lock()
+
+ fmt.Printf("\nrclone: %s \"%v\"?\n", action, subject)
+ i := config.CommandDefault([]string{
+ "yYes, this is OK",
+ "nNo, skip this",
+ fmt.Sprintf("sSkip all %s operations with no more questions", action),
+ fmt.Sprintf("!Do all %s operations with no more questions", action),
+ "qExit rclone now.",
+ }, 0)
+
+ StdoutMutex.Unlock()
+
+ switch i {
+ case 'y':
+ skip = false
+ case 'n':
+ skip = true
+ case 's':
+ skip = true
+ skipped[action] = true
+ fs.Logf(nil, "Skipping all %s operations from now on without asking", action)
+ case '!':
+ skip = false
+ skipped[action] = false
+ fs.Logf(nil, "Doing all %s operations from now on without asking", action)
+ case 'q':
+ fs.Logf(nil, "Quitting rclone now")
+ atexit.Run()
+ os.Exit(0)
+ default:
+ skip = true
+ fs.Errorf(nil, "Bad choice %c", i)
+ }
+ return skip
+}
+
+// SkipDestructive should be called whenever rclone is about to do an destructive operation.
+//
+// It will check the --dry-run flag and it will ask the user if the --interactive flag is set.
+//
+// subject should be the object or directory in use
+//
+// action should be a descriptive word or short phrase
+//
+// Together they should make sense in this sentence: "Rclone is about
+// to action subject".
+func SkipDestructive(ctx context.Context, subject any, action string) (skip bool) {
+ var flag string
+ ci := fs.GetConfig(ctx)
+ switch {
+ case ci.DryRun:
+ flag = "--dry-run"
+ skip = true
+ case ci.Interactive:
+ flag = "--interactive"
+ interactiveMu.Lock()
+ defer interactiveMu.Unlock()
+ var found bool
+ skip, found = skipped[action]
+ if !found {
+ skip = skipDestructiveChoose(ctx, subject, action)
+ }
+ default:
+ return false
+ }
+ if skip {
+ size := int64(-1)
+ if do, ok := subject.(interface{ Size() int64 }); ok {
+ size = do.Size()
+ }
+ if size >= 0 {
+ fs.Logf(subject, "Skipped %s as %s is set (size %v)", fs.LogValue("skipped", action), flag, fs.LogValue("size", fs.SizeSuffix(size)))
+ } else {
+ fs.Logf(subject, "Skipped %s as %s is set", fs.LogValue("skipped", action), flag)
+ }
+ }
+ return skip
+}
+
+// Return the best way of describing the directory for the logs
+func dirName(f fs.Fs, dst fs.Directory, dir string) any {
+ if dst != nil {
+ if dst.Remote() != "" {
+ return dst
+ }
+ // Root is described as the Fs
+ return f
+ }
+ if dir != "" {
+ return dir
+ }
+ // Root is described as the Fs
+ return f
+}
+
+// CopyDirMetadata copies the src directory to dst or f if nil. If dst is nil then it uses
+// dir as the name of the new directory.
+//
+// It returns the destination directory if possible. Note that this may
+// be nil.
+func CopyDirMetadata(ctx context.Context, f fs.Fs, dst fs.Directory, dir string, src fs.Directory) (newDst fs.Directory, err error) {
+ ci := fs.GetConfig(ctx)
+ logName := dirName(f, dst, dir)
+ if SkipDestructive(ctx, logName, "update directory metadata") {
+ return nil, nil
+ }
+
+ // Options for the directory metadata
+ options := []fs.OpenOption{}
+ if ci.MetadataSet != nil {
+ options = append(options, fs.MetadataOption(ci.MetadataSet))
+ }
+
+ // Read metadata from src and add options and use metadata mapper
+ metadata, err := fs.GetMetadataOptions(ctx, f, src, options)
+ if err != nil {
+ return nil, err
+ }
+
+ // Fall back to ModTime if metadata not available
+ if metadata == nil {
+ metadata = fs.Metadata{}
+ }
+ if metadata["mtime"] == "" {
+ metadata["mtime"] = src.ModTime(ctx).Format(time.RFC3339Nano)
+ }
+
+ // Now set the metadata
+ if dst == nil {
+ do := f.Features().MkdirMetadata
+ if do == nil {
+ return nil, fmt.Errorf("internal error: expecting %v to have MkdirMetadata method: %w", f, fs.ErrorNotImplemented)
+ }
+ newDst, err = do(ctx, dir, metadata)
+ } else {
+ do, ok := dst.(fs.SetMetadataer)
+ if !ok {
+ return nil, fmt.Errorf("internal error: expecting directory %s (%T) from %v to have SetMetadata method: %w", logName, dst, f, fs.ErrorNotImplemented)
+ }
+ err = do.SetMetadata(ctx, metadata)
+ newDst = dst
+ }
+ if err != nil {
+ return nil, err
+ }
+ fs.Infof(logName, "Updated directory metadata")
+ return newDst, nil
+}
+
+// SetDirModTime sets the modtime on dst or dir
+//
+// If dst is nil then it uses dir as the name of the directory.
+//
+// It returns the destination directory if possible. Note that this
+// may be nil.
+//
+// It does not create the directory.
+func SetDirModTime(ctx context.Context, f fs.Fs, dst fs.Directory, dir string, modTime time.Time) (newDst fs.Directory, err error) {
+ logName := dirName(f, dst, dir)
+ ci := fs.GetConfig(ctx)
+ if ci.NoUpdateDirModTime {
+ fs.Debugf(logName, "Skipping set directory modification time as --no-update-dir-modtime is set")
+ return nil, nil
+ }
+ if SkipDestructive(ctx, logName, "set directory modification time") {
+ return nil, nil
+ }
+ if dst != nil {
+ dir = dst.Remote()
+ }
+
+ // Try to set the ModTime with the Directory.SetModTime method first as this is the most efficient
+ if dst != nil {
+ if do, ok := dst.(fs.SetModTimer); ok {
+ err := do.SetModTime(ctx, modTime)
+ if errors.Is(err, fs.ErrorNotImplemented) {
+ // Fall through and run the code below if not implemented
+ // This can happen for fs.DirWrapper instances
+ } else if err != nil {
+ return dst, err
+ } else {
+ fs.Infof(logName, "Set directory modification time (using SetModTime)")
+ return dst, nil
+ }
+ }
+ }
+
+ // Next try to set the ModTime with the Fs.DirSetModTime method as this works for non-metadata backends
+ if do := f.Features().DirSetModTime; do != nil {
+ err := do(ctx, dir, modTime)
+ if err != nil {
+ return dst, err
+ }
+ fs.Infof(logName, "Set directory modification time (using DirSetModTime)")
+ return dst, nil
+ }
+
+ // Something should have worked so return an error
+ return nil, fmt.Errorf("no method to set directory modtime found for %v (%T): %w", f, dst, fs.ErrorNotImplemented)
+}
diff --git a/fs/operations/operations_internal_test.go b/fs/operations/operations_internal_test.go
new file mode 100644
index 0000000..658249e
--- /dev/null
+++ b/fs/operations/operations_internal_test.go
@@ -0,0 +1,44 @@
+// Internal tests for operations
+
+package operations
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+
+ _ "github.com/mewsen/rclone-studip-backend-oot/backend/studip"
+ "github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/fs/object"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSizeDiffers(t *testing.T) {
+ ctx := context.Background()
+ ci := fs.GetConfig(ctx)
+ when := time.Now()
+ for _, test := range []struct {
+ ignoreSize bool
+ srcSize int64
+ dstSize int64
+ want bool
+ }{
+ {false, 0, 0, false},
+ {false, 1, 2, true},
+ {false, 1, -1, false},
+ {false, -1, 1, false},
+ {true, 0, 0, false},
+ {true, 1, 2, false},
+ {true, 1, -1, false},
+ {true, -1, 1, false},
+ } {
+ src := object.NewStaticObjectInfo("a", when, test.srcSize, true, nil, nil)
+ dst := object.NewStaticObjectInfo("a", when, test.dstSize, true, nil, nil)
+ oldIgnoreSize := ci.IgnoreSize
+ ci.IgnoreSize = test.ignoreSize
+ got := sizeDiffers(ctx, src, dst)
+ ci.IgnoreSize = oldIgnoreSize
+ assert.Equal(t, test.want, got, fmt.Sprintf("ignoreSize=%v, srcSize=%v, dstSize=%v", test.ignoreSize, test.srcSize, test.dstSize))
+ }
+}
diff --git a/fs/operations/operations_test.go b/fs/operations/operations_test.go
new file mode 100644
index 0000000..3fb5992
--- /dev/null
+++ b/fs/operations/operations_test.go
@@ -0,0 +1,1968 @@
+// Integration tests - test rclone by doing real transactions to a
+// storage provider to and from the local disk.
+//
+// By default it will use a local fs, however you can provide a
+// -remote option to use a different remote. The test_all.go script
+// is a wrapper to call this for all the test remotes.
+//
+// FIXME not safe for concurrent running of tests until fs.Config is
+// no longer a global
+//
+// NB When writing tests
+//
+// Make sure every series of writes to the remote has a
+// fstest.CheckItems() before use. This make sure the directory
+// listing is now consistent and stops cascading errors.
+//
+// Call accounting.GlobalStats().ResetCounters() before every fs.Sync() as it
+// uses the error count internally.
+
+package operations_test
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+
+ _ "github.com/mewsen/rclone-studip-backend-oot/backend/studip"
+ _ "github.com/rclone/rclone/backend/all" // import all backends
+ "github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/fs/accounting"
+ "github.com/rclone/rclone/fs/filter"
+ "github.com/rclone/rclone/fs/fshttp"
+ "github.com/rclone/rclone/fs/hash"
+ "github.com/rclone/rclone/fs/operations"
+ "github.com/rclone/rclone/fstest"
+ "github.com/rclone/rclone/fstest/fstests"
+ "github.com/rclone/rclone/lib/pacer"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/text/cases"
+ "golang.org/x/text/language"
+)
+
+// Some times used in the tests
+var (
+ t1 = fstest.Time("2001-02-03T04:05:06.499999999Z")
+ t2 = fstest.Time("2011-12-25T12:59:59.123456789Z")
+ t3 = fstest.Time("2011-12-30T12:59:59.000000000Z")
+)
+
+// TestMain drives the tests
+func TestMain(m *testing.M) {
+ fstest.TestMain(m)
+}
+
+func TestMkdir(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+
+ err := operations.Mkdir(ctx, r.Fremote, "")
+ require.NoError(t, err)
+ fstest.CheckListing(t, r.Fremote, []fstest.Item{})
+
+ err = operations.Mkdir(ctx, r.Fremote, "")
+ require.NoError(t, err)
+}
+
+func TestLsd(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ file1 := r.WriteObject(ctx, "sub dir/hello world", "hello world", t1)
+
+ r.CheckRemoteItems(t, file1)
+
+ var buf bytes.Buffer
+ err := operations.ListDir(ctx, r.Fremote, &buf)
+ require.NoError(t, err)
+ res := buf.String()
+ assert.Contains(t, res, "sub dir\n")
+}
+
+func TestLs(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ file1 := r.WriteBoth(ctx, "potato2", "------------------------------------------------------------", t1)
+ file2 := r.WriteBoth(ctx, "empty space", "-", t2)
+
+ r.CheckRemoteItems(t, file1, file2)
+
+ var buf bytes.Buffer
+ err := operations.List(ctx, r.Fremote, &buf)
+ require.NoError(t, err)
+ res := buf.String()
+ assert.Contains(t, res, " 1 empty space\n")
+ assert.Contains(t, res, " 60 potato2\n")
+}
+
+func TestLsWithFilesFrom(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+ file1 := r.WriteBoth(ctx, "potato2", "------------------------------------------------------------", t1)
+ file2 := r.WriteBoth(ctx, "empty space", "-", t2)
+
+ r.CheckRemoteItems(t, file1, file2)
+
+ // Set the --files-from equivalent
+ f, err := filter.NewFilter(nil)
+ require.NoError(t, err)
+ require.NoError(t, f.AddFile("potato2"))
+ require.NoError(t, f.AddFile("notfound"))
+
+ // Change the active filter
+ ctx = filter.ReplaceConfig(ctx, f)
+
+ var buf bytes.Buffer
+ err = operations.List(ctx, r.Fremote, &buf)
+ require.NoError(t, err)
+ assert.Equal(t, " 60 potato2\n", buf.String())
+
+ // Now try with --no-traverse
+ ci.NoTraverse = true
+
+ buf.Reset()
+ err = operations.List(ctx, r.Fremote, &buf)
+ require.NoError(t, err)
+ assert.Equal(t, " 60 potato2\n", buf.String())
+}
+
+func TestLsLong(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ file1 := r.WriteBoth(ctx, "potato2", "------------------------------------------------------------", t1)
+ file2 := r.WriteBoth(ctx, "empty space", "-", t2)
+
+ r.CheckRemoteItems(t, file1, file2)
+
+ var buf bytes.Buffer
+ err := operations.ListLong(ctx, r.Fremote, &buf)
+ require.NoError(t, err)
+ res := buf.String()
+ lines := strings.Split(strings.Trim(res, "\n"), "\n")
+ assert.Equal(t, 2, len(lines))
+
+ timeFormat := "2006-01-02 15:04:05.000000000"
+ precision := r.Fremote.Precision()
+ location := time.Now().Location()
+ checkTime := func(m, filename string, expected time.Time) {
+ modTime, err := time.ParseInLocation(timeFormat, m, location) // parse as localtime
+ if err != nil {
+ t.Errorf("Error parsing %q: %v", m, err)
+ } else {
+ fstest.AssertTimeEqualWithPrecision(t, filename, expected, modTime, precision)
+ }
+ }
+
+ m1 := regexp.MustCompile(`(?m)^ 1 (\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\.\d{9}) empty space$`)
+ if ms := m1.FindStringSubmatch(res); ms == nil {
+ t.Errorf("empty space missing: %q", res)
+ } else {
+ checkTime(ms[1], "empty space", t2.Local())
+ }
+
+ m2 := regexp.MustCompile(`(?m)^ 60 (\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\.\d{9}) potato2$`)
+ if ms := m2.FindStringSubmatch(res); ms == nil {
+ t.Errorf("potato2 missing: %q", res)
+ } else {
+ checkTime(ms[1], "potato2", t1.Local())
+ }
+}
+
+func TestHashSums(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ file1 := r.WriteBoth(ctx, "potato2", "------------------------------------------------------------", t1)
+ file2 := r.WriteBoth(ctx, "empty space", "-", t2)
+
+ r.CheckRemoteItems(t, file1, file2)
+
+ hashes := r.Fremote.Hashes()
+
+ var quickXorHash hash.Type
+ err := quickXorHash.Set("QuickXorHash")
+ require.NoError(t, err)
+
+ for _, test := range []struct {
+ name string
+ download bool
+ base64 bool
+ ht hash.Type
+ want []string
+ }{
+ {
+ ht: hash.MD5,
+ want: []string{
+ "336d5ebc5436534e61d16e63ddfca327 empty space\n",
+ "d6548b156ea68a4e003e786df99eee76 potato2\n",
+ },
+ },
+ {
+ ht: hash.MD5,
+ download: true,
+ want: []string{
+ "336d5ebc5436534e61d16e63ddfca327 empty space\n",
+ "d6548b156ea68a4e003e786df99eee76 potato2\n",
+ },
+ },
+ {
+ ht: hash.SHA1,
+ want: []string{
+ "3bc15c8aae3e4124dd409035f32ea2fd6835efc9 empty space\n",
+ "9dc7f7d3279715991a22853f5981df582b7f9f6d potato2\n",
+ },
+ },
+ {
+ ht: hash.SHA1,
+ download: true,
+ want: []string{
+ "3bc15c8aae3e4124dd409035f32ea2fd6835efc9 empty space\n",
+ "9dc7f7d3279715991a22853f5981df582b7f9f6d potato2\n",
+ },
+ },
+ {
+ ht: quickXorHash,
+ want: []string{
+ "2d00000000000000000000000100000000000000 empty space\n",
+ "4001dad296b6b4a52d6d694b67dad296b6b4a52d potato2\n",
+ },
+ },
+ {
+ ht: quickXorHash,
+ download: true,
+ want: []string{
+ "2d00000000000000000000000100000000000000 empty space\n",
+ "4001dad296b6b4a52d6d694b67dad296b6b4a52d potato2\n",
+ },
+ },
+ {
+ ht: quickXorHash,
+ base64: true,
+ want: []string{
+ "LQAAAAAAAAAAAAAAAQAAAAAAAAA= empty space\n",
+ "QAHa0pa2tKUtbWlLZ9rSlra0pS0= potato2\n",
+ },
+ },
+ {
+ ht: quickXorHash,
+ base64: true,
+ download: true,
+ want: []string{
+ "LQAAAAAAAAAAAAAAAQAAAAAAAAA= empty space\n",
+ "QAHa0pa2tKUtbWlLZ9rSlra0pS0= potato2\n",
+ },
+ },
+ } {
+ if !hashes.Contains(test.ht) {
+ continue
+ }
+ name := cases.Title(language.Und, cases.NoLower).String(test.ht.String())
+ if test.download {
+ name += "Download"
+ }
+ if test.base64 {
+ name += "Base64"
+ }
+ t.Run(name, func(t *testing.T) {
+ var buf bytes.Buffer
+ err := operations.HashLister(ctx, test.ht, test.base64, test.download, r.Fremote, &buf)
+ require.NoError(t, err)
+ res := buf.String()
+ for _, line := range test.want {
+ assert.Contains(t, res, line)
+ }
+ })
+ }
+}
+
+func TestHashSumsWithErrors(t *testing.T) {
+ ctx := context.Background()
+ memFs, err := fs.NewFs(ctx, ":memory:")
+ require.NoError(t, err)
+
+ // Make a test file
+ content := "-"
+ item1 := fstest.NewItem("file1", content, t1)
+ _ = fstests.PutTestContents(ctx, t, memFs, &item1, content, true)
+
+ // MemoryFS supports MD5
+ buf := &bytes.Buffer{}
+ err = operations.HashLister(ctx, hash.MD5, false, false, memFs, buf)
+ require.NoError(t, err)
+ assert.Contains(t, buf.String(), "336d5ebc5436534e61d16e63ddfca327 file1\n")
+
+ // MemoryFS can't do SHA1, but UNSUPPORTED must not appear in the output
+ buf.Reset()
+ err = operations.HashLister(ctx, hash.SHA1, false, false, memFs, buf)
+ require.NoError(t, err)
+ assert.NotContains(t, buf.String(), " UNSUPPORTED ")
+
+ // ERROR must not appear in the output either
+ assert.NotContains(t, buf.String(), " ERROR ")
+ // TODO mock an unreadable file
+}
+
+func TestHashStream(t *testing.T) {
+ reader := strings.NewReader("")
+ in := io.NopCloser(reader)
+ out := &bytes.Buffer{}
+ for _, test := range []struct {
+ input string
+ ht hash.Type
+ wantHex string
+ wantBase64 string
+ }{
+ {
+ input: "",
+ ht: hash.MD5,
+ wantHex: "d41d8cd98f00b204e9800998ecf8427e -\n",
+ wantBase64: "1B2M2Y8AsgTpgAmY7PhCfg== -\n",
+ },
+ {
+ input: "",
+ ht: hash.SHA1,
+ wantHex: "da39a3ee5e6b4b0d3255bfef95601890afd80709 -\n",
+ wantBase64: "2jmj7l5rSw0yVb_vlWAYkK_YBwk= -\n",
+ },
+ {
+ input: "Hello world!",
+ ht: hash.MD5,
+ wantHex: "86fb269d190d2c85f6e0468ceca42a20 -\n",
+ wantBase64: "hvsmnRkNLIX24EaM7KQqIA== -\n",
+ },
+ {
+ input: "Hello world!",
+ ht: hash.SHA1,
+ wantHex: "d3486ae9136e7856bc42212385ea797094475802 -\n",
+ wantBase64: "00hq6RNueFa8QiEjhep5cJRHWAI= -\n",
+ },
+ } {
+ reader.Reset(test.input)
+ require.NoError(t, operations.HashSumStream(test.ht, false, in, out))
+ assert.Equal(t, test.wantHex, out.String())
+ _, _ = reader.Seek(0, io.SeekStart)
+ out.Reset()
+ require.NoError(t, operations.HashSumStream(test.ht, true, in, out))
+ assert.Equal(t, test.wantBase64, out.String())
+ out.Reset()
+ }
+}
+
+func TestSuffixName(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ for _, test := range []struct {
+ remote string
+ suffix string
+ keepExt bool
+ want string
+ }{
+ {"test.txt", "", false, "test.txt"},
+ {"test.txt", "", true, "test.txt"},
+ {"test.txt", "-suffix", false, "test.txt-suffix"},
+ {"test.txt", "-suffix", true, "test-suffix.txt"},
+ {"test.txt.csv", "-suffix", false, "test.txt.csv-suffix"},
+ {"test.txt.csv", "-suffix", true, "test-suffix.txt.csv"},
+ {"test", "-suffix", false, "test-suffix"},
+ {"test", "-suffix", true, "test-suffix"},
+ {"test.html", "-suffix", true, "test-suffix.html"},
+ {"test.html.txt", "-suffix", true, "test-suffix.html.txt"},
+ {"test.csv.html.txt", "-suffix", true, "test-suffix.csv.html.txt"},
+ {"test.badext.csv.html.txt", "-suffix", true, "test.badext-suffix.csv.html.txt"},
+ {"test.badext", "-suffix", true, "test-suffix.badext"},
+ } {
+ ci.Suffix = test.suffix
+ ci.SuffixKeepExtension = test.keepExt
+ got := operations.SuffixName(ctx, test.remote)
+ assert.Equal(t, test.want, got, fmt.Sprintf("%+v", test))
+ }
+}
+
+func TestCount(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+ file1 := r.WriteBoth(ctx, "potato2", "------------------------------------------------------------", t1)
+ file2 := r.WriteBoth(ctx, "empty space", "-", t2)
+ file3 := r.WriteBoth(ctx, "sub dir/potato3", "hello", t2)
+
+ r.CheckRemoteItems(t, file1, file2, file3)
+
+ // Check the MaxDepth too
+ ci.MaxDepth = 1
+
+ objects, size, sizeless, err := operations.Count(ctx, r.Fremote)
+ require.NoError(t, err)
+ assert.Equal(t, int64(2), objects)
+ assert.Equal(t, int64(61), size)
+ assert.Equal(t, int64(0), sizeless)
+}
+
+func TestDelete(t *testing.T) {
+ ctx := context.Background()
+ fi, err := filter.NewFilter(nil)
+ require.NoError(t, err)
+ fi.Opt.MaxSize = 60
+ ctx = filter.ReplaceConfig(ctx, fi)
+ r := fstest.NewRun(t)
+ file1 := r.WriteObject(ctx, "small", "1234567890", t2) // 10 bytes
+ file2 := r.WriteObject(ctx, "medium", "------------------------------------------------------------", t1) // 60 bytes
+ file3 := r.WriteObject(ctx, "large", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", t1) // 100 bytes
+ r.CheckRemoteItems(t, file1, file2, file3)
+
+ err = operations.Delete(ctx, r.Fremote)
+ require.NoError(t, err)
+ r.CheckRemoteItems(t, file3)
+}
+
+func isChunker(f fs.Fs) bool {
+ return strings.HasPrefix(f.Name(), "TestChunker")
+}
+
+func skipIfChunker(t *testing.T, f fs.Fs) {
+ if isChunker(f) {
+ t.Skip("Skipping test on chunker backend")
+ }
+}
+
+func TestMaxDelete(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+ accounting.GlobalStats().ResetCounters()
+ ci.MaxDelete = 2
+ defer r.Finalise()
+ skipIfChunker(t, r.Fremote) // chunker does copy/delete on s3
+ file1 := r.WriteObject(ctx, "small", "1234567890", t2) // 10 bytes
+ file2 := r.WriteObject(ctx, "medium", "------------------------------------------------------------", t1) // 60 bytes
+ file3 := r.WriteObject(ctx, "large", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", t1) // 100 bytes
+ r.CheckRemoteItems(t, file1, file2, file3)
+ err := operations.Delete(ctx, r.Fremote)
+
+ require.Error(t, err)
+ objects, _, _, err := operations.Count(ctx, r.Fremote)
+ require.NoError(t, err)
+ assert.Equal(t, int64(1), objects)
+}
+
+// TestMaxDeleteSizeLargeFile one of the files is larger than allowed
+func TestMaxDeleteSizeLargeFile(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+ accounting.GlobalStats().ResetCounters()
+ ci.MaxDeleteSize = 70
+ defer r.Finalise()
+ skipIfChunker(t, r.Fremote) // chunker does copy/delete on s3
+ file1 := r.WriteObject(ctx, "small", "1234567890", t2) // 10 bytes
+ file2 := r.WriteObject(ctx, "medium", "------------------------------------------------------------", t1) // 60 bytes
+ file3 := r.WriteObject(ctx, "large", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", t1) // 100 bytes
+ r.CheckRemoteItems(t, file1, file2, file3)
+
+ err := operations.Delete(ctx, r.Fremote)
+ require.Error(t, err)
+ r.CheckRemoteItems(t, file3)
+}
+
+func TestMaxDeleteSize(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+ accounting.GlobalStats().ResetCounters()
+ ci.MaxDeleteSize = 160
+ defer r.Finalise()
+ skipIfChunker(t, r.Fremote) // chunker does copy/delete on s3
+ file1 := r.WriteObject(ctx, "small", "1234567890", t2) // 10 bytes
+ file2 := r.WriteObject(ctx, "medium", "------------------------------------------------------------", t1) // 60 bytes
+ file3 := r.WriteObject(ctx, "large", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", t1) // 100 bytes
+ r.CheckRemoteItems(t, file1, file2, file3)
+
+ err := operations.Delete(ctx, r.Fremote)
+ require.Error(t, err)
+ objects, _, _, err := operations.Count(ctx, r.Fremote)
+ require.NoError(t, err)
+ assert.Equal(t, int64(1), objects) // 10 or 100 bytes
+}
+
+func TestReadFile(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ defer r.Finalise()
+
+ contents := "A file to read the contents."
+ file := r.WriteObject(ctx, "ReadFile", contents, t1)
+ r.CheckRemoteItems(t, file)
+
+ o, err := r.Fremote.NewObject(ctx, file.Path)
+ require.NoError(t, err)
+
+ buf, err := operations.ReadFile(ctx, o)
+ require.NoError(t, err)
+ assert.Equal(t, contents, string(buf))
+}
+
+func TestRetry(t *testing.T) {
+ ctx := context.Background()
+
+ var i int
+ var err error
+ fn := func() error {
+ i--
+ if i <= 0 {
+ return nil
+ }
+ return err
+ }
+
+ i, err = 3, fmt.Errorf("Wrapped EOF is retriable: %w", io.EOF)
+ assert.Equal(t, nil, operations.Retry(ctx, nil, 5, fn))
+ assert.Equal(t, 0, i)
+
+ i, err = 10, pacer.RetryAfterError(errors.New("BANG"), 10*time.Millisecond)
+ assert.Equal(t, err, operations.Retry(ctx, nil, 5, fn))
+ assert.Equal(t, 5, i)
+
+ i, err = 10, fs.ErrorObjectNotFound
+ assert.Equal(t, fs.ErrorObjectNotFound, operations.Retry(ctx, nil, 5, fn))
+ assert.Equal(t, 9, i)
+
+}
+
+func TestCat(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ file1 := r.WriteBoth(ctx, "file1", "ABCDEFGHIJ", t1)
+ file2 := r.WriteBoth(ctx, "file2", "012345678", t2)
+
+ r.CheckRemoteItems(t, file1, file2)
+
+ for _, test := range []struct {
+ offset int64
+ count int64
+ separator string
+ a string
+ b string
+ }{
+ {0, -1, "", "ABCDEFGHIJ", "012345678"},
+ {0, 5, "", "ABCDE", "01234"},
+ {-3, -1, "", "HIJ", "678"},
+ {1, 3, "", "BCD", "123"},
+ {0, -1, "\n", "ABCDEFGHIJ", "012345678"},
+ } {
+ var buf bytes.Buffer
+ err := operations.Cat(ctx, r.Fremote, &buf, test.offset, test.count, []byte(test.separator))
+ require.NoError(t, err)
+ res := buf.String()
+
+ if res != test.a+test.separator+test.b+test.separator && res != test.b+test.separator+test.a+test.separator {
+ t.Errorf("Incorrect output from Cat(%d,%d,%s): %q", test.offset, test.count, test.separator, res)
+ }
+ }
+}
+
+func TestPurge(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRunIndividual(t) // make new container (azureblob has delayed mkdir after rmdir)
+ r.Mkdir(ctx, r.Fremote)
+
+ // Make some files and dirs
+ r.ForceMkdir(ctx, r.Fremote)
+ file1 := r.WriteObject(ctx, "A1/B1/C1/one", "aaa", t1)
+ //..and dirs we expect to delete
+ require.NoError(t, operations.Mkdir(ctx, r.Fremote, "A2"))
+ require.NoError(t, operations.Mkdir(ctx, r.Fremote, "A1/B2"))
+ require.NoError(t, operations.Mkdir(ctx, r.Fremote, "A1/B2/C2"))
+ require.NoError(t, operations.Mkdir(ctx, r.Fremote, "A1/B1/C3"))
+ require.NoError(t, operations.Mkdir(ctx, r.Fremote, "A3"))
+ require.NoError(t, operations.Mkdir(ctx, r.Fremote, "A3/B3"))
+ require.NoError(t, operations.Mkdir(ctx, r.Fremote, "A3/B3/C4"))
+ //..and one more file at the end
+ file2 := r.WriteObject(ctx, "A1/two", "bbb", t2)
+
+ fstest.CheckListingWithPrecision(
+ t,
+ r.Fremote,
+ []fstest.Item{
+ file1, file2,
+ },
+ []string{
+ "A1",
+ "A1/B1",
+ "A1/B1/C1",
+ "A2",
+ "A1/B2",
+ "A1/B2/C2",
+ "A1/B1/C3",
+ "A3",
+ "A3/B3",
+ "A3/B3/C4",
+ },
+ fs.GetModifyWindow(ctx, r.Fremote),
+ )
+
+ require.NoError(t, operations.Purge(ctx, r.Fremote, "A1/B1"))
+
+ fstest.CheckListingWithPrecision(
+ t,
+ r.Fremote,
+ []fstest.Item{
+ file2,
+ },
+ []string{
+ "A1",
+ "A2",
+ "A1/B2",
+ "A1/B2/C2",
+ "A3",
+ "A3/B3",
+ "A3/B3/C4",
+ },
+ fs.GetModifyWindow(ctx, r.Fremote),
+ )
+
+ require.NoError(t, operations.Purge(ctx, r.Fremote, ""))
+
+ fstest.CheckListingWithPrecision(
+ t,
+ r.Fremote,
+ []fstest.Item{},
+ []string{},
+ fs.GetModifyWindow(ctx, r.Fremote),
+ )
+
+}
+
+func TestRmdirsNoLeaveRoot(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ r.Mkdir(ctx, r.Fremote)
+
+ // Make some files and dirs we expect to keep
+ r.ForceMkdir(ctx, r.Fremote)
+ file1 := r.WriteObject(ctx, "A1/B1/C1/one", "aaa", t1)
+ //..and dirs we expect to delete
+ require.NoError(t, operations.Mkdir(ctx, r.Fremote, "A2"))
+ require.NoError(t, operations.Mkdir(ctx, r.Fremote, "A1/B2"))
+ require.NoError(t, operations.Mkdir(ctx, r.Fremote, "A1/B2/C2"))
+ require.NoError(t, operations.Mkdir(ctx, r.Fremote, "A1/B1/C3"))
+ require.NoError(t, operations.Mkdir(ctx, r.Fremote, "A3"))
+ require.NoError(t, operations.Mkdir(ctx, r.Fremote, "A3/B3"))
+ require.NoError(t, operations.Mkdir(ctx, r.Fremote, "A3/B3/C4"))
+ //..and one more file at the end
+ file2 := r.WriteObject(ctx, "A1/two", "bbb", t2)
+
+ fstest.CheckListingWithPrecision(
+ t,
+ r.Fremote,
+ []fstest.Item{
+ file1, file2,
+ },
+ []string{
+ "A1",
+ "A1/B1",
+ "A1/B1/C1",
+ "A2",
+ "A1/B2",
+ "A1/B2/C2",
+ "A1/B1/C3",
+ "A3",
+ "A3/B3",
+ "A3/B3/C4",
+ },
+ fs.GetModifyWindow(ctx, r.Fremote),
+ )
+
+ require.NoError(t, operations.Rmdirs(ctx, r.Fremote, "A3/B3/C4", false))
+
+ fstest.CheckListingWithPrecision(
+ t,
+ r.Fremote,
+ []fstest.Item{
+ file1, file2,
+ },
+ []string{
+ "A1",
+ "A1/B1",
+ "A1/B1/C1",
+ "A2",
+ "A1/B2",
+ "A1/B2/C2",
+ "A1/B1/C3",
+ "A3",
+ "A3/B3",
+ },
+ fs.GetModifyWindow(ctx, r.Fremote),
+ )
+
+ require.NoError(t, operations.Rmdirs(ctx, r.Fremote, "", false))
+
+ fstest.CheckListingWithPrecision(
+ t,
+ r.Fremote,
+ []fstest.Item{
+ file1, file2,
+ },
+ []string{
+ "A1",
+ "A1/B1",
+ "A1/B1/C1",
+ },
+ fs.GetModifyWindow(ctx, r.Fremote),
+ )
+
+ // Delete the files so we can remove everything including the root
+ for _, file := range []fstest.Item{file1, file2} {
+ o, err := r.Fremote.NewObject(ctx, file.Path)
+ require.NoError(t, err)
+ require.NoError(t, o.Remove(ctx))
+ }
+
+ require.NoError(t, operations.Rmdirs(ctx, r.Fremote, "", false))
+
+ fstest.CheckListingWithPrecision(
+ t,
+ r.Fremote,
+ []fstest.Item{},
+ []string{},
+ fs.GetModifyWindow(ctx, r.Fremote),
+ )
+}
+
+func TestRmdirsLeaveRoot(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ r.Mkdir(ctx, r.Fremote)
+
+ r.ForceMkdir(ctx, r.Fremote)
+
+ require.NoError(t, operations.Mkdir(ctx, r.Fremote, "A1"))
+ require.NoError(t, operations.Mkdir(ctx, r.Fremote, "A1/B1"))
+ require.NoError(t, operations.Mkdir(ctx, r.Fremote, "A1/B1/C1"))
+
+ fstest.CheckListingWithPrecision(
+ t,
+ r.Fremote,
+ []fstest.Item{},
+ []string{
+ "A1",
+ "A1/B1",
+ "A1/B1/C1",
+ },
+ fs.GetModifyWindow(ctx, r.Fremote),
+ )
+
+ require.NoError(t, operations.Rmdirs(ctx, r.Fremote, "A1", true))
+
+ fstest.CheckListingWithPrecision(
+ t,
+ r.Fremote,
+ []fstest.Item{},
+ []string{
+ "A1",
+ },
+ fs.GetModifyWindow(ctx, r.Fremote),
+ )
+}
+
+func TestRmdirsWithFilter(t *testing.T) {
+ ctx := context.Background()
+ ctx, fi := filter.AddConfig(ctx)
+ require.NoError(t, fi.AddRule("+ /A1/B1/**"))
+ require.NoError(t, fi.AddRule("- *"))
+ r := fstest.NewRun(t)
+ r.Mkdir(ctx, r.Fremote)
+
+ r.ForceMkdir(ctx, r.Fremote)
+
+ require.NoError(t, operations.Mkdir(ctx, r.Fremote, "A1"))
+ require.NoError(t, operations.Mkdir(ctx, r.Fremote, "A1/B1"))
+ require.NoError(t, operations.Mkdir(ctx, r.Fremote, "A1/B1/C1"))
+
+ fstest.CheckListingWithPrecision(
+ t,
+ r.Fremote,
+ []fstest.Item{},
+ []string{
+ "A1",
+ "A1/B1",
+ "A1/B1/C1",
+ },
+ fs.GetModifyWindow(ctx, r.Fremote),
+ )
+
+ require.NoError(t, operations.Rmdirs(ctx, r.Fremote, "", false))
+
+ fstest.CheckListingWithPrecision(
+ t,
+ r.Fremote,
+ []fstest.Item{},
+ []string{
+ "A1",
+ },
+ fs.GetModifyWindow(ctx, r.Fremote),
+ )
+}
+
+func TestCopyURL(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+
+ contents := "file contents\n"
+ file1 := r.WriteFile("file1", contents, t1)
+ file2 := r.WriteFile("file2", contents, t1)
+ r.Mkdir(ctx, r.Fremote)
+ r.CheckRemoteItems(t)
+
+ // check when reading from regular HTTP server
+ status := 0
+ nameHeader := false
+ headerFilename := "headerfilename.txt"
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if status != 0 {
+ http.Error(w, "an error occurred", status)
+ }
+ if nameHeader {
+ w.Header().Set("Content-Disposition", `attachment; filename="folder\`+headerFilename+`"`)
+ }
+ _, err := w.Write([]byte(contents))
+ assert.NoError(t, err)
+ })
+ ts := httptest.NewServer(handler)
+ defer ts.Close()
+
+ o, err := operations.CopyURL(ctx, r.Fremote, "file1", ts.URL, false, false, false)
+ require.NoError(t, err)
+ assert.Equal(t, int64(len(contents)), o.Size())
+
+ fstest.CheckListingWithPrecision(t, r.Fremote, []fstest.Item{file1}, nil, fs.ModTimeNotSupported)
+
+ // Check file clobbering
+ _, err = operations.CopyURL(ctx, r.Fremote, "file1", ts.URL, false, false, true)
+ require.Error(t, err)
+
+ // Check auto file naming
+ status = 0
+ urlFileName := "filename.txt"
+ o, err = operations.CopyURL(ctx, r.Fremote, "", ts.URL+"/"+urlFileName, true, false, false)
+ require.NoError(t, err)
+ assert.Equal(t, int64(len(contents)), o.Size())
+ assert.Equal(t, urlFileName, o.Remote())
+
+ // Check header file naming
+ nameHeader = true
+ o, err = operations.CopyURL(ctx, r.Fremote, "", ts.URL, true, true, false)
+ require.NoError(t, err)
+ assert.Equal(t, int64(len(contents)), o.Size())
+ assert.Equal(t, headerFilename, o.Remote())
+
+ // Check auto file naming when url without file name
+ _, err = operations.CopyURL(ctx, r.Fremote, "file1", ts.URL, true, false, false)
+ require.Error(t, err)
+
+ // Check header file naming without header set
+ nameHeader = false
+ _, err = operations.CopyURL(ctx, r.Fremote, "file1", ts.URL, true, true, false)
+ require.Error(t, err)
+
+ // Check an error is returned for a 404
+ status = http.StatusNotFound
+ o, err = operations.CopyURL(ctx, r.Fremote, "file1", ts.URL, false, false, false)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "Not Found")
+ assert.Nil(t, o)
+ status = 0
+
+ // check when reading from unverified HTTPS server
+ ci.InsecureSkipVerify = true
+ fshttp.ResetTransport()
+ defer fshttp.ResetTransport()
+ tss := httptest.NewTLSServer(handler)
+ defer tss.Close()
+
+ o, err = operations.CopyURL(ctx, r.Fremote, "file2", tss.URL, false, false, false)
+ require.NoError(t, err)
+ assert.Equal(t, int64(len(contents)), o.Size())
+ fstest.CheckListingWithPrecision(t, r.Fremote, []fstest.Item{file1, file2, fstest.NewItem(urlFileName, contents, t1), fstest.NewItem(headerFilename, contents, t1)}, nil, fs.ModTimeNotSupported)
+}
+
+func TestCopyURLToWriter(t *testing.T) {
+ ctx := context.Background()
+ contents := "file contents\n"
+
+ // check when reading from regular HTTP server
+ status := 0
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if status != 0 {
+ http.Error(w, "an error occurred", status)
+ return
+ }
+ _, err := w.Write([]byte(contents))
+ assert.NoError(t, err)
+ })
+ ts := httptest.NewServer(handler)
+ defer ts.Close()
+
+ // test normal fetch
+ var buf bytes.Buffer
+ err := operations.CopyURLToWriter(ctx, ts.URL, &buf)
+ require.NoError(t, err)
+ assert.Equal(t, contents, buf.String())
+
+ // test fetch with error
+ status = http.StatusNotFound
+ buf.Reset()
+ err = operations.CopyURLToWriter(ctx, ts.URL, &buf)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "Not Found")
+ assert.Equal(t, 0, len(buf.String()))
+}
+
+func TestMoveFile(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+
+ file1 := r.WriteFile("file1", "file1 contents", t1)
+ r.CheckLocalItems(t, file1)
+
+ file2 := file1
+ file2.Path = "sub/file2"
+
+ err := operations.MoveFile(ctx, r.Fremote, r.Flocal, file2.Path, file1.Path)
+ require.NoError(t, err)
+ r.CheckLocalItems(t)
+ r.CheckRemoteItems(t, file2)
+
+ r.WriteFile("file1", "file1 contents", t1)
+ r.CheckLocalItems(t, file1)
+
+ err = operations.MoveFile(ctx, r.Fremote, r.Flocal, file2.Path, file1.Path)
+ require.NoError(t, err)
+ r.CheckLocalItems(t)
+ r.CheckRemoteItems(t, file2)
+
+ err = operations.MoveFile(ctx, r.Fremote, r.Fremote, file2.Path, file2.Path)
+ require.NoError(t, err)
+ r.CheckLocalItems(t)
+ r.CheckRemoteItems(t, file2)
+}
+
+func TestMoveFileWithIgnoreExisting(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+ file1 := r.WriteFile("file1", "file1 contents", t1)
+ r.CheckLocalItems(t, file1)
+
+ ci.IgnoreExisting = true
+
+ err := operations.MoveFile(ctx, r.Fremote, r.Flocal, file1.Path, file1.Path)
+ require.NoError(t, err)
+ r.CheckLocalItems(t)
+ r.CheckRemoteItems(t, file1)
+
+ // Recreate file with updated content
+ file1b := r.WriteFile("file1", "file1 modified", t2)
+ r.CheckLocalItems(t, file1b)
+
+ // Ensure modified file did not transfer and was not deleted
+ err = operations.MoveFile(ctx, r.Fremote, r.Flocal, file1.Path, file1b.Path)
+ require.NoError(t, err)
+ r.CheckLocalItems(t, file1b)
+ r.CheckRemoteItems(t, file1)
+}
+
+func TestCaseInsensitiveMoveFile(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ if !r.Fremote.Features().CaseInsensitive {
+ return
+ }
+
+ file1 := r.WriteFile("file1", "file1 contents", t1)
+ r.CheckLocalItems(t, file1)
+
+ file2 := file1
+ file2.Path = "sub/file2"
+
+ err := operations.MoveFile(ctx, r.Fremote, r.Flocal, file2.Path, file1.Path)
+ require.NoError(t, err)
+ r.CheckLocalItems(t)
+ r.CheckRemoteItems(t, file2)
+
+ r.WriteFile("file1", "file1 contents", t1)
+ r.CheckLocalItems(t, file1)
+
+ err = operations.MoveFile(ctx, r.Fremote, r.Flocal, file2.Path, file1.Path)
+ require.NoError(t, err)
+ r.CheckLocalItems(t)
+ r.CheckRemoteItems(t, file2)
+
+ file2Capitalized := file2
+ file2Capitalized.Path = "sub/File2"
+
+ err = operations.MoveFile(ctx, r.Fremote, r.Fremote, file2Capitalized.Path, file2.Path)
+ require.NoError(t, err)
+ r.CheckLocalItems(t)
+ r.CheckRemoteItems(t, file2Capitalized)
+}
+
+func TestCaseInsensitiveMoveFileDryRun(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+ if !r.Fremote.Features().CaseInsensitive {
+ return
+ }
+
+ file1 := r.WriteObject(ctx, "hello", "world", t1)
+ r.CheckRemoteItems(t, file1)
+
+ ci.DryRun = true
+ err := operations.MoveFile(ctx, r.Fremote, r.Fremote, "HELLO", file1.Path)
+ require.NoError(t, err)
+ r.CheckRemoteItems(t, file1)
+}
+
+func TestMoveFileBackupDir(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+ if !operations.CanServerSideMove(r.Fremote) {
+ t.Skip("Skipping test as remote does not support server-side move or copy")
+ }
+
+ ci.BackupDir = r.FremoteName + "/backup"
+
+ file1 := r.WriteFile("dst/file1", "file1 contents", t1)
+ r.CheckLocalItems(t, file1)
+
+ file1old := r.WriteObject(ctx, "dst/file1", "file1 contents old", t1)
+ r.CheckRemoteItems(t, file1old)
+
+ err := operations.MoveFile(ctx, r.Fremote, r.Flocal, file1.Path, file1.Path)
+ require.NoError(t, err)
+ r.CheckLocalItems(t)
+ file1old.Path = "backup/dst/file1"
+ r.CheckRemoteItems(t, file1old, file1)
+}
+
+// testFsInfo is for unit testing fs.Info
+type testFsInfo struct {
+ name string
+ root string
+ stringVal string
+ precision time.Duration
+ hashes hash.Set
+ features fs.Features
+}
+
+// Name of the remote (as passed into NewFs)
+func (i *testFsInfo) Name() string { return i.name }
+
+// Root of the remote (as passed into NewFs)
+func (i *testFsInfo) Root() string { return i.root }
+
+// String returns a description of the FS
+func (i *testFsInfo) String() string { return i.stringVal }
+
+// Precision of the ModTimes in this Fs
+func (i *testFsInfo) Precision() time.Duration { return i.precision }
+
+// Returns the supported hash types of the filesystem
+func (i *testFsInfo) Hashes() hash.Set { return i.hashes }
+
+// Returns the supported hash types of the filesystem
+func (i *testFsInfo) Features() *fs.Features { return &i.features }
+
+func TestSameConfig(t *testing.T) {
+ a := &testFsInfo{name: "name", root: "root"}
+ for _, test := range []struct {
+ name string
+ root string
+ expected bool
+ }{
+ {"name", "root", true},
+ {"name", "rooty", true},
+ {"namey", "root", false},
+ {"namey", "roott", false},
+ } {
+ b := &testFsInfo{name: test.name, root: test.root}
+ actual := operations.SameConfig(a, b)
+ assert.Equal(t, test.expected, actual)
+ actual = operations.SameConfig(b, a)
+ assert.Equal(t, test.expected, actual)
+ }
+}
+
+func TestSame(t *testing.T) {
+ a := &testFsInfo{name: "name", root: "root"}
+ for _, test := range []struct {
+ name string
+ root string
+ expected bool
+ }{
+ {"name", "root", true},
+ {"name", "rooty", false},
+ {"namey", "root", false},
+ {"namey", "roott", false},
+ } {
+ b := &testFsInfo{name: test.name, root: test.root}
+ actual := operations.Same(a, b)
+ assert.Equal(t, test.expected, actual)
+ actual = operations.Same(b, a)
+ assert.Equal(t, test.expected, actual)
+ }
+}
+
+// testFs is for unit testing fs.Fs
+type testFs struct {
+ testFsInfo
+}
+
+func (i *testFs) List(ctx context.Context, dir string) (entries fs.DirEntries, err error) {
+ return nil, nil
+}
+
+func (i *testFs) NewObject(ctx context.Context, remote string) (fs.Object, error) { return nil, nil }
+
+func (i *testFs) Put(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) {
+ return nil, nil
+}
+
+func (i *testFs) Mkdir(ctx context.Context, dir string) error { return nil }
+
+func (i *testFs) Rmdir(ctx context.Context, dir string) error { return nil }
+
+// copied from TestOverlapping because the behavior of OverlappingFilterCheck should be identical to Overlapping
+// when no filters are set
+func TestOverlappingFilterCheckWithoutFilter(t *testing.T) {
+ ctx := context.Background()
+ src := &testFs{testFsInfo{name: "name", root: "root"}}
+ slash := string(os.PathSeparator) // native path separator
+ for _, test := range []struct {
+ name string
+ root string
+ expected bool
+ }{
+ {"name", "root", true},
+ {"name", "/root", true},
+ {"namey", "root", false},
+ {"name", "rooty", false},
+ {"namey", "rooty", false},
+ {"name", "roo", false},
+ {"name", "root/toot", true},
+ {"name", "root/toot/", true},
+ {"name", "root" + slash + "toot", true},
+ {"name", "root" + slash + "toot" + slash, true},
+ {"name", "", true},
+ {"name", "/", true},
+ } {
+ dst := &testFs{testFsInfo{name: test.name, root: test.root}}
+ what := fmt.Sprintf("(%q,%q) vs (%q,%q)", src.name, src.root, dst.name, dst.root)
+ actual := operations.OverlappingFilterCheck(ctx, src, dst)
+ assert.Equal(t, test.expected, actual, what)
+ actual = operations.OverlappingFilterCheck(ctx, dst, src)
+ assert.Equal(t, test.expected, actual, what)
+ }
+}
+
+func TestOverlappingFilterCheckWithFilter(t *testing.T) {
+ ctx := context.Background()
+ fi, err := filter.NewFilter(nil)
+ require.NoError(t, err)
+ require.NoError(t, fi.Add(false, "/exclude/"))
+ require.NoError(t, fi.Add(false, "/Exclude2/"))
+ require.NoError(t, fi.Add(true, "*"))
+ ctx = filter.ReplaceConfig(ctx, fi)
+
+ src := &testFs{testFsInfo{name: "name", root: "root"}}
+ src.features.CaseInsensitive = true
+ slash := string(os.PathSeparator) // native path separator
+ for _, test := range []struct {
+ name string
+ root string
+ expected bool
+ }{
+ {"name", "root", true},
+ {"name", "ROOT", true}, // case insensitive is set
+ {"name", "/root", true},
+ {"name", "root/", true},
+ {"name", "root" + slash, true},
+ {"name", "root/exclude", false},
+ {"name", "root/Exclude2", false},
+ {"name", "root/include", true},
+ {"name", "root/exclude/", false},
+ {"name", "root/Exclude2/", false},
+ {"name", "root/exclude/sub", false},
+ {"name", "root/Exclude2/sub", false},
+ {"name", "/root/exclude/", false},
+ {"name", "root" + slash + "exclude", false},
+ {"name", "root" + slash + "exclude" + slash, false},
+ {"namey", "root/include", false},
+ {"namey", "root/include/", false},
+ {"namey", "root" + slash + "include", false},
+ {"namey", "root" + slash + "include" + slash, false},
+ } {
+ dst := &testFs{testFsInfo{name: test.name, root: test.root}}
+ dst.features.CaseInsensitive = true
+ what := fmt.Sprintf("(%q,%q) vs (%q,%q)", src.name, src.root, dst.name, dst.root)
+ actual := operations.OverlappingFilterCheck(ctx, dst, src)
+ assert.Equal(t, test.expected, actual, what)
+ actual = operations.OverlappingFilterCheck(ctx, src, dst)
+ assert.Equal(t, test.expected, actual, what)
+ }
+}
+
+func TestListFormat(t *testing.T) {
+ item0 := &operations.ListJSONItem{
+ Path: "a",
+ Name: "a",
+ Encrypted: "encryptedFileName",
+ Size: 1,
+ MimeType: "application/octet-stream",
+ ModTime: operations.Timestamp{
+ When: t1,
+ Format: "2006-01-02T15:04:05.000000000Z07:00"},
+ IsDir: false,
+ Hashes: map[string]string{
+ "md5": "0cc175b9c0f1b6a831c399e269772661",
+ "sha1": "86f7e437faa5a7fce15d1ddcb9eaeaea377667b8",
+ "dropbox": "bf5d3affb73efd2ec6c36ad3112dd933efed63c4e1cbffcfa88e2759c144f2d8",
+ "quickxor": "6100000000000000000000000100000000000000"},
+ ID: "fileID",
+ OrigID: "fileOrigID",
+ }
+
+ item1 := &operations.ListJSONItem{
+ Path: "subdir",
+ Name: "subdir",
+ Encrypted: "encryptedDirName",
+ Size: -1,
+ MimeType: "inode/directory",
+ ModTime: operations.Timestamp{
+ When: t2,
+ Format: "2006-01-02T15:04:05.000000000Z07:00"},
+ IsDir: true,
+ Hashes: map[string]string(nil),
+ ID: "dirID",
+ OrigID: "dirOrigID",
+ }
+
+ var list operations.ListFormat
+ list.AddPath()
+ list.SetDirSlash(false)
+ assert.Equal(t, "subdir", list.Format(item1))
+
+ list.SetDirSlash(true)
+ assert.Equal(t, "subdir/", list.Format(item1))
+
+ list.SetOutput(nil)
+ assert.Equal(t, "", list.Format(item1))
+
+ list.AppendOutput(func(item *operations.ListJSONItem) string { return "a" })
+ list.AppendOutput(func(item *operations.ListJSONItem) string { return "b" })
+ assert.Equal(t, "ab", list.Format(item1))
+ list.SetSeparator(":::")
+ assert.Equal(t, "a:::b", list.Format(item1))
+
+ list.SetOutput(nil)
+ list.AddModTime("")
+ assert.Equal(t, t1.Local().Format("2006-01-02 15:04:05"), list.Format(item0))
+
+ list.SetOutput(nil)
+ list.AddModTime("unix")
+ assert.Equal(t, fmt.Sprint(t1.Local().Unix()), list.Format(item0))
+
+ list.SetOutput(nil)
+ list.AddModTime("unixnano")
+ assert.Equal(t, fmt.Sprint(t1.Local().UnixNano()), list.Format(item0))
+
+ list.SetOutput(nil)
+ list.SetSeparator("|")
+ list.AddID()
+ list.AddOrigID()
+ assert.Equal(t, "fileID|fileOrigID", list.Format(item0))
+ assert.Equal(t, "dirID|dirOrigID", list.Format(item1))
+
+ list.SetOutput(nil)
+ list.AddMimeType()
+ 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)
+ assert.Equal(t, "/a", list.Format(item0))
+ list.SetAbsolute(false)
+ assert.Equal(t, "a", list.Format(item0))
+
+ list.SetOutput(nil)
+ list.AddSize()
+ assert.Equal(t, "1", list.Format(item0))
+
+ list.AddPath()
+ list.AddModTime("")
+ list.SetDirSlash(true)
+ list.SetSeparator("__SEP__")
+ assert.Equal(t, "1__SEP__a__SEP__"+t1.Local().Format("2006-01-02 15:04:05"), list.Format(item0))
+ assert.Equal(t, "-1__SEP__subdir/__SEP__"+t2.Local().Format("2006-01-02 15:04:05"), list.Format(item1))
+
+ for _, test := range []struct {
+ ht hash.Type
+ want string
+ }{
+ {hash.MD5, "0cc175b9c0f1b6a831c399e269772661"},
+ {hash.SHA1, "86f7e437faa5a7fce15d1ddcb9eaeaea377667b8"},
+ } {
+ list.SetOutput(nil)
+ list.AddHash(test.ht)
+ assert.Equal(t, test.want, list.Format(item0))
+ }
+
+ list.SetOutput(nil)
+ list.SetSeparator("|")
+ list.SetCSV(true)
+ list.AddSize()
+ list.AddPath()
+ list.AddModTime("")
+ list.SetDirSlash(true)
+ assert.Equal(t, "1|a|"+t1.Local().Format("2006-01-02 15:04:05"), list.Format(item0))
+ assert.Equal(t, "-1|subdir/|"+t2.Local().Format("2006-01-02 15:04:05"), list.Format(item1))
+
+ list.SetOutput(nil)
+ list.SetSeparator("|")
+ list.AddPath()
+ list.AddEncrypted()
+ assert.Equal(t, "a|encryptedFileName", list.Format(item0))
+ assert.Equal(t, "subdir/|encryptedDirName/", list.Format(item1))
+
+}
+
+func TestDirMove(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+
+ r.Mkdir(ctx, r.Fremote)
+
+ // Make some files and dirs
+ r.ForceMkdir(ctx, r.Fremote)
+ files := []fstest.Item{
+ r.WriteObject(ctx, "A1/one", "one", t1),
+ r.WriteObject(ctx, "A1/two", "two", t2),
+ r.WriteObject(ctx, "A1/B1/three", "three", t3),
+ r.WriteObject(ctx, "A1/B1/C1/four", "four", t1),
+ r.WriteObject(ctx, "A1/B1/C2/five", "five", t2),
+ }
+ require.NoError(t, operations.Mkdir(ctx, r.Fremote, "A1/B2"))
+ require.NoError(t, operations.Mkdir(ctx, r.Fremote, "A1/B1/C3"))
+
+ fstest.CheckListingWithPrecision(
+ t,
+ r.Fremote,
+ files,
+ []string{
+ "A1",
+ "A1/B1",
+ "A1/B2",
+ "A1/B1/C1",
+ "A1/B1/C2",
+ "A1/B1/C3",
+ },
+ fs.GetModifyWindow(ctx, r.Fremote),
+ )
+
+ require.NoError(t, operations.DirMove(ctx, r.Fremote, "A1", "A2"))
+
+ for i := range files {
+ files[i].Path = strings.ReplaceAll(files[i].Path, "A1/", "A2/")
+ }
+
+ fstest.CheckListingWithPrecision(
+ t,
+ r.Fremote,
+ files,
+ []string{
+ "A2",
+ "A2/B1",
+ "A2/B2",
+ "A2/B1/C1",
+ "A2/B1/C2",
+ "A2/B1/C3",
+ },
+ fs.GetModifyWindow(ctx, r.Fremote),
+ )
+
+ // Disable DirMove
+ features := r.Fremote.Features()
+ features.DirMove = nil
+
+ require.NoError(t, operations.DirMove(ctx, r.Fremote, "A2", "A3"))
+
+ for i := range files {
+ files[i].Path = strings.ReplaceAll(files[i].Path, "A2/", "A3/")
+ }
+
+ fstest.CheckListingWithPrecision(
+ t,
+ r.Fremote,
+ files,
+ []string{
+ "A3",
+ "A3/B1",
+ "A3/B2",
+ "A3/B1/C1",
+ "A3/B1/C2",
+ "A3/B1/C3",
+ },
+ fs.GetModifyWindow(ctx, r.Fremote),
+ )
+
+ // Try with a DirMove method that exists but returns fs.ErrorCantDirMove (ex. combine moving across upstreams)
+ // Should fall back to manual move (copy + delete)
+
+ features.DirMove = func(ctx context.Context, src fs.Fs, srcRemote string, dstRemote string) error {
+ return fs.ErrorCantDirMove
+ }
+
+ assert.NoError(t, operations.DirMove(ctx, r.Fremote, "A3", "A4"))
+
+ for i := range files {
+ files[i].Path = strings.ReplaceAll(files[i].Path, "A3/", "A4/")
+ }
+
+ fstest.CheckListingWithPrecision(
+ t,
+ r.Fremote,
+ files,
+ []string{
+ "A4",
+ "A4/B1",
+ "A4/B2",
+ "A4/B1/C1",
+ "A4/B1/C2",
+ "A4/B1/C3",
+ },
+ fs.GetModifyWindow(ctx, r.Fremote),
+ )
+}
+
+func TestGetFsInfo(t *testing.T) {
+ r := fstest.NewRun(t)
+
+ f := r.Fremote
+ info := operations.GetFsInfo(f)
+ assert.Equal(t, f.Name(), info.Name)
+ assert.Equal(t, f.Root(), info.Root)
+ assert.Equal(t, f.String(), info.String)
+ assert.Equal(t, f.Precision(), info.Precision)
+ hashSet := hash.NewHashSet()
+ for _, hashName := range info.Hashes {
+ var ht hash.Type
+ require.NoError(t, ht.Set(hashName))
+ hashSet.Add(ht)
+ }
+ assert.Equal(t, f.Hashes(), hashSet)
+ assert.Equal(t, f.Features().Enabled(), info.Features)
+}
+
+func TestRcat(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ check := func(t *testing.T, withChecksum, ignoreChecksum bool) {
+ ci.CheckSum, ci.IgnoreChecksum = withChecksum, ignoreChecksum
+
+ var prefix string
+ if withChecksum {
+ prefix = "with_checksum_"
+ } else {
+ prefix = "no_checksum_"
+ }
+ if ignoreChecksum {
+ prefix = "ignore_checksum_"
+ }
+
+ r := fstest.NewRun(t)
+
+ if *fstest.SizeLimit > 0 && int64(ci.StreamingUploadCutoff) > *fstest.SizeLimit {
+ savedCutoff := ci.StreamingUploadCutoff
+ ci.StreamingUploadCutoff = fs.SizeSuffix(*fstest.SizeLimit)
+ t.Logf("Adjust StreamingUploadCutoff to size limit %s (was %s)", ci.StreamingUploadCutoff, savedCutoff)
+ }
+
+ fstest.CheckListing(t, r.Fremote, []fstest.Item{})
+
+ data1 := "this is some really nice test data"
+ path1 := prefix + "small_file_from_pipe"
+
+ data2 := string(make([]byte, ci.StreamingUploadCutoff+1))
+ path2 := prefix + "big_file_from_pipe"
+
+ in := io.NopCloser(strings.NewReader(data1))
+ _, err := operations.Rcat(ctx, r.Fremote, path1, in, t1, nil)
+ require.NoError(t, err)
+
+ in = io.NopCloser(strings.NewReader(data2))
+ _, err = operations.Rcat(ctx, r.Fremote, path2, in, t2, nil)
+ require.NoError(t, err)
+
+ file1 := fstest.NewItem(path1, data1, t1)
+ file2 := fstest.NewItem(path2, data2, t2)
+ r.CheckRemoteItems(t, file1, file2)
+ }
+
+ for i := range 4 {
+ withChecksum := (i & 1) != 0
+ ignoreChecksum := (i & 2) != 0
+ t.Run(fmt.Sprintf("withChecksum=%v,ignoreChecksum=%v", withChecksum, ignoreChecksum), func(t *testing.T) {
+ check(t, withChecksum, ignoreChecksum)
+ })
+ }
+}
+
+func TestRcatMetadata(t *testing.T) {
+ r := fstest.NewRun(t)
+
+ if !r.Fremote.Features().UserMetadata {
+ t.Skip("Skipping as destination doesn't support user metadata")
+ }
+
+ test := func(disableUploadCutoff bool) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ ci.Metadata = true
+ data := "this is some really nice test data with metadata"
+ path := "rcat_metadata"
+
+ meta := fs.Metadata{
+ "key": "value",
+ "sausage": "potato",
+ }
+
+ if disableUploadCutoff {
+ ci.StreamingUploadCutoff = 0
+ data += " uploadCutoff=0"
+ path += "_uploadcutoff0"
+ }
+
+ fstest.CheckListing(t, r.Fremote, []fstest.Item{})
+
+ in := io.NopCloser(strings.NewReader(data))
+ _, err := operations.Rcat(ctx, r.Fremote, path, in, t1, meta)
+ require.NoError(t, err)
+
+ file := fstest.NewItem(path, data, t1)
+ r.CheckRemoteItems(t, file)
+
+ o, err := r.Fremote.NewObject(ctx, path)
+ require.NoError(t, err)
+ gotMeta, err := fs.GetMetadata(ctx, o)
+ require.NoError(t, err)
+ // Check the specific user data we set is set
+ // Likely there will be other values
+ assert.Equal(t, "value", gotMeta["key"])
+ assert.Equal(t, "potato", gotMeta["sausage"])
+
+ // Delete the test file
+ require.NoError(t, o.Remove(ctx))
+ }
+
+ t.Run("Normal", func(t *testing.T) {
+ test(false)
+ })
+ t.Run("ViaDisk", func(t *testing.T) {
+ test(true)
+ })
+}
+
+func TestRcatSize(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+
+ const body = "------------------------------------------------------------"
+ file1 := r.WriteFile("potato1", body, t1)
+ file2 := r.WriteFile("potato2", body, t2)
+ // Test with known length
+ bodyReader := io.NopCloser(strings.NewReader(body))
+ obj, err := operations.RcatSize(ctx, r.Fremote, file1.Path, bodyReader, int64(len(body)), file1.ModTime, nil)
+ require.NoError(t, err)
+ assert.Equal(t, int64(len(body)), obj.Size())
+ assert.Equal(t, file1.Path, obj.Remote())
+
+ // Test with unknown length
+ bodyReader = io.NopCloser(strings.NewReader(body)) // reset Reader
+ io.NopCloser(strings.NewReader(body))
+ obj, err = operations.RcatSize(ctx, r.Fremote, file2.Path, bodyReader, -1, file2.ModTime, nil)
+ require.NoError(t, err)
+ assert.Equal(t, int64(len(body)), obj.Size())
+ assert.Equal(t, file2.Path, obj.Remote())
+
+ // Check files exist
+ r.CheckRemoteItems(t, file1, file2)
+}
+
+func TestRcatSizeMetadata(t *testing.T) {
+ r := fstest.NewRun(t)
+
+ if !r.Fremote.Features().UserMetadata {
+ t.Skip("Skipping as destination doesn't support user metadata")
+ }
+
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ ci.Metadata = true
+
+ meta := fs.Metadata{
+ "key": "value",
+ "sausage": "potato",
+ }
+
+ const body = "------------------------------------------------------------"
+ file1 := r.WriteFile("potato1", body, t1)
+ file2 := r.WriteFile("potato2", body, t2)
+
+ // Test with known length
+ bodyReader := io.NopCloser(strings.NewReader(body))
+ obj, err := operations.RcatSize(ctx, r.Fremote, file1.Path, bodyReader, int64(len(body)), file1.ModTime, meta)
+ require.NoError(t, err)
+ assert.Equal(t, int64(len(body)), obj.Size())
+ assert.Equal(t, file1.Path, obj.Remote())
+
+ // Test with unknown length
+ bodyReader = io.NopCloser(strings.NewReader(body)) // reset Reader
+ io.NopCloser(strings.NewReader(body))
+ obj, err = operations.RcatSize(ctx, r.Fremote, file2.Path, bodyReader, -1, file2.ModTime, meta)
+ require.NoError(t, err)
+ assert.Equal(t, int64(len(body)), obj.Size())
+ assert.Equal(t, file2.Path, obj.Remote())
+
+ // Check files exist
+ r.CheckRemoteItems(t, file1, file2)
+
+ // Check metadata OK
+ for _, path := range []string{file1.Path, file2.Path} {
+ o, err := r.Fremote.NewObject(ctx, path)
+ require.NoError(t, err)
+ gotMeta, err := fs.GetMetadata(ctx, o)
+ require.NoError(t, err)
+ // Check the specific user data we set is set
+ // Likely there will be other values
+ assert.Equal(t, "value", gotMeta["key"])
+ assert.Equal(t, "potato", gotMeta["sausage"])
+ }
+}
+
+func TestTouchDir(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+
+ if r.Fremote.Precision() == fs.ModTimeNotSupported {
+ t.Skip("Skipping test as remote does not support modtime")
+ }
+
+ file1 := r.WriteBoth(ctx, "potato2", "------------------------------------------------------------", t1)
+ file2 := r.WriteBoth(ctx, "empty space", "-", t2)
+ file3 := r.WriteBoth(ctx, "sub dir/potato3", "hello", t2)
+ r.CheckRemoteItems(t, file1, file2, file3)
+
+ accounting.GlobalStats().ResetCounters()
+ timeValue := time.Date(2010, 9, 8, 7, 6, 5, 4, time.UTC)
+ err := operations.TouchDir(ctx, r.Fremote, "", timeValue, true)
+ require.NoError(t, err)
+ if accounting.Stats(ctx).GetErrors() != 0 {
+ err = accounting.Stats(ctx).GetLastError()
+ require.True(t, errors.Is(err, fs.ErrorCantSetModTime) || errors.Is(err, fs.ErrorCantSetModTimeWithoutDelete))
+ } else {
+ file1.ModTime = timeValue
+ file2.ModTime = timeValue
+ file3.ModTime = timeValue
+ r.CheckRemoteItems(t, file1, file2, file3)
+ }
+}
+
+var testMetadata = fs.Metadata{
+ // System metadata supported by all backends
+ "mtime": t1.Format(time.RFC3339Nano),
+ // User metadata
+ "potato": "jersey",
+}
+
+func TestMkdirMetadata(t *testing.T) {
+ const name = "dir with metadata"
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ ci.Metadata = true
+ r := fstest.NewRun(t)
+ features := r.Fremote.Features()
+ if features.MkdirMetadata == nil {
+ t.Skip("Skipping test as remote does not support MkdirMetadata")
+ }
+
+ newDst, err := operations.MkdirMetadata(ctx, r.Fremote, name, testMetadata)
+ require.NoError(t, err)
+ require.NotNil(t, newDst)
+
+ require.True(t, features.ReadDirMetadata, "Expecting ReadDirMetadata to be supported if MkdirMetadata is supported")
+
+ // Check the returned directory and one read from the listing
+ fstest.CheckEntryMetadata(ctx, t, r.Fremote, newDst, testMetadata)
+ fstest.CheckEntryMetadata(ctx, t, r.Fremote, fstest.NewDirectory(ctx, t, r.Fremote, name), testMetadata)
+}
+
+func TestMkdirModTime(t *testing.T) {
+ const name = "directory with modtime"
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ if r.Fremote.Features().DirSetModTime == nil && r.Fremote.Features().MkdirMetadata == nil {
+ t.Skip("Skipping test as remote does not support DirSetModTime or MkdirMetadata")
+ }
+ newDst, err := operations.MkdirModTime(ctx, r.Fremote, name, t2)
+ require.NoError(t, err)
+
+ // Check the returned directory and one read from the listing
+ // newDst may be nil here depending on how the modtime was set
+ if newDst != nil {
+ fstest.CheckDirModTime(ctx, t, r.Fremote, newDst, t2)
+ }
+ fstest.CheckDirModTime(ctx, t, r.Fremote, fstest.NewDirectory(ctx, t, r.Fremote, name), t2)
+}
+
+func TestCopyDirMetadata(t *testing.T) {
+ const nameNonExistent = "non existent directory"
+ const nameExistent = "existing directory"
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ ci.Metadata = true
+ r := fstest.NewRun(t)
+ if !r.Fremote.Features().WriteDirMetadata && r.Fremote.Features().MkdirMetadata == nil {
+ t.Skip("Skipping test as remote does not support WriteDirMetadata or MkdirMetadata")
+ }
+
+ // Create a source local directory with metadata
+ newSrc, err := operations.MkdirMetadata(ctx, r.Flocal, "dir with metadata to be copied", testMetadata)
+ require.NoError(t, err)
+ require.NotNil(t, newSrc)
+
+ // First try with the directory not existing
+ newDst, err := operations.CopyDirMetadata(ctx, r.Fremote, nil, nameNonExistent, newSrc)
+ require.NoError(t, err)
+ require.NotNil(t, newDst)
+
+ // Check the returned directory and one read from the listing
+ fstest.CheckEntryMetadata(ctx, t, r.Fremote, newDst, testMetadata)
+ fstest.CheckEntryMetadata(ctx, t, r.Fremote, fstest.NewDirectory(ctx, t, r.Fremote, nameNonExistent), testMetadata)
+
+ // Then try with the directory existing
+ require.NoError(t, r.Fremote.Rmdir(ctx, nameNonExistent))
+ require.NoError(t, r.Fremote.Mkdir(ctx, nameExistent))
+ existingDir := fstest.NewDirectory(ctx, t, r.Fremote, nameExistent)
+
+ newDst, err = operations.CopyDirMetadata(ctx, r.Fremote, existingDir, "SHOULD BE IGNORED", newSrc)
+ require.NoError(t, err)
+ require.NotNil(t, newDst)
+
+ // Check the returned directory and one read from the listing
+ fstest.CheckEntryMetadata(ctx, t, r.Fremote, newDst, testMetadata)
+ fstest.CheckEntryMetadata(ctx, t, r.Fremote, fstest.NewDirectory(ctx, t, r.Fremote, nameExistent), testMetadata)
+}
+
+func TestSetDirModTime(t *testing.T) {
+ const name = "set modtime on existing directory"
+ ctx, ci := fs.AddConfig(context.Background())
+ r := fstest.NewRun(t)
+ if r.Fremote.Features().DirSetModTime == nil && !r.Fremote.Features().WriteDirSetModTime {
+ t.Skip("Skipping test as remote does not support DirSetModTime or WriteDirSetModTime")
+ }
+
+ // Check that we obey --no-update-dir-modtime - this should return nil, nil
+ ci.NoUpdateDirModTime = true
+ newDst, err := operations.SetDirModTime(ctx, r.Fremote, nil, "set modtime on non existent directory", t2)
+ require.NoError(t, err)
+ require.Nil(t, newDst)
+ ci.NoUpdateDirModTime = false
+
+ // First try with the directory not existing - should return an error
+ newDst, err = operations.SetDirModTime(ctx, r.Fremote, nil, "set modtime on non existent directory", t2)
+ require.Error(t, err)
+ require.Nil(t, newDst)
+
+ // Then try with the directory existing
+ require.NoError(t, r.Fremote.Mkdir(ctx, name))
+ existingDir := fstest.NewDirectory(ctx, t, r.Fremote, name)
+
+ newDst, err = operations.SetDirModTime(ctx, r.Fremote, existingDir, "SHOULD BE IGNORED", t2)
+ require.NoError(t, err)
+ require.NotNil(t, newDst)
+
+ // Check the returned directory and one read from the listing
+ // The modtime will only be correct on newDst if it had a SetModTime method
+ if _, ok := newDst.(fs.SetModTimer); ok {
+ fstest.CheckDirModTime(ctx, t, r.Fremote, newDst, t2)
+ }
+ fstest.CheckDirModTime(ctx, t, r.Fremote, fstest.NewDirectory(ctx, t, r.Fremote, name), t2)
+
+ // Now wrap the directory to make the SetModTime method return fs.ErrorNotImplemented and check that it falls back correctly
+ wrappedDir := fs.NewDirWrapper(existingDir.Remote(), fs.NewDir(existingDir.Remote(), existingDir.ModTime(ctx)))
+ newDst, err = operations.SetDirModTime(ctx, r.Fremote, wrappedDir, "SHOULD BE IGNORED", t1)
+ require.NoError(t, err)
+ require.NotNil(t, newDst)
+ fstest.CheckDirModTime(ctx, t, r.Fremote, fstest.NewDirectory(ctx, t, r.Fremote, name), t1)
+}
+
+func TestDirsEqual(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ ci.Metadata = true
+ r := fstest.NewRun(t)
+ if !r.Fremote.Features().WriteDirMetadata && r.Fremote.Features().MkdirMetadata == nil {
+ t.Skip("Skipping test as remote does not support WriteDirMetadata or MkdirMetadata")
+ }
+
+ opt := operations.DirsEqualOpt{
+ ModifyWindow: fs.GetModifyWindow(ctx, r.Flocal, r.Fremote),
+ SetDirModtime: true,
+ SetDirMetadata: true,
+ }
+
+ // Create a source local directory with metadata
+ src, err := operations.MkdirMetadata(ctx, r.Flocal, "dir with metadata to be copied", testMetadata)
+ require.NoError(t, err)
+ require.NotNil(t, src)
+
+ // try with nil dst -- should be false
+ equal := operations.DirsEqual(ctx, src, nil, opt)
+ assert.False(t, equal)
+
+ // make a dest with an equal modtime
+ dst, err := operations.MkdirModTime(ctx, r.Fremote, "dst", src.ModTime(ctx))
+ require.NoError(t, err)
+
+ // try with equal modtimes -- should be true
+ equal = operations.DirsEqual(ctx, src, dst, opt)
+ assert.True(t, equal)
+
+ // try with unequal modtimes -- should be false
+ dst, err = operations.SetDirModTime(ctx, r.Fremote, dst, "", t2)
+ require.NoError(t, err)
+ equal = operations.DirsEqual(ctx, src, dst, opt)
+ assert.False(t, equal)
+
+ // try with unequal modtimes that are within modify window -- should be true
+ halfWindow := opt.ModifyWindow / 2
+ dst, err = operations.SetDirModTime(ctx, r.Fremote, dst, "", src.ModTime(ctx).Add(halfWindow))
+ require.NoError(t, err)
+ equal = operations.DirsEqual(ctx, src, dst, opt)
+ assert.True(t, equal)
+
+ // test ignoretimes -- should be false
+ ci.IgnoreTimes = true
+ equal = operations.DirsEqual(ctx, src, dst, opt)
+ assert.False(t, equal)
+
+ // test immutable -- should be true
+ ci.IgnoreTimes = false
+ ci.Immutable = true
+ dst, err = operations.SetDirModTime(ctx, r.Fremote, dst, "", t3)
+ require.NoError(t, err)
+ equal = operations.DirsEqual(ctx, src, dst, opt)
+ assert.True(t, equal)
+
+ // test dst newer than src with --update -- should be true
+ ci.Immutable = false
+ ci.UpdateOlder = true
+ equal = operations.DirsEqual(ctx, src, dst, opt)
+ assert.True(t, equal)
+
+ // test no SetDirModtime or SetDirMetadata -- should be true
+ ci.UpdateOlder = false
+ opt.SetDirMetadata, opt.SetDirModtime = false, false
+ equal = operations.DirsEqual(ctx, src, dst, opt)
+ assert.True(t, equal)
+}
+
+func TestRemoveExisting(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ if r.Fremote.Features().Move == nil {
+ t.Skip("Skipping as remote can't Move")
+ }
+
+ file1 := r.WriteObject(ctx, "sub dir/test remove existing", "hello world", t1)
+ file2 := r.WriteObject(ctx, "sub dir/test remove existing with long name 123456789012345678901234567890123456789012345678901234567890123456789", "hello long name world", t1)
+
+ r.CheckRemoteItems(t, file1, file2)
+
+ var returnedError error
+
+ // Check not found first
+ cleanup, err := operations.RemoveExisting(ctx, r.Fremote, "not found", "TEST")
+ assert.Equal(t, err, nil)
+ r.CheckRemoteItems(t, file1, file2)
+ cleanup(&returnedError)
+ r.CheckRemoteItems(t, file1, file2)
+
+ // Remove file1
+ cleanup, err = operations.RemoveExisting(ctx, r.Fremote, file1.Path, "TEST")
+ assert.Equal(t, err, nil)
+ //r.CheckRemoteItems(t, file1, file2)
+
+ // Check file1 with temporary name exists
+ var buf bytes.Buffer
+ err = operations.List(ctx, r.Fremote, &buf)
+ require.NoError(t, err)
+ res := buf.String()
+ assert.NotContains(t, res, " 11 "+file1.Path+"\n")
+ assert.Contains(t, res, " 11 "+file1.Path+".")
+ assert.Contains(t, res, " 21 "+file2.Path+"\n")
+
+ cleanup(&returnedError)
+ r.CheckRemoteItems(t, file2)
+
+ // Remove file2 with an error
+ cleanup, err = operations.RemoveExisting(ctx, r.Fremote, file2.Path, "TEST")
+ assert.Equal(t, err, nil)
+
+ // Check file2 with truncated temporary name exists
+ buf.Reset()
+ err = operations.List(ctx, r.Fremote, &buf)
+ require.NoError(t, err)
+ res = buf.String()
+ assert.NotContains(t, res, " 21 "+file2.Path+"\n")
+ assert.NotContains(t, res, " 21 "+file2.Path+".")
+ assert.Contains(t, res, " 21 "+file2.Path[:100])
+
+ returnedError = errors.New("BOOM")
+ cleanup(&returnedError)
+ r.CheckRemoteItems(t, file2)
+
+ // Remove file2
+ cleanup, err = operations.RemoveExisting(ctx, r.Fremote, file2.Path, "TEST")
+ assert.Equal(t, err, nil)
+
+ // Check file2 with truncated temporary name exists
+ buf.Reset()
+ err = operations.List(ctx, r.Fremote, &buf)
+ require.NoError(t, err)
+ res = buf.String()
+ assert.NotContains(t, res, " 21 "+file2.Path+"\n")
+ assert.NotContains(t, res, " 21 "+file2.Path+".")
+ assert.Contains(t, res, " 21 "+file2.Path[:100])
+
+ returnedError = nil
+ cleanup(&returnedError)
+ r.CheckRemoteItems(t)
+}
diff --git a/fs/operations/operationsflags/operationsflags.go b/fs/operations/operationsflags/operationsflags.go
new file mode 100644
index 0000000..054ed96
--- /dev/null
+++ b/fs/operations/operationsflags/operationsflags.go
@@ -0,0 +1,147 @@
+// Package operationsflags defines the flags used by rclone operations.
+// It is decoupled into a separate package so it can be replaced.
+package operationsflags
+
+import (
+ "context"
+ _ "embed"
+ "io"
+ "os"
+ "strings"
+
+ "github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/fs/config/flags"
+ "github.com/rclone/rclone/fs/hash"
+ "github.com/rclone/rclone/fs/operations"
+ "github.com/spf13/cobra"
+ "github.com/spf13/pflag"
+)
+
+//go:embed operationsflags.md
+var help string
+
+// Help returns the help string cleaned up to simplify appending
+func Help() string {
+ return strings.TrimSpace(help)
+}
+
+// AddLoggerFlagsOptions contains options for the Logger Flags
+type AddLoggerFlagsOptions struct {
+ Combined string // a file with file names with leading sigils
+ MissingOnSrc string // files only in the destination
+ MissingOnDst string // files only in the source
+ Match string // matching files
+ Differ string // differing files
+ ErrFile string // files with errors of some kind
+ DestAfter string // files that exist on the destination post-sync
+}
+
+// AnySet checks if any of the logger flags have a non-blank value
+func (o AddLoggerFlagsOptions) AnySet() bool {
+ return anyNotBlank(o.Combined, o.MissingOnSrc, o.MissingOnDst, o.Match, o.Differ, o.ErrFile, o.DestAfter)
+}
+
+func anyNotBlank(s ...string) bool {
+ for _, x := range s {
+ if x != "" {
+ return true
+ }
+ }
+ return false
+}
+
+// AddLoggerFlags adds the logger flags to the cmdFlags command
+func AddLoggerFlags(cmdFlags *pflag.FlagSet, opt *operations.LoggerOpt, flagsOpt *AddLoggerFlagsOptions) {
+ flags.StringVarP(cmdFlags, &flagsOpt.Combined, "combined", "", flagsOpt.Combined, "Make a combined report of changes to this file", "Sync")
+ flags.StringVarP(cmdFlags, &flagsOpt.MissingOnSrc, "missing-on-src", "", flagsOpt.MissingOnSrc, "Report all files missing from the source to this file", "Sync")
+ flags.StringVarP(cmdFlags, &flagsOpt.MissingOnDst, "missing-on-dst", "", flagsOpt.MissingOnDst, "Report all files missing from the destination to this file", "Sync")
+ flags.StringVarP(cmdFlags, &flagsOpt.Match, "match", "", flagsOpt.Match, "Report all matching files to this file", "Sync")
+ flags.StringVarP(cmdFlags, &flagsOpt.Differ, "differ", "", flagsOpt.Differ, "Report all non-matching files to this file", "Sync")
+ flags.StringVarP(cmdFlags, &flagsOpt.ErrFile, "error", "", flagsOpt.ErrFile, "Report all files with errors (hashing or reading) to this file", "Sync")
+ flags.StringVarP(cmdFlags, &flagsOpt.DestAfter, "dest-after", "", flagsOpt.DestAfter, "Report all files that exist on the dest post-sync", "Sync")
+
+ // lsf flags for destAfter
+ flags.StringVarP(cmdFlags, &opt.Format, "format", "F", "p", "Output format - see lsf help for details", "Sync")
+ flags.StringVarP(cmdFlags, &opt.TimeFormat, "timeformat", "t", "", "Specify a custom time format - see docs for details (default: 2006-01-02 15:04:05)", "")
+ flags.StringVarP(cmdFlags, &opt.Separator, "separator", "s", ";", "Separator for the items in the format", "Sync")
+ flags.BoolVarP(cmdFlags, &opt.DirSlash, "dir-slash", "d", true, "Append a slash to directory names", "Sync")
+ opt.HashType = hash.MD5
+ flags.FVarP(cmdFlags, &opt.HashType, "hash", "", "Use this hash when `h` is used in the format MD5|SHA-1|DropboxHash", "Sync")
+ flags.BoolVarP(cmdFlags, &opt.FilesOnly, "files-only", "", true, "Only list files", "Sync")
+ flags.BoolVarP(cmdFlags, &opt.DirsOnly, "dirs-only", "", false, "Only list directories", "Sync")
+ flags.BoolVarP(cmdFlags, &opt.Csv, "csv", "", false, "Output in CSV format", "Sync")
+ flags.BoolVarP(cmdFlags, &opt.Absolute, "absolute", "", false, "Put a leading / in front of path names", "Sync")
+ // flags.BoolVarP(cmdFlags, &recurse, "recursive", "R", false, "Recurse into the listing", "")
+}
+
+// ConfigureLoggers verifies and sets up writers for log files requested via CLI flags
+func ConfigureLoggers(ctx context.Context, fdst fs.Fs, command *cobra.Command, opt *operations.LoggerOpt, flagsOpt AddLoggerFlagsOptions) (func(), error) {
+ closers := []io.Closer{}
+
+ if opt.TimeFormat == "max" {
+ opt.TimeFormat = operations.FormatForLSFPrecision(fdst.Precision())
+ }
+ opt.SetListFormat(ctx, command.Flags())
+ opt.NewListJSON(ctx, fdst, "")
+
+ open := func(name string, pout *io.Writer) error {
+ if name == "" {
+ return nil
+ }
+ if name == "-" {
+ *pout = os.Stdout
+ return nil
+ }
+ out, err := os.Create(name)
+ if err != nil {
+ return err
+ }
+ *pout = out
+ closers = append(closers, out)
+ return nil
+ }
+
+ if err := open(flagsOpt.Combined, &opt.Combined); err != nil {
+ return nil, err
+ }
+ if err := open(flagsOpt.MissingOnSrc, &opt.MissingOnSrc); err != nil {
+ return nil, err
+ }
+ if err := open(flagsOpt.MissingOnDst, &opt.MissingOnDst); err != nil {
+ return nil, err
+ }
+ if err := open(flagsOpt.Match, &opt.Match); err != nil {
+ return nil, err
+ }
+ if err := open(flagsOpt.Differ, &opt.Differ); err != nil {
+ return nil, err
+ }
+ if err := open(flagsOpt.ErrFile, &opt.Error); err != nil {
+ return nil, err
+ }
+ if err := open(flagsOpt.DestAfter, &opt.DestAfter); err != nil {
+ return nil, err
+ }
+
+ close := func() {
+ for _, closer := range closers {
+ err := closer.Close()
+ if err != nil {
+ fs.Errorf(nil, "Failed to close report output: %v", err)
+ }
+ }
+ }
+
+ ci := fs.GetConfig(ctx)
+ if ci.NoTraverse && opt.Combined != nil {
+ fs.LogPrintf(fs.LogLevelWarning, nil, "--no-traverse does not list any deletes (-) in --combined output\n")
+ }
+ if ci.NoTraverse && opt.MissingOnSrc != nil {
+ fs.LogPrintf(fs.LogLevelWarning, nil, "--no-traverse makes --missing-on-src produce empty output\n")
+ }
+ if ci.NoTraverse && opt.DestAfter != nil {
+ fs.LogPrintf(fs.LogLevelWarning, nil, "--no-traverse makes --dest-after produce incomplete output\n")
+ }
+
+ return close, nil
+}
diff --git a/fs/operations/operationsflags/operationsflags.md b/fs/operations/operationsflags/operationsflags.md
new file mode 100644
index 0000000..43def48
--- /dev/null
+++ b/fs/operations/operationsflags/operationsflags.md
@@ -0,0 +1,40 @@
+### Logger Flags
+
+The `--differ`, `--missing-on-dst`, `--missing-on-src`, `--match` and `--error`
+flags write paths, one per line, to the file name (or stdout if it is `-`)
+supplied. What they write is described in the help below. For example
+`--differ` will write all paths which are present on both the source and
+destination but different.
+
+The `--combined` flag will write a file (or stdout) which contains all
+file paths with a symbol and then a space and then the path to tell
+you what happened to it. These are reminiscent of diff files.
+
+- `= path` means path was found in source and destination and was identical
+- `- path` means path was missing on the source, so only in the destination
+- `+ path` means path was missing on the destination, so only in the source
+- `* path` means path was present in source and destination but different.
+- `! path` means there was an error reading or hashing the source or dest.
+
+The `--dest-after` flag writes a list file using the same format flags
+as [`lsf`](/commands/rclone_lsf/#synopsis) (including [customizable options
+for hash, modtime, etc.](/commands/rclone_lsf/#synopsis))
+Conceptually it is similar to rsync's `--itemize-changes`, but not identical
+-- it should output an accurate list of what will be on the destination
+after the command is finished.
+
+When the `--no-traverse` flag is set, all logs involving files that exist only
+on the destination will be incomplete or completely missing.
+
+Note that these logger flags have a few limitations, and certain scenarios
+are not currently supported:
+
+- `--max-duration` / `CutoffModeHard`
+- `--compare-dest` / `--copy-dest`
+- server-side moves of an entire dir at once
+- High-level retries, because there would be duplicates (use `--retries 1` to disable)
+- Possibly some unusual error scenarios
+
+Note also that each file is logged during execution, as opposed to after, so it
+is most useful as a predictor of what SHOULD happen to each file
+(which may or may not match what actually DID).
diff --git a/fs/operations/rc.go b/fs/operations/rc.go
new file mode 100644
index 0000000..ce92a00
--- /dev/null
+++ b/fs/operations/rc.go
@@ -0,0 +1,1016 @@
+package operations
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "mime"
+ "mime/multipart"
+ "net/http"
+ "path"
+ "strings"
+ "time"
+
+ "github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/fs/config"
+ "github.com/rclone/rclone/fs/hash"
+ "github.com/rclone/rclone/fs/rc"
+ "github.com/rclone/rclone/lib/diskusage"
+)
+
+func init() {
+ rc.Add(rc.Call{
+ Path: "operations/list",
+ AuthRequired: true,
+ Fn: rcList,
+ Title: "List the given remote and path in JSON format",
+ Help: `This takes the following parameters:
+
+- fs - a remote name string e.g. "drive:"
+- remote - a path within that remote e.g. "dir"
+- opt - a dictionary of options to control the listing (optional)
+ - recurse - If set recurse directories
+ - noModTime - If set return modification time
+ - showEncrypted - If set show decrypted names
+ - showOrigIDs - If set show the IDs for each item if known
+ - showHash - If set return a dictionary of hashes
+ - 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:
+
+- list
+ - This is an array of objects as described in the lsjson command
+
+See the [lsjson](/commands/rclone_lsjson/) command for more information on the above and examples.
+`,
+ })
+}
+
+// List the directory
+func rcList(ctx context.Context, in rc.Params) (out rc.Params, err error) {
+ f, remote, err := rc.GetFsAndRemote(ctx, in)
+ if err != nil {
+ return nil, err
+ }
+ var opt ListJSONOpt
+ err = in.GetStruct("opt", &opt)
+ if rc.NotErrParamNotFound(err) {
+ return nil, err
+ }
+ list := []*ListJSONItem{}
+ err = ListJSON(ctx, f, remote, &opt, func(item *ListJSONItem) error {
+ list = append(list, item)
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ out = make(rc.Params)
+ out["list"] = list
+ return out, nil
+}
+
+func init() {
+ rc.Add(rc.Call{
+ Path: "operations/stat",
+ AuthRequired: true,
+ Fn: rcStat,
+ Title: "Give information about the supplied file or directory",
+ Help: `This takes the following parameters
+
+- fs - a remote name string eg "drive:"
+- remote - a path within that remote eg "dir"
+- opt - a dictionary of options to control the listing (optional)
+ - see operations/list for the options
+
+The result is
+
+- item - an object as described in the lsjson command. Will be null if not found.
+
+Note that if you are only interested in files then it is much more
+efficient to set the filesOnly flag in the options.
+
+See the [lsjson](/commands/rclone_lsjson/) command for more information on the above and examples.
+`,
+ })
+}
+
+// List the directory
+func rcStat(ctx context.Context, in rc.Params) (out rc.Params, err error) {
+ f, remote, err := rc.GetFsAndRemote(ctx, in)
+ if err != nil {
+ return nil, err
+ }
+ var opt ListJSONOpt
+ err = in.GetStruct("opt", &opt)
+ if rc.NotErrParamNotFound(err) {
+ return nil, err
+ }
+ item, err := StatJSON(ctx, f, remote, &opt)
+ if err != nil {
+ return nil, err
+ }
+ out = make(rc.Params)
+ out["item"] = item
+ return out, nil
+}
+
+func init() {
+ rc.Add(rc.Call{
+ Path: "operations/about",
+ AuthRequired: true,
+ Fn: rcAbout,
+ Title: "Return the space used on the remote",
+ Help: `This takes the following parameters:
+
+- fs - a remote name string e.g. "drive:"
+
+The result is as returned from rclone about --json
+
+See the [about](/commands/rclone_about/) command for more information on the above.
+`,
+ })
+}
+
+// About the remote
+func rcAbout(ctx context.Context, in rc.Params) (out rc.Params, err error) {
+ f, err := rc.GetFs(ctx, in)
+ if err != nil {
+ return nil, err
+ }
+ doAbout := f.Features().About
+ if doAbout == nil {
+ return nil, fmt.Errorf("%v doesn't support about", f)
+ }
+ u, err := doAbout(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("about call failed: %w", err)
+ }
+ err = rc.Reshape(&out, u)
+ if err != nil {
+ return nil, fmt.Errorf("about Reshape failed: %w", err)
+ }
+ return out, nil
+}
+
+func init() {
+ for _, copy := range []bool{false, true} {
+ name := "Move"
+ if copy {
+ name = "Copy"
+ }
+ rc.Add(rc.Call{
+ Path: "operations/" + strings.ToLower(name) + "file",
+ AuthRequired: true,
+ Fn: func(ctx context.Context, in rc.Params) (rc.Params, error) {
+ return rcMoveOrCopyFile(ctx, in, copy)
+ },
+ Title: name + " a file from source remote to destination remote",
+ Help: `This takes the following parameters:
+
+- srcFs - a remote name string e.g. "drive:" for the source, "/" for local filesystem
+- srcRemote - a path within that remote e.g. "file.txt" for the source
+- dstFs - a remote name string e.g. "drive2:" for the destination, "/" for local filesystem
+- dstRemote - a path within that remote e.g. "file2.txt" for the destination
+`,
+ })
+ }
+}
+
+// Copy a file
+func rcMoveOrCopyFile(ctx context.Context, in rc.Params, cp bool) (out rc.Params, err error) {
+ srcFs, srcRemote, err := rc.GetFsAndRemoteNamed(ctx, in, "srcFs", "srcRemote")
+ if err != nil {
+ return nil, err
+ }
+ dstFs, dstRemote, err := rc.GetFsAndRemoteNamed(ctx, in, "dstFs", "dstRemote")
+ if err != nil {
+ return nil, err
+ }
+ return nil, moveOrCopyFile(ctx, dstFs, srcFs, dstRemote, srcRemote, cp, false)
+}
+
+func init() {
+ for _, op := range []struct {
+ name string
+ title string
+ help string
+ noRemote bool
+ needsRequest bool
+ noCommand bool
+ }{
+ {name: "mkdir", title: "Make a destination directory or container"},
+ {name: "rmdir", title: "Remove an empty directory or container"},
+ {name: "purge", title: "Remove a directory or container and all of its contents"},
+ {name: "rmdirs", title: "Remove all the empty directories in the path", help: "- leaveRoot - boolean, set to true not to delete the root\n"},
+ {name: "delete", title: "Remove files in the path", noRemote: true},
+ {name: "deletefile", title: "Remove the single file pointed to"},
+ {name: "copyurl", title: "Copy the URL to the object", help: "- url - string, URL to read from\n - autoFilename - boolean, set to true to retrieve destination file name from url\n"},
+ {name: "uploadfile", title: "Upload file using multiform/form-data", help: "- each part in body represents a file to be uploaded\n", needsRequest: true, noCommand: true},
+ {name: "cleanup", title: "Remove trashed files in the remote or path", noRemote: true},
+ {name: "settier", title: "Changes storage tier or class on all files in the path", noRemote: true},
+ {name: "settierfile", title: "Changes storage tier or class on the single file pointed to", noCommand: true},
+ } {
+ var remote, command string
+ if !op.noRemote {
+ remote = "- remote - a path within that remote e.g. \"dir\"\n"
+ }
+ if !op.noCommand {
+ command = "See the [" + op.name + "](/commands/rclone_" + op.name + "/) command for more information on the above.\n"
+ }
+ rc.Add(rc.Call{
+ Path: "operations/" + op.name,
+ AuthRequired: true,
+ NeedsRequest: op.needsRequest,
+ Fn: func(ctx context.Context, in rc.Params) (rc.Params, error) {
+ return rcSingleCommand(ctx, in, op.name, op.noRemote)
+ },
+ Title: op.title,
+ Help: `This takes the following parameters:
+
+- fs - a remote name string e.g. "drive:"
+` + remote + op.help + "\n" + command,
+ })
+ }
+}
+
+// Run a single command, e.g. Mkdir
+func rcSingleCommand(ctx context.Context, in rc.Params, name string, noRemote bool) (out rc.Params, err error) {
+ var (
+ f fs.Fs
+ remote string
+ )
+ if noRemote {
+ f, err = rc.GetFs(ctx, in)
+ } else {
+ f, remote, err = rc.GetFsAndRemote(ctx, in)
+ }
+ if err != nil {
+ return nil, err
+ }
+ switch name {
+ case "mkdir":
+ return nil, Mkdir(ctx, f, remote)
+ case "rmdir":
+ return nil, Rmdir(ctx, f, remote)
+ case "purge":
+ return nil, Purge(ctx, f, remote)
+ case "rmdirs":
+ leaveRoot, err := in.GetBool("leaveRoot")
+ if rc.NotErrParamNotFound(err) {
+ return nil, err
+ }
+ return nil, Rmdirs(ctx, f, remote, leaveRoot)
+ case "delete":
+ return nil, Delete(ctx, f)
+ case "deletefile":
+ o, err := f.NewObject(ctx, remote)
+ if err != nil {
+ return nil, err
+ }
+ return nil, DeleteFile(ctx, o)
+ case "copyurl":
+ url, err := in.GetString("url")
+ if err != nil {
+ return nil, err
+ }
+ autoFilename, _ := in.GetBool("autoFilename")
+ noClobber, _ := in.GetBool("noClobber")
+ headerFilename, _ := in.GetBool("headerFilename")
+
+ _, err = CopyURL(ctx, f, remote, url, autoFilename, headerFilename, noClobber)
+ return nil, err
+ case "uploadfile":
+
+ var request *http.Request
+ request, err := in.GetHTTPRequest()
+ if err != nil {
+ return nil, err
+ }
+
+ contentType := request.Header.Get("Content-Type")
+ mediaType, params, err := mime.ParseMediaType(contentType)
+ if err != nil {
+ return nil, err
+ }
+
+ if strings.HasPrefix(mediaType, "multipart/") {
+ mr := multipart.NewReader(request.Body, params["boundary"])
+ for {
+ p, err := mr.NextPart()
+ if err == io.EOF {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ if p.FileName() != "" {
+ obj, err := Rcat(ctx, f, path.Join(remote, p.FileName()), p, time.Now(), nil)
+ if err != nil {
+ return nil, err
+ }
+ fs.Debugf(obj, "Upload Succeeded")
+ }
+ }
+ }
+ return nil, nil
+ case "cleanup":
+ return nil, CleanUp(ctx, f)
+ case "settier":
+ if !f.Features().SetTier {
+ return nil, fmt.Errorf("remote %s does not support settier", f.Name())
+ }
+ tier, err := in.GetString("tier")
+ if err != nil {
+ return nil, err
+ }
+ return nil, SetTier(ctx, f, tier)
+ case "settierfile":
+ if !f.Features().SetTier {
+ return nil, fmt.Errorf("remote %s does not support settier", f.Name())
+ }
+ tier, err := in.GetString("tier")
+ if err != nil {
+ return nil, err
+ }
+ o, err := f.NewObject(ctx, remote)
+ if err != nil {
+ return nil, err
+ }
+ return nil, SetTierFile(ctx, o, tier)
+ }
+ panic("unknown rcSingleCommand type")
+}
+
+func init() {
+ rc.Add(rc.Call{
+ Path: "operations/size",
+ AuthRequired: true,
+ Fn: rcSize,
+ Title: "Count the number of bytes and files in remote",
+ Help: `This takes the following parameters:
+
+- fs - a remote name string e.g. "drive:path/to/dir"
+
+Returns:
+
+- count - number of files
+- bytes - number of bytes in those files
+
+See the [size](/commands/rclone_size/) command for more information on the above.
+`,
+ })
+}
+
+// Size a directory
+func rcSize(ctx context.Context, in rc.Params) (out rc.Params, err error) {
+ f, err := rc.GetFs(ctx, in)
+ if err != nil {
+ return nil, err
+ }
+ count, bytes, sizeless, err := Count(ctx, f)
+ if err != nil {
+ return nil, err
+ }
+ out = make(rc.Params)
+ out["count"] = count
+ out["bytes"] = bytes
+ out["sizeless"] = sizeless
+ return out, nil
+}
+
+func init() {
+ rc.Add(rc.Call{
+ Path: "operations/publiclink",
+ AuthRequired: true,
+ Fn: rcPublicLink,
+ Title: "Create or retrieve a public link to the given file or folder.",
+ Help: `This takes the following parameters:
+
+- fs - a remote name string e.g. "drive:"
+- remote - a path within that remote e.g. "dir"
+- unlink - boolean - if set removes the link rather than adding it (optional)
+- expire - string - the expiry time of the link e.g. "1d" (optional)
+
+Returns:
+
+- url - URL of the resource
+
+See the [link](/commands/rclone_link/) command for more information on the above.
+`,
+ })
+}
+
+// Make a public link
+func rcPublicLink(ctx context.Context, in rc.Params) (out rc.Params, err error) {
+ f, remote, err := rc.GetFsAndRemote(ctx, in)
+ if err != nil {
+ return nil, err
+ }
+ unlink, _ := in.GetBool("unlink")
+ expire, err := in.GetDuration("expire")
+ if rc.IsErrParamNotFound(err) {
+ expire = time.Duration(fs.DurationOff)
+ } else if err != nil {
+ return nil, err
+ }
+ url, err := PublicLink(ctx, f, remote, fs.Duration(expire), unlink)
+ if err != nil {
+ return nil, err
+ }
+ out = make(rc.Params)
+ out["url"] = url
+ return out, nil
+}
+
+func init() {
+ rc.Add(rc.Call{
+ Path: "operations/fsinfo",
+ Fn: rcFsInfo,
+ Title: "Return information about the remote",
+ Help: `This takes the following parameters:
+
+- fs - a remote name string e.g. "drive:"
+
+This returns info about the remote passed in;
+
+` + "```" + `
+{
+ // 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"
+ }
+}
+` + "```" + `
+
+This command does not have a command line equivalent so use this instead:
+
+ rclone rc --loopback operations/fsinfo fs=remote:
+
+`,
+ })
+}
+
+// Fsinfo the remote
+func rcFsInfo(ctx context.Context, in rc.Params) (out rc.Params, err error) {
+ f, err := rc.GetFs(ctx, in)
+ if err != nil {
+ return nil, err
+ }
+ info := GetFsInfo(f)
+ err = rc.Reshape(&out, info)
+ if err != nil {
+ return nil, fmt.Errorf("fsinfo Reshape failed: %w", err)
+ }
+ return out, nil
+}
+
+func init() {
+ rc.Add(rc.Call{
+ Path: "backend/command",
+ AuthRequired: true,
+ Fn: rcBackend,
+ Title: "Runs a backend command.",
+ Help: `This takes the following parameters:
+
+- command - a string with the command name
+- fs - a remote name string e.g. "drive:"
+- arg - a list of arguments for the backend command
+- opt - a map of string to string of options
+
+Returns:
+
+- result - result from the backend command
+
+Example:
+
+ rclone rc backend/command command=noop fs=. -o echo=yes -o blue -a path1 -a path2
+
+Returns
+
+` + "```" + `
+{
+ "result": {
+ "arg": [
+ "path1",
+ "path2"
+ ],
+ "name": "noop",
+ "opt": {
+ "blue": "",
+ "echo": "yes"
+ }
+ }
+}
+` + "```" + `
+
+Note that this is the direct equivalent of using this "backend"
+command:
+
+ rclone backend noop . -o echo=yes -o blue path1 path2
+
+Note that arguments must be preceded by the "-a" flag
+
+See the [backend](/commands/rclone_backend/) command for more information.
+`,
+ })
+}
+
+// Make a public link
+func rcBackend(ctx context.Context, in rc.Params) (out rc.Params, err error) {
+ f, err := rc.GetFs(ctx, in)
+ if err != nil {
+ return nil, err
+ }
+ doCommand := f.Features().Command
+ if doCommand == nil {
+ return nil, fmt.Errorf("%v: doesn't support backend commands", f)
+ }
+ command, err := in.GetString("command")
+ if err != nil {
+ return nil, err
+ }
+ opt := map[string]string{}
+ err = in.GetStructMissingOK("opt", &opt)
+ if err != nil {
+ return nil, err
+ }
+ arg := []string{}
+ err = in.GetStructMissingOK("arg", &arg)
+ if err != nil {
+ return nil, err
+ }
+ result, err := doCommand(ctx, command, arg, opt)
+ if err != nil {
+ return nil, fmt.Errorf("command %q failed: %w", command, err)
+ }
+ out = make(rc.Params)
+ out["result"] = result
+ return out, nil
+}
+
+// This should really be in fs/rc/internal.go but can't go there due
+// to a circular dependency on config.
+func init() {
+ rc.Add(rc.Call{
+ Path: "core/du",
+ Fn: rcDu,
+ Title: "Returns disk usage of a locally attached disk.",
+ Help: `
+This returns the disk usage for the local directory passed in as dir.
+
+If the directory is not passed in, it defaults to the directory
+pointed to by --cache-dir.
+
+- dir - string (optional)
+
+Returns:
+
+` + "```" + `
+{
+ "dir": "/",
+ "info": {
+ "Available": 361769115648,
+ "Free": 361785892864,
+ "Total": 982141468672
+ }
+}
+` + "```" + `
+`,
+ })
+}
+
+// Terminates app
+func rcDu(ctx context.Context, in rc.Params) (out rc.Params, err error) {
+ dir, err := in.GetString("dir")
+ if rc.IsErrParamNotFound(err) {
+ dir = config.GetCacheDir()
+ } else if err != nil {
+ return nil, err
+ }
+ info, err := diskusage.New(dir)
+ if err != nil {
+ return nil, err
+ }
+ out = rc.Params{
+ "dir": dir,
+ "info": info,
+ }
+ return out, nil
+}
+
+func init() {
+ rc.Add(rc.Call{
+ Path: "operations/check",
+ AuthRequired: true,
+ Fn: rcCheck,
+ Title: "check the source and destination are the same",
+ Help: `Checks the files in the source and destination match. It compares
+sizes and hashes and logs a report of files that don't
+match. It doesn't alter the source or destination.
+
+This takes the following parameters:
+
+- srcFs - a remote name string e.g. "drive:" for the source, "/" for local filesystem
+- dstFs - a remote name string e.g. "drive2:" for the destination, "/" for local filesystem
+- download - check by downloading rather than with hash
+- checkFileHash - treat checkFileFs:checkFileRemote as a SUM file with hashes of given type
+- checkFileFs - treat checkFileFs:checkFileRemote as a SUM file with hashes of given type
+- checkFileRemote - treat checkFileFs:checkFileRemote as a SUM file with hashes of given type
+- oneWay - check one way only, source files must exist on remote
+- combined - make a combined report of changes (default false)
+- missingOnSrc - report all files missing from the source (default true)
+- missingOnDst - report all files missing from the destination (default true)
+- match - report all matching files (default false)
+- differ - report all non-matching files (default true)
+- error - report all files with errors (hashing or reading) (default true)
+
+If you supply the download flag, it will download the data from
+both remotes and check them against each other on the fly. This can
+be useful for remotes that don't support hashes or if you really want
+to check all the data.
+
+If you supply the size-only global flag, it will only compare the sizes not
+the hashes as well. Use this for a quick check.
+
+If you supply the checkFileHash option with a valid hash name, the
+checkFileFs:checkFileRemote must point to a text file in the SUM
+format. This treats the checksum file as the source and dstFs as the
+destination. Note that srcFs is not used and should not be supplied in
+this case.
+
+Returns:
+
+- success - true if no error, false otherwise
+- status - textual summary of check, OK or text string
+- hashType - hash used in check, may be missing
+- combined - array of strings of combined report of changes
+- missingOnSrc - array of strings of all files missing from the source
+- missingOnDst - array of strings of all files missing from the destination
+- match - array of strings of all matching files
+- differ - array of strings of all non-matching files
+- error - array of strings of all files with errors (hashing or reading)
+
+`,
+ })
+}
+
+// Writer which writes into the slice provided
+type stringWriter struct {
+ out *[]string
+}
+
+// Write writes len(p) bytes from p to the underlying data stream. It returns
+// the number of bytes written from p (0 <= n <= len(p)) and any error
+// encountered that caused the write to stop early. Write must return a non-nil
+// error if it returns n < len(p). Write must not modify the slice data,
+// even temporarily.
+//
+// Implementations must not retain p.
+func (s stringWriter) Write(p []byte) (n int, err error) {
+ result := string(p)
+ result = strings.TrimSuffix(result, "\n")
+ *s.out = append(*s.out, result)
+ return len(p), nil
+}
+
+// Check two directories
+func rcCheck(ctx context.Context, in rc.Params) (out rc.Params, err error) {
+ srcFs, err := rc.GetFsNamed(ctx, in, "srcFs")
+ if err != nil && !rc.IsErrParamNotFound(err) {
+ return nil, err
+ }
+
+ dstFs, err := rc.GetFsNamed(ctx, in, "dstFs")
+ if err != nil {
+ return nil, err
+ }
+
+ checkFileFs, checkFileRemote, err := rc.GetFsAndRemoteNamed(ctx, in, "checkFileFs", "checkFileRemote")
+ if err != nil && !rc.IsErrParamNotFound(err) {
+ return nil, err
+ }
+
+ checkFileHash, err := in.GetString("checkFileHash")
+ if err != nil && !rc.IsErrParamNotFound(err) {
+ return nil, err
+ }
+
+ checkFileSet := 0
+ if checkFileHash != "" {
+ checkFileSet++
+ }
+ if checkFileFs != nil {
+ checkFileSet++
+ }
+ if checkFileRemote != "" {
+ checkFileSet++
+ }
+ if checkFileSet > 0 && checkFileSet < 3 {
+ return nil, fmt.Errorf("need all of checkFileFs, checkFileRemote, checkFileHash to be set together")
+ }
+
+ var checkFileHashType hash.Type
+ if checkFileHash != "" {
+ if err := checkFileHashType.Set(checkFileHash); err != nil {
+ return nil, err
+ }
+ if srcFs != nil {
+ return nil, rc.NewErrParamInvalid(errors.New("only supply dstFs when using checkFileHash"))
+ }
+ } else if srcFs == nil {
+ return nil, rc.NewErrParamInvalid(errors.New("need srcFs parameter when not using checkFileHash"))
+ }
+
+ oneway, _ := in.GetBool("oneWay")
+ download, _ := in.GetBool("download")
+
+ opt := &CheckOpt{
+ Fsrc: srcFs,
+ Fdst: dstFs,
+ OneWay: oneway,
+ }
+
+ out = rc.Params{}
+
+ getOutput := func(name string, Default bool) io.Writer {
+ active, err := in.GetBool(name)
+ if err != nil {
+ active = Default
+ }
+ if !active {
+ return nil
+ }
+ result := []string{}
+ out[name] = &result
+ return stringWriter{&result}
+ }
+
+ opt.Combined = getOutput("combined", false)
+ opt.MissingOnSrc = getOutput("missingOnSrc", true)
+ opt.MissingOnDst = getOutput("missingOnDst", true)
+ opt.Match = getOutput("match", false)
+ opt.Differ = getOutput("differ", true)
+ opt.Error = getOutput("error", true)
+
+ if checkFileHash != "" {
+ out["hashType"] = checkFileHashType.String()
+ err = CheckSum(ctx, dstFs, checkFileFs, checkFileRemote, checkFileHashType, opt, download)
+ } else {
+ if download {
+ err = CheckDownload(ctx, opt)
+ } else {
+ out["hashType"] = srcFs.Hashes().Overlap(dstFs.Hashes()).GetOne().String()
+ err = Check(ctx, opt)
+ }
+ }
+ if err != nil {
+ out["status"] = err.Error()
+ out["success"] = false
+ } else {
+ out["status"] = "OK"
+ out["success"] = true
+ }
+ return out, nil
+}
+
+func init() {
+ rc.Add(rc.Call{
+ Path: "operations/hashsum",
+ AuthRequired: true,
+ Fn: rcHashsum,
+ Title: "Produces a hashsum file for all the objects in the path.",
+ Help: `Produces a hash file for all the objects in the path using the hash
+named. The output is in the same format as the standard
+md5sum/sha1sum tool.
+
+This takes the following parameters:
+
+- fs - a remote name string e.g. "drive:" for the source, "/" for local filesystem
+ - this can point to a file and just that file will be returned in the listing.
+- hashType - type of hash to be used
+- download - check by downloading rather than with hash (boolean)
+- base64 - output the hashes in base64 rather than hex (boolean)
+
+If you supply the download flag, it will download the data from the
+remote and create the hash on the fly. This can be useful for remotes
+that don't support the given hash or if you really want to check all
+the data.
+
+Note that if you wish to supply a checkfile to check hashes against
+the current files then you should use operations/check instead of
+operations/hashsum.
+
+Returns:
+
+- hashsum - array of strings of the hashes
+- hashType - type of hash used
+
+Example:
+
+ $ rclone rc --loopback operations/hashsum fs=bin hashType=MD5 download=true base64=true
+ {
+ "hashType": "md5",
+ "hashsum": [
+ "WTSVLpuiXyJO_kGzJerRLg== backend-versions.sh",
+ "v1b_OlWCJO9LtNq3EIKkNQ== bisect-go-rclone.sh",
+ "VHbmHzHh4taXzgag8BAIKQ== bisect-rclone.sh",
+ ]
+ }
+
+See the [hashsum](/commands/rclone_hashsum/) command for more information on the above.
+`,
+ })
+}
+
+// Parse download, base64 and hashType parameters
+func parseHashParameters(in rc.Params) (download bool, base64 bool, ht hash.Type, err error) {
+ download, _ = in.GetBool("download")
+ base64, _ = in.GetBool("base64")
+ hashType, err := in.GetString("hashType")
+ if err != nil {
+ return
+ }
+ err = ht.Set(hashType)
+ return
+}
+
+// Hashsum a directory
+func rcHashsum(ctx context.Context, in rc.Params) (out rc.Params, err error) {
+ ctx, f, err := rc.GetFsNamedFileOK(ctx, in, "fs")
+ if err != nil {
+ return nil, err
+ }
+
+ download, base64, ht, err := parseHashParameters(in)
+ if err != nil {
+ return out, err
+ }
+
+ hashes := []string{}
+ err = HashLister(ctx, ht, base64, download, f, stringWriter{&hashes})
+ out = rc.Params{
+ "hashType": ht.String(),
+ "hashsum": hashes,
+ }
+ return out, err
+}
+
+func init() {
+ rc.Add(rc.Call{
+ Path: "operations/hashsumfile",
+ AuthRequired: true,
+ Fn: rcHashsumFile,
+ Title: "Produces a hash for a single file.",
+ Help: `Produces a hash for a single file using the hash named.
+
+This takes the following parameters:
+
+- fs - a remote name string e.g. "drive:"
+- remote - a path within that remote e.g. "file.txt"
+- hashType - type of hash to be used
+- download - check by downloading rather than with hash (boolean)
+- base64 - output the hashes in base64 rather than hex (boolean)
+
+If you supply the download flag, it will download the data from the
+remote and create the hash on the fly. This can be useful for remotes
+that don't support the given hash or if you really want to read all
+the data.
+
+Returns:
+
+- hash - hash for the file
+- hashType - type of hash used
+
+Example:
+
+ $ rclone rc --loopback operations/hashsumfile fs=/ remote=/bin/bash hashType=MD5 download=true base64=true
+ {
+ "hashType": "md5",
+ "hash": "MDMw-fG2YXs7Uz5Nz-H68A=="
+ }
+
+See the [hashsum](/commands/rclone_hashsum/) command for more information on the above.
+`,
+ })
+}
+
+// Hashsum a file
+func rcHashsumFile(ctx context.Context, in rc.Params) (out rc.Params, err error) {
+ f, remote, err := rc.GetFsAndRemote(ctx, in)
+ if err != nil {
+ return nil, err
+ }
+ download, base64, ht, err := parseHashParameters(in)
+ if err != nil {
+ return out, err
+ }
+ o, err := f.NewObject(ctx, remote)
+ if err != nil {
+ return nil, err
+ }
+ sum, err := HashSum(ctx, ht, base64, download, o)
+ out = rc.Params{
+ "hashType": ht.String(),
+ "hash": sum,
+ }
+ return out, err
+}
diff --git a/fs/operations/rc_test.go b/fs/operations/rc_test.go
new file mode 100644
index 0000000..04357fb
--- /dev/null
+++ b/fs/operations/rc_test.go
@@ -0,0 +1,892 @@
+package operations_test
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path"
+ "sort"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/fs/cache"
+ "github.com/rclone/rclone/fs/hash"
+ "github.com/rclone/rclone/fs/operations"
+ "github.com/rclone/rclone/fs/rc"
+ "github.com/rclone/rclone/fstest"
+ "github.com/rclone/rclone/lib/diskusage"
+ "github.com/rclone/rclone/lib/rest"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func rcNewRun(t *testing.T, method string) (*fstest.Run, *rc.Call) {
+ if *fstest.RemoteName != "" {
+ t.Skip("Skipping test on non local remote")
+ }
+ r := fstest.NewRun(t)
+ call := rc.Calls.Get(method)
+ assert.NotNil(t, call)
+ cache.Put(r.LocalName, r.Flocal)
+ cache.Put(r.FremoteName, r.Fremote)
+ return r, call
+}
+
+// operations/about: Return the space used on the remote
+func TestRcAbout(t *testing.T) {
+ r, call := rcNewRun(t, "operations/about")
+ r.Mkdir(context.Background(), r.Fremote)
+
+ // Will get an error if remote doesn't support About
+ expectedErr := r.Fremote.Features().About == nil
+
+ in := rc.Params{
+ "fs": r.FremoteName,
+ }
+ out, err := call.Fn(context.Background(), in)
+ if expectedErr {
+ assert.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+
+ // Can't really check the output much!
+ assert.NotEqual(t, int64(0), out["Total"])
+}
+
+// operations/cleanup: Remove trashed files in the remote or path
+func TestRcCleanup(t *testing.T) {
+ r, call := rcNewRun(t, "operations/cleanup")
+
+ in := rc.Params{
+ "fs": r.LocalName,
+ }
+ out, err := call.Fn(context.Background(), in)
+ require.Error(t, err)
+ assert.Equal(t, rc.Params(nil), out)
+ assert.Contains(t, err.Error(), "doesn't support cleanup")
+}
+
+// operations/copyfile: Copy a file from source remote to destination remote
+func TestRcCopyfile(t *testing.T) {
+ r, call := rcNewRun(t, "operations/copyfile")
+ file1 := r.WriteFile("file1", "file1 contents", t1)
+ r.Mkdir(context.Background(), r.Fremote)
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t)
+
+ in := rc.Params{
+ "srcFs": r.LocalName,
+ "srcRemote": "file1",
+ "dstFs": r.FremoteName,
+ "dstRemote": "file1-renamed",
+ }
+ out, err := call.Fn(context.Background(), in)
+ require.NoError(t, err)
+ assert.Equal(t, rc.Params(nil), out)
+
+ r.CheckLocalItems(t, file1)
+ file1.Path = "file1-renamed"
+ r.CheckRemoteItems(t, file1)
+}
+
+// operations/copyurl: Copy the URL to the object
+func TestRcCopyurl(t *testing.T) {
+ r, call := rcNewRun(t, "operations/copyurl")
+ contents := "file1 contents\n"
+ file1 := r.WriteFile("file1", contents, t1)
+ r.Mkdir(context.Background(), r.Fremote)
+ r.CheckRemoteItems(t)
+
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, err := w.Write([]byte(contents))
+ assert.NoError(t, err)
+ }))
+ defer ts.Close()
+
+ in := rc.Params{
+ "fs": r.FremoteName,
+ "remote": "file1",
+ "url": ts.URL,
+ "autoFilename": false,
+ "noClobber": false,
+ }
+ out, err := call.Fn(context.Background(), in)
+ require.NoError(t, err)
+ assert.Equal(t, rc.Params(nil), out)
+
+ in = rc.Params{
+ "fs": r.FremoteName,
+ "remote": "file1",
+ "url": ts.URL,
+ "autoFilename": false,
+ "noClobber": true,
+ }
+ out, err = call.Fn(context.Background(), in)
+ require.Error(t, err)
+ assert.Equal(t, rc.Params(nil), out)
+
+ urlFileName := "filename.txt"
+ in = rc.Params{
+ "fs": r.FremoteName,
+ "remote": "",
+ "url": ts.URL + "/" + urlFileName,
+ "autoFilename": true,
+ "noClobber": false,
+ }
+ out, err = call.Fn(context.Background(), in)
+ require.NoError(t, err)
+ assert.Equal(t, rc.Params(nil), out)
+
+ in = rc.Params{
+ "fs": r.FremoteName,
+ "remote": "",
+ "url": ts.URL,
+ "autoFilename": true,
+ "noClobber": false,
+ }
+ out, err = call.Fn(context.Background(), in)
+ require.Error(t, err)
+ assert.Equal(t, rc.Params(nil), out)
+
+ fstest.CheckListingWithPrecision(t, r.Fremote, []fstest.Item{file1, fstest.NewItem(urlFileName, contents, t1)}, nil, fs.ModTimeNotSupported)
+}
+
+// operations/delete: Remove files in the path
+func TestRcDelete(t *testing.T) {
+ r, call := rcNewRun(t, "operations/delete")
+
+ file1 := r.WriteObject(context.Background(), "small", "1234567890", t2) // 10 bytes
+ file2 := r.WriteObject(context.Background(), "medium", "------------------------------------------------------------", t1) // 60 bytes
+ file3 := r.WriteObject(context.Background(), "large", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", t1) // 100 bytes
+ r.CheckRemoteItems(t, file1, file2, file3)
+
+ in := rc.Params{
+ "fs": r.FremoteName,
+ }
+ out, err := call.Fn(context.Background(), in)
+ require.NoError(t, err)
+ assert.Equal(t, rc.Params(nil), out)
+
+ r.CheckRemoteItems(t)
+}
+
+// operations/deletefile: Remove the single file pointed to
+func TestRcDeletefile(t *testing.T) {
+ r, call := rcNewRun(t, "operations/deletefile")
+
+ file1 := r.WriteObject(context.Background(), "small", "1234567890", t2) // 10 bytes
+ file2 := r.WriteObject(context.Background(), "medium", "------------------------------------------------------------", t1) // 60 bytes
+ r.CheckRemoteItems(t, file1, file2)
+
+ in := rc.Params{
+ "fs": r.FremoteName,
+ "remote": "small",
+ }
+ out, err := call.Fn(context.Background(), in)
+ require.NoError(t, err)
+ assert.Equal(t, rc.Params(nil), out)
+
+ r.CheckRemoteItems(t, file2)
+}
+
+// operations/list: List the given remote and path in JSON format.
+func TestRcList(t *testing.T) {
+ r, call := rcNewRun(t, "operations/list")
+
+ file1 := r.WriteObject(context.Background(), "a", "a", t1)
+ file2 := r.WriteObject(context.Background(), "subdir/b", "bb", t2)
+
+ r.CheckRemoteItems(t, file1, file2)
+
+ in := rc.Params{
+ "fs": r.FremoteName,
+ "remote": "",
+ }
+ out, err := call.Fn(context.Background(), in)
+ require.NoError(t, err)
+
+ list := out["list"].([]*operations.ListJSONItem)
+ assert.Equal(t, 2, len(list))
+
+ checkFile1 := func(got *operations.ListJSONItem) {
+ assert.WithinDuration(t, t1, got.ModTime.When, time.Second)
+ assert.Equal(t, "a", got.Path)
+ assert.Equal(t, "a", got.Name)
+ assert.Equal(t, int64(1), got.Size)
+ assert.Equal(t, "application/octet-stream", got.MimeType)
+ assert.Equal(t, false, got.IsDir)
+ }
+ checkFile1(list[0])
+
+ checkSubdir := func(got *operations.ListJSONItem) {
+ assert.Equal(t, "subdir", got.Path)
+ assert.Equal(t, "subdir", got.Name)
+ // assert.Equal(t, int64(-1), got.Size) // size can vary for directories
+ assert.Equal(t, "inode/directory", got.MimeType)
+ assert.Equal(t, true, got.IsDir)
+ }
+ checkSubdir(list[1])
+
+ in = rc.Params{
+ "fs": r.FremoteName,
+ "remote": "",
+ "opt": rc.Params{
+ "recurse": true,
+ },
+ }
+ out, err = call.Fn(context.Background(), in)
+ require.NoError(t, err)
+
+ list = out["list"].([]*operations.ListJSONItem)
+ assert.Equal(t, 3, len(list))
+ checkFile1(list[0])
+ checkSubdir(list[1])
+
+ checkFile2 := func(got *operations.ListJSONItem) {
+ assert.WithinDuration(t, t2, got.ModTime.When, time.Second)
+ assert.Equal(t, "subdir/b", got.Path)
+ assert.Equal(t, "b", got.Name)
+ assert.Equal(t, int64(2), got.Size)
+ assert.Equal(t, "application/octet-stream", got.MimeType)
+ assert.Equal(t, false, got.IsDir)
+ }
+ checkFile2(list[2])
+}
+
+// operations/stat: Stat the given remote and path in JSON format.
+func TestRcStat(t *testing.T) {
+ r, call := rcNewRun(t, "operations/stat")
+
+ file1 := r.WriteObject(context.Background(), "subdir/a", "a", t1)
+
+ r.CheckRemoteItems(t, file1)
+
+ fetch := func(t *testing.T, remotePath string) *operations.ListJSONItem {
+ in := rc.Params{
+ "fs": r.FremoteName,
+ "remote": remotePath,
+ }
+ out, err := call.Fn(context.Background(), in)
+ require.NoError(t, err)
+ return out["item"].(*operations.ListJSONItem)
+ }
+
+ t.Run("Root", func(t *testing.T) {
+ stat := fetch(t, "")
+ assert.Equal(t, "", stat.Path)
+ assert.Equal(t, "", stat.Name)
+ assert.Equal(t, int64(-1), stat.Size)
+ assert.Equal(t, "inode/directory", stat.MimeType)
+ assert.Equal(t, true, stat.IsDir)
+ })
+
+ t.Run("File", func(t *testing.T) {
+ stat := fetch(t, "subdir/a")
+ assert.WithinDuration(t, t1, stat.ModTime.When, time.Second)
+ assert.Equal(t, "subdir/a", stat.Path)
+ assert.Equal(t, "a", stat.Name)
+ assert.Equal(t, int64(1), stat.Size)
+ assert.Equal(t, "application/octet-stream", stat.MimeType)
+ assert.Equal(t, false, stat.IsDir)
+ })
+
+ t.Run("Subdir", func(t *testing.T) {
+ stat := fetch(t, "subdir")
+ assert.Equal(t, "subdir", stat.Path)
+ assert.Equal(t, "subdir", stat.Name)
+ // assert.Equal(t, int64(-1), stat.Size) // size can vary for directories
+ assert.Equal(t, "inode/directory", stat.MimeType)
+ assert.Equal(t, true, stat.IsDir)
+ })
+
+ t.Run("NotFound", func(t *testing.T) {
+ stat := fetch(t, "notfound")
+ assert.Nil(t, stat)
+ })
+}
+
+// operations/settier: Set the storage tier of a fs
+func TestRcSetTier(t *testing.T) {
+ ctx := context.Background()
+ r, call := rcNewRun(t, "operations/settier")
+ if !r.Fremote.Features().SetTier {
+ t.Skip("settier not supported")
+ }
+ file1 := r.WriteObject(context.Background(), "file1", "file1 contents", t1)
+ r.CheckRemoteItems(t, file1)
+
+ // Because we don't know what the current tier options here are, let's
+ // just get the current tier, and reuse that
+ o, err := r.Fremote.NewObject(ctx, file1.Path)
+ require.NoError(t, err)
+ trr, ok := o.(fs.GetTierer)
+ require.True(t, ok)
+ ctier := trr.GetTier()
+ in := rc.Params{
+ "fs": r.FremoteName,
+ "tier": ctier,
+ }
+ out, err := call.Fn(context.Background(), in)
+ require.NoError(t, err)
+ assert.Equal(t, rc.Params(nil), out)
+
+}
+
+// operations/settier: Set the storage tier of a file
+func TestRcSetTierFile(t *testing.T) {
+ ctx := context.Background()
+ r, call := rcNewRun(t, "operations/settierfile")
+ if !r.Fremote.Features().SetTier {
+ t.Skip("settier not supported")
+ }
+ file1 := r.WriteObject(context.Background(), "file1", "file1 contents", t1)
+ r.CheckRemoteItems(t, file1)
+
+ // Because we don't know what the current tier options here are, let's
+ // just get the current tier, and reuse that
+ o, err := r.Fremote.NewObject(ctx, file1.Path)
+ require.NoError(t, err)
+ trr, ok := o.(fs.GetTierer)
+ require.True(t, ok)
+ ctier := trr.GetTier()
+ in := rc.Params{
+ "fs": r.FremoteName,
+ "remote": "file1",
+ "tier": ctier,
+ }
+ out, err := call.Fn(context.Background(), in)
+ require.NoError(t, err)
+ assert.Equal(t, rc.Params(nil), out)
+
+}
+
+// operations/mkdir: Make a destination directory or container
+func TestRcMkdir(t *testing.T) {
+ ctx := context.Background()
+ r, call := rcNewRun(t, "operations/mkdir")
+ r.Mkdir(context.Background(), r.Fremote)
+
+ fstest.CheckListingWithPrecision(t, r.Fremote, []fstest.Item{}, []string{}, fs.GetModifyWindow(ctx, r.Fremote))
+
+ in := rc.Params{
+ "fs": r.FremoteName,
+ "remote": "subdir",
+ }
+ out, err := call.Fn(context.Background(), in)
+ require.NoError(t, err)
+ assert.Equal(t, rc.Params(nil), out)
+
+ fstest.CheckListingWithPrecision(t, r.Fremote, []fstest.Item{}, []string{"subdir"}, fs.GetModifyWindow(ctx, r.Fremote))
+}
+
+// operations/movefile: Move a file from source remote to destination remote
+func TestRcMovefile(t *testing.T) {
+ r, call := rcNewRun(t, "operations/movefile")
+ file1 := r.WriteFile("file1", "file1 contents", t1)
+ r.Mkdir(context.Background(), r.Fremote)
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t)
+
+ in := rc.Params{
+ "srcFs": r.LocalName,
+ "srcRemote": "file1",
+ "dstFs": r.FremoteName,
+ "dstRemote": "file1-renamed",
+ }
+ out, err := call.Fn(context.Background(), in)
+ require.NoError(t, err)
+ assert.Equal(t, rc.Params(nil), out)
+
+ r.CheckLocalItems(t)
+ file1.Path = "file1-renamed"
+ r.CheckRemoteItems(t, file1)
+}
+
+// operations/purge: Remove a directory or container and all of its contents
+func TestRcPurge(t *testing.T) {
+ ctx := context.Background()
+ r, call := rcNewRun(t, "operations/purge")
+ file1 := r.WriteObject(context.Background(), "subdir/file1", "subdir/file1 contents", t1)
+
+ fstest.CheckListingWithPrecision(t, r.Fremote, []fstest.Item{file1}, []string{"subdir"}, fs.GetModifyWindow(ctx, r.Fremote))
+
+ in := rc.Params{
+ "fs": r.FremoteName,
+ "remote": "subdir",
+ }
+ out, err := call.Fn(context.Background(), in)
+ require.NoError(t, err)
+ assert.Equal(t, rc.Params(nil), out)
+
+ fstest.CheckListingWithPrecision(t, r.Fremote, []fstest.Item{}, []string{}, fs.GetModifyWindow(ctx, r.Fremote))
+}
+
+// operations/rmdir: Remove an empty directory or container
+func TestRcRmdir(t *testing.T) {
+ ctx := context.Background()
+ r, call := rcNewRun(t, "operations/rmdir")
+ r.Mkdir(context.Background(), r.Fremote)
+ assert.NoError(t, r.Fremote.Mkdir(context.Background(), "subdir"))
+
+ fstest.CheckListingWithPrecision(t, r.Fremote, []fstest.Item{}, []string{"subdir"}, fs.GetModifyWindow(ctx, r.Fremote))
+
+ in := rc.Params{
+ "fs": r.FremoteName,
+ "remote": "subdir",
+ }
+ out, err := call.Fn(context.Background(), in)
+ require.NoError(t, err)
+ assert.Equal(t, rc.Params(nil), out)
+
+ fstest.CheckListingWithPrecision(t, r.Fremote, []fstest.Item{}, []string{}, fs.GetModifyWindow(ctx, r.Fremote))
+}
+
+// operations/rmdirs: Remove all the empty directories in the path
+func TestRcRmdirs(t *testing.T) {
+ ctx := context.Background()
+ r, call := rcNewRun(t, "operations/rmdirs")
+ r.Mkdir(context.Background(), r.Fremote)
+ assert.NoError(t, r.Fremote.Mkdir(context.Background(), "subdir"))
+ assert.NoError(t, r.Fremote.Mkdir(context.Background(), "subdir/subsubdir"))
+
+ fstest.CheckListingWithPrecision(t, r.Fremote, []fstest.Item{}, []string{"subdir", "subdir/subsubdir"}, fs.GetModifyWindow(ctx, r.Fremote))
+
+ in := rc.Params{
+ "fs": r.FremoteName,
+ "remote": "subdir",
+ }
+ out, err := call.Fn(context.Background(), in)
+ require.NoError(t, err)
+ assert.Equal(t, rc.Params(nil), out)
+
+ fstest.CheckListingWithPrecision(t, r.Fremote, []fstest.Item{}, []string{}, fs.GetModifyWindow(ctx, r.Fremote))
+
+ assert.NoError(t, r.Fremote.Mkdir(context.Background(), "subdir"))
+ assert.NoError(t, r.Fremote.Mkdir(context.Background(), "subdir/subsubdir"))
+
+ in = rc.Params{
+ "fs": r.FremoteName,
+ "remote": "subdir",
+ "leaveRoot": true,
+ }
+ out, err = call.Fn(context.Background(), in)
+ require.NoError(t, err)
+ assert.Equal(t, rc.Params(nil), out)
+
+ fstest.CheckListingWithPrecision(t, r.Fremote, []fstest.Item{}, []string{"subdir"}, fs.GetModifyWindow(ctx, r.Fremote))
+
+}
+
+// operations/size: Count the number of bytes and files in remote
+func TestRcSize(t *testing.T) {
+ r, call := rcNewRun(t, "operations/size")
+ file1 := r.WriteObject(context.Background(), "small", "1234567890", t2) // 10 bytes
+ file2 := r.WriteObject(context.Background(), "subdir/medium", "------------------------------------------------------------", t1) // 60 bytes
+ file3 := r.WriteObject(context.Background(), "subdir/subsubdir/large", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", t1) // 50 bytes
+ r.CheckRemoteItems(t, file1, file2, file3)
+
+ in := rc.Params{
+ "fs": r.FremoteName,
+ }
+ out, err := call.Fn(context.Background(), in)
+ require.NoError(t, err)
+ assert.Equal(t, rc.Params{
+ "count": int64(3),
+ "bytes": int64(120),
+ "sizeless": int64(0),
+ }, out)
+}
+
+// operations/publiclink: Create or retrieve a public link to the given file or folder.
+func TestRcPublicLink(t *testing.T) {
+ r, call := rcNewRun(t, "operations/publiclink")
+ in := rc.Params{
+ "fs": r.FremoteName,
+ "remote": "",
+ "expire": "5m",
+ "unlink": false,
+ }
+ _, err := call.Fn(context.Background(), in)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "doesn't support public links")
+}
+
+// operations/fsinfo: Return information about the remote
+func TestRcFsInfo(t *testing.T) {
+ r, call := rcNewRun(t, "operations/fsinfo")
+ in := rc.Params{
+ "fs": r.FremoteName,
+ }
+ got, err := call.Fn(context.Background(), in)
+ require.NoError(t, err)
+ want := operations.GetFsInfo(r.Fremote)
+ assert.Equal(t, want.Name, got["Name"])
+ assert.Equal(t, want.Root, got["Root"])
+ assert.Equal(t, want.String, got["String"])
+ assert.Equal(t, float64(want.Precision), got["Precision"])
+ var hashes []any
+ for _, hash := range want.Hashes {
+ hashes = append(hashes, hash)
+ }
+ assert.Equal(t, hashes, got["Hashes"])
+ var features = map[string]any{}
+ for k, v := range want.Features {
+ features[k] = v
+ }
+ assert.Equal(t, features, got["Features"])
+
+}
+
+// operations/uploadfile : Tests if upload file succeeds
+func TestUploadFile(t *testing.T) {
+ r, call := rcNewRun(t, "operations/uploadfile")
+ ctx := context.Background()
+
+ testFileName := "uploadfile-test.txt"
+ testFileContent := "Hello World"
+ r.WriteFile(testFileName, testFileContent, t1)
+ testItem1 := fstest.NewItem(testFileName, testFileContent, t1)
+ testItem2 := fstest.NewItem(path.Join("subdir", testFileName), testFileContent, t1)
+
+ currentFile, err := os.Open(path.Join(r.LocalName, testFileName))
+ require.NoError(t, err)
+
+ defer func() {
+ assert.NoError(t, currentFile.Close())
+ }()
+
+ formReader, contentType, _, err := rest.MultipartUpload(ctx, currentFile, url.Values{}, "file", testFileName, "application/octet-stream")
+ require.NoError(t, err)
+
+ httpReq := httptest.NewRequest("POST", "/", formReader)
+ httpReq.Header.Add("Content-Type", contentType)
+
+ in := rc.Params{
+ "_request": httpReq,
+ "fs": r.FremoteName,
+ "remote": "",
+ }
+
+ _, err = call.Fn(context.Background(), in)
+ require.NoError(t, err)
+
+ fstest.CheckListingWithPrecision(t, r.Fremote, []fstest.Item{testItem1}, nil, fs.ModTimeNotSupported)
+
+ assert.NoError(t, r.Fremote.Mkdir(context.Background(), "subdir"))
+
+ currentFile2, err := os.Open(path.Join(r.LocalName, testFileName))
+ require.NoError(t, err)
+
+ defer func() {
+ assert.NoError(t, currentFile2.Close())
+ }()
+
+ formReader, contentType, _, err = rest.MultipartUpload(ctx, currentFile2, url.Values{}, "file", testFileName, "application/octet-stream")
+ require.NoError(t, err)
+
+ httpReq = httptest.NewRequest("POST", "/", formReader)
+ httpReq.Header.Add("Content-Type", contentType)
+
+ in = rc.Params{
+ "_request": httpReq,
+ "fs": r.FremoteName,
+ "remote": "subdir",
+ }
+
+ _, err = call.Fn(context.Background(), in)
+ require.NoError(t, err)
+
+ fstest.CheckListingWithPrecision(t, r.Fremote, []fstest.Item{testItem1, testItem2}, nil, fs.ModTimeNotSupported)
+
+}
+
+// operations/command: Runs a backend command
+func TestRcCommand(t *testing.T) {
+ r, call := rcNewRun(t, "backend/command")
+ in := rc.Params{
+ "fs": r.FremoteName,
+ "command": "noop",
+ "opt": map[string]string{
+ "echo": "true",
+ "blue": "",
+ },
+ "arg": []string{
+ "path1",
+ "path2",
+ },
+ }
+ got, err := call.Fn(context.Background(), in)
+ if err != nil {
+ assert.False(t, r.Fremote.Features().IsLocal, "mustn't fail on local remote")
+ assert.Contains(t, err.Error(), "command not found")
+ return
+ }
+ want := rc.Params{"result": map[string]any{
+ "arg": []string{
+ "path1",
+ "path2",
+ },
+ "name": "noop",
+ "opt": map[string]string{
+ "blue": "",
+ "echo": "true",
+ },
+ }}
+ assert.Equal(t, want, got)
+ errTxt := "explosion in the sausage factory"
+ in["opt"].(map[string]string)["error"] = errTxt
+ _, err = call.Fn(context.Background(), in)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), errTxt)
+}
+
+// operations/command: Runs a backend command
+func TestRcDu(t *testing.T) {
+ ctx := context.Background()
+ _, call := rcNewRun(t, "core/du")
+ in := rc.Params{}
+ out, err := call.Fn(ctx, in)
+ if err == diskusage.ErrUnsupported {
+ t.Skip(err)
+ }
+ assert.NotEqual(t, "", out["dir"])
+ info := out["info"].(diskusage.Info)
+ assert.True(t, info.Total != 0)
+ assert.True(t, info.Total > info.Free)
+ assert.True(t, info.Total > info.Available)
+ assert.True(t, info.Free >= info.Available)
+}
+
+// operations/check: check the source and destination are the same
+func TestRcCheck(t *testing.T) {
+ ctx := context.Background()
+ r, call := rcNewRun(t, "operations/check")
+ r.Mkdir(ctx, r.Fremote)
+
+ MD5SUMS := `
+0ef726ce9b1a7692357ff70dd321d595 file1
+deadbeefcafe00000000000000000000 subdir/file2
+0386a8b8fcf672c326845c00ba41b9e2 subdir/subsubdir/file4
+`
+
+ file1 := r.WriteBoth(ctx, "file1", "file1 contents", t1)
+ file2 := r.WriteFile("subdir/file2", MD5SUMS, t2)
+ file3 := r.WriteObject(ctx, "subdir/subsubdir/file3", "file3 contents", t3)
+ file4a := r.WriteFile("subdir/subsubdir/file4", "file4 contents", t3)
+ file4b := r.WriteObject(ctx, "subdir/subsubdir/file4", "file4 different contents", t3)
+ // operations.HashLister(ctx, hash.MD5, false, false, r.Fremote, os.Stdout)
+
+ r.CheckLocalItems(t, file1, file2, file4a)
+ r.CheckRemoteItems(t, file1, file3, file4b)
+
+ pstring := func(items ...fstest.Item) *[]string {
+ xs := make([]string, len(items))
+ for i, item := range items {
+ xs[i] = item.Path
+ }
+ return &xs
+ }
+
+ for _, testName := range []string{"Normal", "Download"} {
+ t.Run(testName, func(t *testing.T) {
+ in := rc.Params{
+ "srcFs": r.LocalName,
+ "dstFs": r.FremoteName,
+ "combined": true,
+ "missingOnSrc": true,
+ "missingOnDst": true,
+ "match": true,
+ "differ": true,
+ "error": true,
+ }
+ if testName == "Download" {
+ in["download"] = true
+ }
+ out, err := call.Fn(ctx, in)
+ require.NoError(t, err)
+
+ combined := []string{
+ "= " + file1.Path,
+ "+ " + file2.Path,
+ "- " + file3.Path,
+ "* " + file4a.Path,
+ }
+ sort.Strings(combined)
+ sort.Strings(*out["combined"].(*[]string))
+ want := rc.Params{
+ "missingOnSrc": pstring(file3),
+ "missingOnDst": pstring(file2),
+ "differ": pstring(file4a),
+ "error": pstring(),
+ "match": pstring(file1),
+ "combined": &combined,
+ "status": "3 differences found",
+ "success": false,
+ }
+ if testName == "Normal" {
+ want["hashType"] = "md5"
+ }
+
+ assert.Equal(t, want, out)
+ })
+ }
+
+ t.Run("CheckFile", func(t *testing.T) {
+ // The checksum file is treated as the source and srcFs is not used
+ in := rc.Params{
+ "dstFs": r.FremoteName,
+ "combined": true,
+ "missingOnSrc": true,
+ "missingOnDst": true,
+ "match": true,
+ "differ": true,
+ "error": true,
+ "checkFileFs": r.LocalName,
+ "checkFileRemote": file2.Path,
+ "checkFileHash": "md5",
+ }
+ out, err := call.Fn(ctx, in)
+ require.NoError(t, err)
+
+ combined := []string{
+ "= " + file1.Path,
+ "+ " + file2.Path,
+ "- " + file3.Path,
+ "* " + file4a.Path,
+ }
+ sort.Strings(combined)
+ sort.Strings(*out["combined"].(*[]string))
+ if strings.HasPrefix(out["status"].(string), "file not in") {
+ out["status"] = "file not in"
+ }
+ want := rc.Params{
+ "missingOnSrc": pstring(file3),
+ "missingOnDst": pstring(file2),
+ "differ": pstring(file4a),
+ "error": pstring(),
+ "match": pstring(file1),
+ "combined": &combined,
+ "hashType": "md5",
+ "status": "file not in",
+ "success": false,
+ }
+
+ assert.Equal(t, want, out)
+ })
+
+}
+
+// operations/hashsum: hashsum a directory
+func TestRcHashsum(t *testing.T) {
+ ctx := context.Background()
+ r, call := rcNewRun(t, "operations/hashsum")
+ r.Mkdir(ctx, r.Fremote)
+
+ file1Contents := "file1 contents"
+ file1 := r.WriteBoth(ctx, "hashsum-file1", file1Contents, t1)
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, file1)
+
+ hasher := hash.NewMultiHasher()
+ _, err := hasher.Write([]byte(file1Contents))
+ require.NoError(t, err)
+
+ for _, test := range []struct {
+ ht hash.Type
+ base64 bool
+ download bool
+ }{
+ {
+ ht: r.Fremote.Hashes().GetOne(),
+ }, {
+ ht: r.Fremote.Hashes().GetOne(),
+ base64: true,
+ }, {
+ ht: hash.Whirlpool,
+ base64: false,
+ download: true,
+ }, {
+ ht: hash.Whirlpool,
+ base64: true,
+ download: true,
+ },
+ } {
+ t.Run(fmt.Sprintf("hash=%v,base64=%v,download=%v", test.ht, test.base64, test.download), func(t *testing.T) {
+ file1Hash, err := hasher.SumString(test.ht, test.base64)
+ require.NoError(t, err)
+
+ in := rc.Params{
+ "fs": r.FremoteName,
+ "hashType": test.ht.String(),
+ "base64": test.base64,
+ "download": test.download,
+ }
+
+ out, err := call.Fn(ctx, in)
+ require.NoError(t, err)
+ assert.Equal(t, test.ht.String(), out["hashType"])
+ want := []string{
+ fmt.Sprintf("%s hashsum-file1", file1Hash),
+ }
+ assert.Equal(t, want, out["hashsum"])
+ })
+ }
+}
+
+// operations/hashsum: hashsum a single file
+func TestRcHashsumSingleFile(t *testing.T) {
+ ctx := context.Background()
+ r, call := rcNewRun(t, "operations/hashsum")
+ r.Mkdir(ctx, r.Fremote)
+
+ file1Contents := "file1 contents"
+ file1 := r.WriteBoth(ctx, "hashsum-file1", file1Contents, t1)
+ file2Contents := "file2 contents"
+ file2 := r.WriteBoth(ctx, "hashsum-file2", file2Contents, t1)
+ r.CheckLocalItems(t, file1, file2)
+ r.CheckRemoteItems(t, file1, file2)
+
+ // Make an fs pointing to just the file
+ fsString := path.Join(r.FremoteName, file1.Path)
+
+ in := rc.Params{
+ "fs": fsString,
+ "hashType": "MD5",
+ "download": true,
+ }
+
+ out, err := call.Fn(ctx, in)
+ require.NoError(t, err)
+ assert.Equal(t, "md5", out["hashType"])
+ assert.Equal(t, []string{"0ef726ce9b1a7692357ff70dd321d595 hashsum-file1"}, out["hashsum"])
+}
+
+// operations/hashsumfile: hashsum a single file
+func TestRcHashsumFile(t *testing.T) {
+ ctx := context.Background()
+ r, call := rcNewRun(t, "operations/hashsumfile")
+ r.Mkdir(ctx, r.Fremote)
+
+ file1Contents := "file1 contents"
+ file1 := r.WriteBoth(ctx, "hashsumfile-file1", file1Contents, t1)
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, file1)
+
+ in := rc.Params{
+ "fs": r.FremoteName,
+ "remote": file1.Path,
+ "hashType": "MD5",
+ "download": true,
+ }
+
+ out, err := call.Fn(ctx, in)
+ require.NoError(t, err)
+ assert.Equal(t, "md5", out["hashType"])
+ assert.Equal(t, "0ef726ce9b1a7692357ff70dd321d595", out["hash"])
+}
diff --git a/fs/operations/reopen.go b/fs/operations/reopen.go
new file mode 100644
index 0000000..fcc2a68
--- /dev/null
+++ b/fs/operations/reopen.go
@@ -0,0 +1,346 @@
+package operations
+
+import (
+ "context"
+ "errors"
+ "io"
+ "sync"
+
+ "github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/fs/fserrors"
+)
+
+// AccountFn is a function which will be called after every read
+// from the ReOpen.
+//
+// It may return an error which will be passed back to the user.
+type AccountFn func(n int) error
+
+// ReOpen is a wrapper for an object reader which reopens the stream on error
+type ReOpen struct {
+ ctx context.Context
+ mu sync.Mutex // mutex to protect the below
+ readAtMu sync.Mutex // mutex to serialize the ReadAt calls
+ src fs.Object // object to open
+ baseOptions []fs.OpenOption // options to pass to initial open and where offset == 0
+ options []fs.OpenOption // option to pass on subsequent opens where offset != 0
+ rangeOption fs.RangeOption // adjust this range option on re-opens
+ rc io.ReadCloser // underlying stream
+ size int64 // total size of object - can be -ve
+ start int64 // absolute position to start reading from
+ end int64 // absolute position to end reading (exclusive)
+ offset int64 // offset in the file we are at, offset from start
+ newOffset int64 // if different to offset, reopen needed
+ maxTries int // maximum number of retries
+ tries int // number of retries we've had so far in this stream
+ err error // if this is set then Read/Close calls will return it
+ opened bool // if set then rc is valid and needs closing
+ account AccountFn // account for a read
+ reads int // count how many times the data has been read
+ accountOn int // only account on or after this read
+}
+
+var (
+ errFileClosed = errors.New("file already closed")
+ errTooManyTries = errors.New("failed to reopen: too many retries")
+ errInvalidWhence = errors.New("reopen Seek: invalid whence")
+ errNegativeSeek = errors.New("reopen Seek: negative position")
+ errSeekPastEnd = errors.New("reopen Seek: attempt to seek past end of data")
+ errBadEndSeek = errors.New("reopen Seek: can't seek from end with unknown sized object")
+)
+
+// NewReOpen makes a handle which will reopen itself and seek to where
+// it was on errors up to maxTries times.
+//
+// If an fs.HashesOption is set this will be applied when reading from
+// the start.
+//
+// If an fs.RangeOption is set then this will applied when reading from
+// the start, and updated on retries.
+func NewReOpen(ctx context.Context, src fs.Object, maxTries int, options ...fs.OpenOption) (rc *ReOpen, err error) {
+ h := &ReOpen{
+ ctx: ctx,
+ src: src,
+ maxTries: maxTries,
+ baseOptions: options,
+ size: src.Size(),
+ start: 0,
+ offset: 0,
+ newOffset: -1, // -1 means no seek required
+ }
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
+ // Filter the options for subsequent opens
+ h.options = make([]fs.OpenOption, 0, len(options)+1)
+ var limit int64 = -1
+ for _, option := range options {
+ switch x := option.(type) {
+ case *fs.HashesOption:
+ // leave hash option out when ranging
+ case *fs.RangeOption:
+ h.start, limit = x.Decode(h.end)
+ case *fs.SeekOption:
+ h.start, limit = x.Offset, -1
+ default:
+ h.options = append(h.options, option)
+ }
+ }
+
+ // Put our RangeOption on the end
+ h.rangeOption.Start = h.start
+ h.options = append(h.options, &h.rangeOption)
+
+ // If a size range is set then set the end point of the file to that
+ if limit >= 0 && h.size >= 0 {
+ h.end = h.start + limit
+ h.rangeOption.End = h.end - 1 // remember range options are inclusive
+ } else {
+ h.end = h.size
+ h.rangeOption.End = -1
+ }
+
+ err = h.open()
+ if err != nil {
+ return nil, err
+ }
+ return h, nil
+}
+
+// Open makes a handle which will reopen itself and seek to where it
+// was on errors.
+//
+// If an fs.HashesOption is set this will be applied when reading from
+// the start.
+//
+// If an fs.RangeOption is set then this will applied when reading from
+// the start, and updated on retries.
+//
+// It will obey LowLevelRetries in the ctx as the maximum number of
+// tries.
+//
+// Use this instead of calling the Open method on fs.Objects
+func Open(ctx context.Context, src fs.Object, options ...fs.OpenOption) (rc *ReOpen, err error) {
+ maxTries := fs.GetConfig(ctx).LowLevelRetries
+ return NewReOpen(ctx, src, maxTries, options...)
+}
+
+// open the underlying handle - call with lock held
+//
+// we don't retry here as the Open() call will itself have low level retries
+func (h *ReOpen) open() error {
+ var opts []fs.OpenOption
+ if h.offset == 0 {
+ // if reading from the start using the initial options
+ opts = h.baseOptions
+ } else {
+ // otherwise use the filtered options
+ opts = h.options
+ // Adjust range start to where we have got to
+ h.rangeOption.Start = h.start + h.offset
+ }
+ // Make a copy of the options as fs.FixRangeOption modifies them :-(
+ opts = append(make([]fs.OpenOption, 0, len(opts)), opts...)
+ h.tries++
+ if h.tries > h.maxTries {
+ h.err = errTooManyTries
+ } else {
+ h.rc, h.err = h.src.Open(h.ctx, opts...)
+ }
+ if h.err != nil {
+ if h.tries > 1 {
+ fs.Debugf(h.src, "Reopen failed after offset %d bytes read: %v", h.offset, h.err)
+ }
+ return h.err
+ }
+ h.opened = true
+ return nil
+}
+
+// reopen the underlying handle by closing it and reopening it.
+func (h *ReOpen) reopen() (err error) {
+ // close underlying stream if needed
+ if h.opened {
+ h.opened = false
+ _ = h.rc.Close()
+ }
+ return h.open()
+}
+
+// account for n bytes being read
+func (h *ReOpen) accountRead(n int) error {
+ if h.account == nil {
+ return nil
+ }
+ // Don't start accounting until we've reached this many reads
+ //
+ // rw.reads will be 1 the first time this is called
+ // rw.accountOn 2 means start accounting on the 2nd read through
+ if h.reads >= h.accountOn {
+ return h.account(n)
+ }
+ return nil
+}
+
+// Read bytes retrying as necessary
+func (h *ReOpen) Read(p []byte) (n int, err error) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ if h.err != nil {
+ // return a previous error if there is one
+ return n, h.err
+ }
+
+ // re-open if seek needed
+ if h.newOffset >= 0 {
+ if h.offset != h.newOffset {
+ fs.Debugf(h.src, "Seek from %d to %d", h.offset, h.newOffset)
+ h.offset = h.newOffset
+ err = h.reopen()
+ if err != nil {
+ return 0, err
+ }
+ }
+ h.newOffset = -1
+ }
+
+ // Read a full buffer
+ startOffset := h.offset
+ var nn int
+ for n < len(p) && err == nil {
+ nn, err = h.rc.Read(p[n:])
+ n += nn
+ h.offset += int64(nn)
+ if err != nil && err != io.EOF {
+ h.err = err
+ if !fserrors.IsNoLowLevelRetryError(err) {
+ fs.Debugf(h.src, "Reopening on read failure after offset %d bytes: retry %d/%d: %v", h.offset, h.tries, h.maxTries, err)
+ if h.reopen() == nil {
+ err = nil
+ }
+ }
+ }
+ }
+ // Count a read of the data if we read from the start successfully
+ if startOffset == 0 && n != 0 {
+ h.reads++
+ }
+ // Account the read
+ accErr := h.accountRead(n)
+ if err == nil {
+ err = accErr
+ }
+ return n, err
+}
+
+// ReadAt reads len(p) bytes at absolute offset off without changing
+// the read position.
+//
+// Note: operations are serialized; it won't behave like a truly
+// concurrent ReaderAt.
+func (h *ReOpen) ReadAt(p []byte, off int64) (n int, err error) {
+ h.readAtMu.Lock()
+ defer h.readAtMu.Unlock()
+
+ // Save current position
+ cur, err := h.Seek(0, io.SeekCurrent)
+ if err != nil {
+ return 0, err
+ }
+ // Seek to requested offset
+ if _, err = h.Seek(off, io.SeekStart); err != nil {
+ return 0, err
+ }
+ // Restore position on exit
+ defer func() {
+ if _, seekErr := h.Seek(cur, io.SeekStart); seekErr != nil && err == nil {
+ err = seekErr
+ }
+ }()
+
+ // Fill p fully unless EOF
+ return h.Read(p)
+}
+
+// Seek sets the offset for the next Read or Write to offset, interpreted
+// according to whence: SeekStart means relative to the start of the file,
+// SeekCurrent means relative to the current offset, and SeekEnd means relative
+// to the end (for example, offset = -2 specifies the penultimate byte of the
+// file). Seek returns the new offset relative to the start of the file or an
+// error, if any.
+//
+// Seeking to an offset before the start of the file is an error. Seeking
+// to any positive offset may be allowed, but if the new offset exceeds the
+// size of the underlying object the behavior of subsequent I/O operations is
+// implementation-dependent.
+func (h *ReOpen) Seek(offset int64, whence int) (int64, error) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ if h.err != nil {
+ // return a previous error if there is one
+ return 0, h.err
+ }
+ var abs int64
+ var size = h.end - h.start
+ switch whence {
+ case io.SeekStart:
+ abs = offset
+ case io.SeekCurrent:
+ if h.newOffset >= 0 {
+ abs = h.newOffset + offset
+ } else {
+ abs = h.offset + offset
+ }
+ case io.SeekEnd:
+ if h.size < 0 {
+ return 0, errBadEndSeek
+ }
+ abs = size + offset
+ default:
+ return 0, errInvalidWhence
+ }
+ if abs < 0 {
+ return 0, errNegativeSeek
+ }
+ if h.size >= 0 && abs > size {
+ return size, errSeekPastEnd
+ }
+
+ h.tries = 0 // Reset open count on seek
+ h.newOffset = abs // New offset - applied in Read
+ return abs, nil
+}
+
+// Close the stream
+func (h *ReOpen) Close() error {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ if !h.opened {
+ return errFileClosed
+ }
+ h.opened = false
+ h.err = errFileClosed
+ return h.rc.Close()
+}
+
+// SetAccounting should be provided with a function which will be
+// called after every read from the RW.
+//
+// It may return an error which will be passed back to the user.
+func (h *ReOpen) SetAccounting(account AccountFn) *ReOpen {
+ h.account = account
+ return h
+}
+
+// DelayAccounting makes sure the accounting function only gets called
+// on the i-th or later read of the data from this point (counting
+// from 1).
+//
+// This is useful so that we don't account initial reads of the data
+// e.g. when calculating hashes.
+//
+// Set this to 0 to account everything.
+func (h *ReOpen) DelayAccounting(i int) {
+ h.accountOn = i
+ h.reads = 0
+}
diff --git a/fs/operations/reopen_test.go b/fs/operations/reopen_test.go
new file mode 100644
index 0000000..19f6ca3
--- /dev/null
+++ b/fs/operations/reopen_test.go
@@ -0,0 +1,429 @@
+package operations
+
+import (
+ "context"
+ "errors"
+ "io"
+ "testing"
+
+ "github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/fs/hash"
+ "github.com/rclone/rclone/fstest/mockobject"
+ "github.com/rclone/rclone/lib/pool"
+ "github.com/rclone/rclone/lib/readers"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// check interfaces
+var (
+ _ io.ReadSeekCloser = (*ReOpen)(nil)
+ _ io.ReaderAt = (*ReOpen)(nil)
+ _ pool.DelayAccountinger = (*ReOpen)(nil)
+)
+
+var errorTestError = errors.New("test error")
+
+// this is a wrapper for a mockobject with a custom Open function
+//
+// breaks indicate the number of bytes to read before returning an
+// error
+type reOpenTestObject struct {
+ fs.Object
+ t *testing.T
+ wantStart int64
+ breaks []int64
+ unknownSize bool
+}
+
+// Open opens the file for read. Call Close() on the returned io.ReadCloser
+//
+// This will break after reading the number of bytes in breaks
+func (o *reOpenTestObject) Open(ctx context.Context, options ...fs.OpenOption) (io.ReadCloser, error) {
+ // Lots of backends do this - make sure it works as it modifies options
+ fs.FixRangeOption(options, o.Size())
+ gotHash := false
+ gotRange := false
+ startPos := int64(0)
+ for _, option := range options {
+ switch x := option.(type) {
+ case *fs.HashesOption:
+ gotHash = true
+ case *fs.RangeOption:
+ gotRange = true
+ startPos = x.Start
+ if o.unknownSize {
+ assert.Equal(o.t, int64(-1), x.End)
+ }
+ case *fs.SeekOption:
+ startPos = x.Offset
+ }
+ }
+ assert.Equal(o.t, o.wantStart, startPos)
+ // Check if ranging, mustn't have hash if offset != 0
+ if gotHash && gotRange {
+ assert.Equal(o.t, int64(0), startPos)
+ }
+ rc, err := o.Object.Open(ctx, options...)
+ if err != nil {
+ return nil, err
+ }
+ if len(o.breaks) > 0 {
+ // Pop a breakpoint off
+ N := o.breaks[0]
+ o.breaks = o.breaks[1:]
+ o.wantStart += N
+ // If 0 then return an error immediately
+ if N == 0 {
+ return nil, errorTestError
+ }
+ // Read N bytes then an error
+ r := io.MultiReader(&io.LimitedReader{R: rc, N: N}, readers.ErrorReader{Err: errorTestError})
+ // Wrap with Close in a new readCloser
+ rc = readCloser{Reader: r, Closer: rc}
+ }
+ return rc, nil
+}
+
+func TestReOpen(t *testing.T) {
+ for _, testName := range []string{"Normal", "WithRangeOption", "WithSeekOption", "UnknownSize"} {
+ t.Run(testName, func(t *testing.T) {
+ // Contents for the mock object
+ var (
+ reOpenTestcontents = []byte("0123456789")
+ expectedRead = reOpenTestcontents
+ rangeOption *fs.RangeOption
+ seekOption *fs.SeekOption
+ unknownSize = false
+ )
+ switch testName {
+ case "Normal":
+ case "WithRangeOption":
+ rangeOption = &fs.RangeOption{Start: 1, End: 7} // range is inclusive
+ expectedRead = reOpenTestcontents[1:8]
+ case "WithSeekOption":
+ seekOption = &fs.SeekOption{Offset: 2}
+ expectedRead = reOpenTestcontents[2:]
+ case "UnknownSize":
+ rangeOption = &fs.RangeOption{Start: 1, End: -1}
+ expectedRead = reOpenTestcontents[1:]
+ unknownSize = true
+ default:
+ panic("bad test name")
+ }
+
+ // Start the test with the given breaks
+ testReOpen := func(breaks []int64, maxRetries int) (*ReOpen, *reOpenTestObject, error) {
+ srcOrig := mockobject.New("potato").WithContent(reOpenTestcontents, mockobject.SeekModeNone)
+ srcOrig.SetUnknownSize(unknownSize)
+ src := &reOpenTestObject{
+ Object: srcOrig,
+ t: t,
+ breaks: breaks,
+ unknownSize: unknownSize,
+ }
+ opts := []fs.OpenOption{}
+ if rangeOption == nil && seekOption == nil {
+ opts = append(opts, &fs.HashesOption{Hashes: hash.NewHashSet(hash.MD5)})
+ }
+ if rangeOption != nil {
+ opts = append(opts, rangeOption)
+ src.wantStart = rangeOption.Start
+ }
+ if seekOption != nil {
+ opts = append(opts, seekOption)
+ src.wantStart = seekOption.Offset
+ }
+ rc, err := NewReOpen(context.Background(), src, maxRetries, opts...)
+ return rc, src, err
+ }
+
+ // Reset the start after a seek, taking into account the offset
+ setWantStart := func(src *reOpenTestObject, x int64) {
+ src.wantStart = x
+ if rangeOption != nil {
+ src.wantStart += rangeOption.Start
+ } else if seekOption != nil {
+ src.wantStart += seekOption.Offset
+ }
+ }
+
+ t.Run("Basics", func(t *testing.T) {
+ // open
+ h, _, err := testReOpen(nil, 10)
+ assert.NoError(t, err)
+
+ // Check contents read correctly
+ got, err := io.ReadAll(h)
+ assert.NoError(t, err)
+ assert.Equal(t, expectedRead, got)
+
+ // Check read after end
+ var buf = make([]byte, 1)
+ n, err := h.Read(buf)
+ assert.Equal(t, 0, n)
+ assert.Equal(t, io.EOF, err)
+
+ // Rewind the stream
+ _, err = h.Seek(0, io.SeekStart)
+ require.NoError(t, err)
+
+ // Check contents read correctly
+ got, err = io.ReadAll(h)
+ assert.NoError(t, err)
+ assert.Equal(t, expectedRead, got)
+
+ // Check close
+ assert.NoError(t, h.Close())
+
+ // Check double close
+ assert.Equal(t, errFileClosed, h.Close())
+
+ // Check read after close
+ n, err = h.Read(buf)
+ assert.Equal(t, 0, n)
+ assert.Equal(t, errFileClosed, err)
+ })
+
+ t.Run("ErrorAtStart", func(t *testing.T) {
+ // open with immediate breaking
+ h, _, err := testReOpen([]int64{0}, 10)
+ assert.Equal(t, errorTestError, err)
+ assert.Nil(t, h)
+ })
+
+ t.Run("WithErrors", func(t *testing.T) {
+ // open with a few break points but less than the max
+ h, _, err := testReOpen([]int64{2, 1, 3}, 10)
+ assert.NoError(t, err)
+
+ // check contents
+ got, err := io.ReadAll(h)
+ assert.NoError(t, err)
+ assert.Equal(t, expectedRead, got)
+
+ // check close
+ assert.NoError(t, h.Close())
+ })
+
+ t.Run("TooManyErrors", func(t *testing.T) {
+ // open with a few break points but >= the max
+ h, _, err := testReOpen([]int64{2, 1, 3}, 3)
+ assert.NoError(t, err)
+
+ // check contents
+ got, err := io.ReadAll(h)
+ assert.Equal(t, errorTestError, err)
+ assert.Equal(t, expectedRead[:6], got)
+
+ // check old error is returned
+ var buf = make([]byte, 1)
+ n, err := h.Read(buf)
+ assert.Equal(t, 0, n)
+ assert.Equal(t, errTooManyTries, err)
+
+ // Check close
+ assert.Equal(t, errFileClosed, h.Close())
+ })
+
+ t.Run("ReadAt", func(t *testing.T) {
+ // open
+ h, src, err := testReOpen([]int64{2, 1, 3}, 10)
+ assert.NoError(t, err)
+
+ buf := make([]byte, 5)
+
+ // Read at 0
+ n, err := h.ReadAt(buf, 0)
+ require.NoError(t, err)
+ assert.Equal(t, 5, n)
+ assert.Equal(t, expectedRead[:n], buf[:n])
+
+ // Read at 1
+ setWantStart(src, 1)
+ n, err = h.ReadAt(buf[:3], 1)
+ require.NoError(t, err)
+ assert.Equal(t, 3, n)
+ assert.Equal(t, expectedRead[1:n+1], buf[:n])
+
+ // check position unchanged
+ pos, err := h.Seek(0, io.SeekCurrent)
+ require.NoError(t, err)
+ assert.Equal(t, int64(0), pos)
+
+ // check close
+ assert.NoError(t, h.Close())
+ _, err = h.Seek(0, io.SeekCurrent)
+ assert.Equal(t, errFileClosed, err)
+ })
+
+ t.Run("Seek", func(t *testing.T) {
+ // open
+ h, src, err := testReOpen([]int64{2, 1, 3}, 10)
+ assert.NoError(t, err)
+
+ // Seek to end
+ pos, err := h.Seek(int64(len(expectedRead)), io.SeekStart)
+ assert.NoError(t, err)
+ assert.Equal(t, int64(len(expectedRead)), pos)
+
+ // Seek to start
+ pos, err = h.Seek(0, io.SeekStart)
+ assert.NoError(t, err)
+ assert.Equal(t, int64(0), pos)
+
+ // Should not allow seek past end
+ pos, err = h.Seek(int64(len(expectedRead))+1, io.SeekCurrent)
+ if !unknownSize {
+ assert.Equal(t, errSeekPastEnd, err)
+ assert.Equal(t, len(expectedRead), int(pos))
+ } else {
+ assert.Equal(t, nil, err)
+ assert.Equal(t, len(expectedRead)+1, int(pos))
+
+ // Seek back to start to get tests in sync
+ pos, err = h.Seek(0, io.SeekStart)
+ assert.NoError(t, err)
+ assert.Equal(t, int64(0), pos)
+ }
+
+ // Should not allow seek to negative position start
+ pos, err = h.Seek(-1, io.SeekCurrent)
+ assert.Equal(t, errNegativeSeek, err)
+ assert.Equal(t, 0, int(pos))
+
+ // Should not allow seek with invalid whence
+ pos, err = h.Seek(0, 3)
+ assert.Equal(t, errInvalidWhence, err)
+ assert.Equal(t, 0, int(pos))
+
+ // check read
+ dst := make([]byte, 5)
+ n, err := h.Read(dst)
+ assert.Nil(t, err)
+ assert.Equal(t, 5, n)
+ assert.Equal(t, expectedRead[:5], dst)
+
+ // Test io.SeekCurrent
+ pos, err = h.Seek(-3, io.SeekCurrent)
+ assert.Nil(t, err)
+ assert.Equal(t, 2, int(pos))
+
+ // check read
+ setWantStart(src, 2)
+ n, err = h.Read(dst)
+ assert.Nil(t, err)
+ assert.Equal(t, 5, n)
+ assert.Equal(t, expectedRead[2:7], dst)
+
+ pos, err = h.Seek(-2, io.SeekCurrent)
+ assert.Nil(t, err)
+ assert.Equal(t, 5, int(pos))
+
+ // Test io.SeekEnd
+ pos, err = h.Seek(-3, io.SeekEnd)
+ if !unknownSize {
+ assert.Nil(t, err)
+ assert.Equal(t, len(expectedRead)-3, int(pos))
+ } else {
+ assert.Equal(t, errBadEndSeek, err)
+ assert.Equal(t, 0, int(pos))
+
+ // sync
+ pos, err = h.Seek(1, io.SeekCurrent)
+ assert.Nil(t, err)
+ assert.Equal(t, 6, int(pos))
+ }
+
+ // check read
+ dst = make([]byte, 3)
+ setWantStart(src, int64(len(expectedRead)-3))
+ n, err = h.Read(dst)
+ assert.Nil(t, err)
+ assert.Equal(t, 3, n)
+ assert.Equal(t, expectedRead[len(expectedRead)-3:], dst)
+
+ // check close
+ assert.NoError(t, h.Close())
+ _, err = h.Seek(0, io.SeekCurrent)
+ assert.Equal(t, errFileClosed, err)
+ })
+
+ t.Run("AccountRead", func(t *testing.T) {
+ h, _, err := testReOpen(nil, 10)
+ assert.NoError(t, err)
+
+ var total int
+ h.SetAccounting(func(n int) error {
+ total += n
+ return nil
+ })
+
+ dst := make([]byte, 3)
+ n, err := h.Read(dst)
+ assert.Equal(t, 3, n)
+ assert.NoError(t, err)
+ assert.Equal(t, 3, total)
+ })
+
+ t.Run("AccountReadDelay", func(t *testing.T) {
+ h, _, err := testReOpen(nil, 10)
+ assert.NoError(t, err)
+
+ var total int
+ h.SetAccounting(func(n int) error {
+ total += n
+ return nil
+ })
+
+ rewind := func() {
+ _, err := h.Seek(0, io.SeekStart)
+ require.NoError(t, err)
+ }
+
+ h.DelayAccounting(3)
+
+ dst := make([]byte, 16)
+
+ n, err := h.Read(dst)
+ assert.Equal(t, len(expectedRead), n)
+ assert.Equal(t, io.EOF, err)
+ assert.Equal(t, 0, total)
+ rewind()
+
+ n, err = h.Read(dst)
+ assert.Equal(t, len(expectedRead), n)
+ assert.Equal(t, io.EOF, err)
+ assert.Equal(t, 0, total)
+ rewind()
+
+ n, err = h.Read(dst)
+ assert.Equal(t, len(expectedRead), n)
+ assert.Equal(t, io.EOF, err)
+ assert.Equal(t, len(expectedRead), total)
+ rewind()
+
+ n, err = h.Read(dst)
+ assert.Equal(t, len(expectedRead), n)
+ assert.Equal(t, io.EOF, err)
+ assert.Equal(t, 2*len(expectedRead), total)
+ rewind()
+ })
+
+ t.Run("AccountReadError", func(t *testing.T) {
+ // Test accounting errors
+ h, _, err := testReOpen(nil, 10)
+ assert.NoError(t, err)
+
+ h.SetAccounting(func(n int) error {
+ return errorTestError
+ })
+
+ dst := make([]byte, 3)
+ n, err := h.Read(dst)
+ assert.Equal(t, 3, n)
+ assert.Equal(t, errorTestError, err)
+ })
+ })
+ }
+}
diff --git a/fs/sync/pipe.go b/fs/sync/pipe.go
new file mode 100644
index 0000000..124a077
--- /dev/null
+++ b/fs/sync/pipe.go
@@ -0,0 +1,237 @@
+package sync
+
+import (
+ "context"
+ "fmt"
+ "math/bits"
+ "strconv"
+ "strings"
+ "sync"
+
+ "github.com/aalpar/deheap"
+ "github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/fs/fserrors"
+)
+
+// compare two items for order by
+type lessFn func(a, b fs.ObjectPair) bool
+
+// pipe provides an unbounded channel like experience
+//
+// Note unlike channels these aren't strictly ordered.
+type pipe struct {
+ mu sync.Mutex
+ c chan struct{}
+ queue []fs.ObjectPair
+ closed bool
+ totalSize int64
+ stats func(items int, totalSize int64)
+ less lessFn
+ fraction int
+}
+
+func newPipe(orderBy string, stats func(items int, totalSize int64), maxBacklog int) (*pipe, error) {
+ if maxBacklog < 0 {
+ maxBacklog = (1 << (bits.UintSize - 1)) - 1 // largest positive int
+ }
+ less, fraction, err := newLess(orderBy)
+ if err != nil {
+ return nil, fserrors.FatalError(err)
+ }
+ p := &pipe{
+ c: make(chan struct{}, maxBacklog),
+ stats: stats,
+ less: less,
+ fraction: fraction,
+ }
+ if p.less != nil {
+ deheap.Init(p)
+ }
+ return p, nil
+}
+
+// Len satisfy heap.Interface - must be called with lock held
+func (p *pipe) Len() int {
+ return len(p.queue)
+}
+
+// Len satisfy heap.Interface - must be called with lock held
+func (p *pipe) Less(i, j int) bool {
+ return p.less(p.queue[i], p.queue[j])
+}
+
+// Swap satisfy heap.Interface - must be called with lock held
+func (p *pipe) Swap(i, j int) {
+ p.queue[i], p.queue[j] = p.queue[j], p.queue[i]
+}
+
+// Push satisfy heap.Interface - must be called with lock held
+func (p *pipe) Push(item any) {
+ p.queue = append(p.queue, item.(fs.ObjectPair))
+}
+
+// Pop satisfy heap.Interface - must be called with lock held
+func (p *pipe) Pop() any {
+ old := p.queue
+ n := len(old)
+ item := old[n-1]
+ old[n-1] = fs.ObjectPair{} // avoid memory leak
+ p.queue = old[0 : n-1]
+ return item
+}
+
+// Put a pair into the pipe
+//
+// It returns ok = false if the context was cancelled
+//
+// It will panic if you call it after Close()
+//
+// Note that pairs where src==dst aren't counted for stats
+func (p *pipe) Put(ctx context.Context, pair fs.ObjectPair) (ok bool) {
+ if ctx.Err() != nil {
+ return false
+ }
+ p.mu.Lock()
+ if p.less == nil {
+ // no order-by
+ p.queue = append(p.queue, pair)
+ } else {
+ deheap.Push(p, pair)
+ }
+ size := pair.Src.Size()
+ if size > 0 && pair.Src != pair.Dst {
+ p.totalSize += size
+ }
+ p.stats(len(p.queue), p.totalSize)
+ p.mu.Unlock()
+ select {
+ case <-ctx.Done():
+ return false
+ case p.c <- struct{}{}:
+ }
+ return true
+}
+
+// Get a pair from the pipe
+//
+// If fraction is > the mixed fraction set in the pipe then it gets it
+// from the other end of the heap if order-by is in effect
+//
+// It returns ok = false if the context was cancelled or Close() has
+// been called.
+func (p *pipe) GetMax(ctx context.Context, fraction int) (pair fs.ObjectPair, ok bool) {
+ if ctx.Err() != nil {
+ return
+ }
+ select {
+ case <-ctx.Done():
+ return
+ case _, ok = <-p.c:
+ if !ok {
+ return
+ }
+ }
+ p.mu.Lock()
+ if p.less == nil {
+ // no order-by
+ pair = p.queue[0]
+ p.queue[0] = fs.ObjectPair{} // avoid memory leak
+ p.queue = p.queue[1:]
+ } else if p.fraction < 0 || fraction < p.fraction {
+ pair = deheap.Pop(p).(fs.ObjectPair)
+ } else {
+ pair = deheap.PopMax(p).(fs.ObjectPair)
+ }
+ size := pair.Src.Size()
+ if size > 0 && pair.Src != pair.Dst {
+ p.totalSize -= size
+ }
+ if p.totalSize < 0 {
+ p.totalSize = 0
+ }
+ p.stats(len(p.queue), p.totalSize)
+ p.mu.Unlock()
+ return pair, true
+}
+
+// Get a pair from the pipe
+//
+// It returns ok = false if the context was cancelled or Close() has
+// been called.
+func (p *pipe) Get(ctx context.Context) (pair fs.ObjectPair, ok bool) {
+ return p.GetMax(ctx, -1)
+}
+
+// Stats reads the number of items in the queue and the totalSize
+func (p *pipe) Stats() (items int, totalSize int64) {
+ p.mu.Lock()
+ items, totalSize = len(p.queue), p.totalSize
+ p.mu.Unlock()
+ return items, totalSize
+}
+
+// Close the pipe
+//
+// Writes to a closed pipe will panic as will double closing a pipe
+func (p *pipe) Close() {
+ p.mu.Lock()
+ close(p.c)
+ p.closed = true
+ p.mu.Unlock()
+}
+
+// newLess returns a less function for the heap comparison or nil if
+// one is not required
+func newLess(orderBy string) (less lessFn, fraction int, err error) {
+ fraction = -1
+ if orderBy == "" {
+ return nil, fraction, nil
+ }
+ parts := strings.Split(strings.ToLower(orderBy), ",")
+ switch parts[0] {
+ case "name":
+ less = func(a, b fs.ObjectPair) bool {
+ return a.Src.Remote() < b.Src.Remote()
+ }
+ case "size":
+ less = func(a, b fs.ObjectPair) bool {
+ return a.Src.Size() < b.Src.Size()
+ }
+ case "modtime":
+ less = func(a, b fs.ObjectPair) bool {
+ ctx := context.Background()
+ return a.Src.ModTime(ctx).Before(b.Src.ModTime(ctx))
+ }
+ default:
+ return nil, fraction, fmt.Errorf("unknown --order-by comparison %q", parts[0])
+ }
+ descending := false
+ if len(parts) > 1 {
+ switch parts[1] {
+ case "ascending", "asc":
+ case "descending", "desc":
+ descending = true
+ case "mixed":
+ fraction = 50
+ if len(parts) > 2 {
+ fraction, err = strconv.Atoi(parts[2])
+ if err != nil {
+ return nil, fraction, fmt.Errorf("bad mixed fraction --order-by %q", parts[2])
+ }
+ }
+
+ default:
+ return nil, fraction, fmt.Errorf("unknown --order-by sort direction %q", parts[1])
+ }
+ }
+ if (fraction >= 0 && len(parts) > 3) || (fraction < 0 && len(parts) > 2) {
+ return nil, fraction, fmt.Errorf("bad --order-by string %q", orderBy)
+ }
+ if descending {
+ oldLess := less
+ less = func(a, b fs.ObjectPair) bool {
+ return !oldLess(a, b)
+ }
+ }
+ return less, fraction, nil
+}
diff --git a/fs/sync/pipe_test.go b/fs/sync/pipe_test.go
new file mode 100644
index 0000000..94916aa
--- /dev/null
+++ b/fs/sync/pipe_test.go
@@ -0,0 +1,290 @@
+package sync
+
+import (
+ "container/heap"
+ "context"
+ "sync"
+ "sync/atomic"
+ "testing"
+
+ "github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/fstest/mockobject"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// Check interface satisfied
+var _ heap.Interface = (*pipe)(nil)
+
+func TestPipe(t *testing.T) {
+ var queueLength int
+ var queueSize int64
+ stats := func(n int, size int64) {
+ queueLength, queueSize = n, size
+ }
+
+ // Make a new pipe
+ p, err := newPipe("", stats, 10)
+ require.NoError(t, err)
+
+ checkStats := func(expectedN int, expectedSize int64) {
+ n, size := p.Stats()
+ assert.Equal(t, expectedN, n)
+ assert.Equal(t, expectedSize, size)
+ assert.Equal(t, expectedN, queueLength)
+ assert.Equal(t, expectedSize, queueSize)
+ }
+
+ checkStats(0, 0)
+
+ ctx := context.Background()
+
+ obj1 := mockobject.New("potato").WithContent([]byte("hello"), mockobject.SeekModeNone)
+
+ pair1 := fs.ObjectPair{Src: obj1, Dst: nil}
+ pairD := fs.ObjectPair{Src: obj1, Dst: obj1} // this object should not count to the stats
+
+ // Put an object
+ ok := p.Put(ctx, pair1)
+ assert.Equal(t, true, ok)
+ checkStats(1, 5)
+
+ // Put an object to be deleted
+ ok = p.Put(ctx, pairD)
+ assert.Equal(t, true, ok)
+ checkStats(2, 5)
+
+ // Close the pipe showing reading on closed pipe is OK
+ p.Close()
+
+ // Read from pipe
+ pair2, ok := p.Get(ctx)
+ assert.Equal(t, pair1, pair2)
+ assert.Equal(t, true, ok)
+ checkStats(1, 0)
+
+ // Read from pipe
+ pair2, ok = p.Get(ctx)
+ assert.Equal(t, pairD, pair2)
+ assert.Equal(t, true, ok)
+ checkStats(0, 0)
+
+ // Check read on closed pipe
+ pair2, ok = p.Get(ctx)
+ assert.Equal(t, fs.ObjectPair{}, pair2)
+ assert.Equal(t, false, ok)
+
+ // Check panic on write to closed pipe
+ assert.Panics(t, func() { p.Put(ctx, pair1) })
+
+ // Make a new pipe
+ p, err = newPipe("", stats, 10)
+ require.NoError(t, err)
+ ctx2, cancel := context.WithCancel(ctx)
+
+ // cancel it in the background - check read ceases
+ go cancel()
+ pair2, ok = p.Get(ctx2)
+ assert.Equal(t, fs.ObjectPair{}, pair2)
+ assert.Equal(t, false, ok)
+
+ // check we can't write
+ ok = p.Put(ctx2, pair1)
+ assert.Equal(t, false, ok)
+
+}
+
+// TestPipeConcurrent runs concurrent Get and Put to flush out any
+// race conditions and concurrency problems.
+func TestPipeConcurrent(t *testing.T) {
+ const (
+ N = 1000
+ readWriters = 10
+ )
+
+ stats := func(n int, size int64) {}
+
+ // Make a new pipe
+ p, err := newPipe("", stats, 10)
+ require.NoError(t, err)
+
+ var wg sync.WaitGroup
+ obj1 := mockobject.New("potato").WithContent([]byte("hello"), mockobject.SeekModeNone)
+ pair1 := fs.ObjectPair{Src: obj1, Dst: nil}
+ ctx := context.Background()
+ var count atomic.Int64
+
+ for range readWriters {
+ wg.Add(2)
+ go func() {
+ defer wg.Done()
+ for range N {
+ // Read from pipe
+ pair2, ok := p.Get(ctx)
+ assert.Equal(t, pair1, pair2)
+ assert.Equal(t, true, ok)
+ count.Add(-1)
+ }
+ }()
+ go func() {
+ defer wg.Done()
+ for range N {
+ // Put an object
+ ok := p.Put(ctx, pair1)
+ assert.Equal(t, true, ok)
+ count.Add(1)
+ }
+ }()
+ }
+ wg.Wait()
+
+ assert.Equal(t, int64(0), count.Load())
+}
+
+func TestPipeOrderBy(t *testing.T) {
+ var (
+ stats = func(n int, size int64) {}
+ ctx = context.Background()
+ obj1 = mockobject.New("b").WithContent([]byte("1"), mockobject.SeekModeNone)
+ obj2 = mockobject.New("a").WithContent([]byte("22"), mockobject.SeekModeNone)
+ pair1 = fs.ObjectPair{Src: obj1}
+ pair2 = fs.ObjectPair{Src: obj2}
+ )
+
+ for _, test := range []struct {
+ orderBy string
+ swapped1 bool
+ swapped2 bool
+ fraction int
+ }{
+ {"", false, true, -1},
+ {"size", false, false, -1},
+ {"name", true, true, -1},
+ {"modtime", false, true, -1},
+ {"size,ascending", false, false, -1},
+ {"name,asc", true, true, -1},
+ {"modtime,ascending", false, true, -1},
+ {"size,descending", true, true, -1},
+ {"name,desc", false, false, -1},
+ {"modtime,descending", true, false, -1},
+ {"size,mixed,50", false, false, 25},
+ {"size,mixed,51", true, true, 75},
+ } {
+ t.Run(test.orderBy, func(t *testing.T) {
+ p, err := newPipe(test.orderBy, stats, 10)
+ require.NoError(t, err)
+
+ readAndCheck := func(swapped bool) {
+ var readFirst, readSecond fs.ObjectPair
+ var ok1, ok2 bool
+ if test.fraction < 0 {
+ readFirst, ok1 = p.Get(ctx)
+ readSecond, ok2 = p.Get(ctx)
+ } else {
+ readFirst, ok1 = p.GetMax(ctx, test.fraction)
+ readSecond, ok2 = p.GetMax(ctx, test.fraction)
+ }
+ assert.True(t, ok1)
+ assert.True(t, ok2)
+
+ if swapped {
+ assert.True(t, readFirst == pair2 && readSecond == pair1)
+ } else {
+ assert.True(t, readFirst == pair1 && readSecond == pair2)
+ }
+ }
+
+ ok := p.Put(ctx, pair1)
+ assert.True(t, ok)
+ ok = p.Put(ctx, pair2)
+ assert.True(t, ok)
+
+ readAndCheck(test.swapped1)
+
+ // insert other way round
+
+ ok = p.Put(ctx, pair2)
+ assert.True(t, ok)
+ ok = p.Put(ctx, pair1)
+ assert.True(t, ok)
+
+ readAndCheck(test.swapped2)
+ })
+ }
+}
+
+func TestNewLess(t *testing.T) {
+ t.Run("blankOK", func(t *testing.T) {
+ less, _, err := newLess("")
+ require.NoError(t, err)
+ assert.Nil(t, less)
+ })
+
+ t.Run("tooManyParts", func(t *testing.T) {
+ _, _, err := newLess("size,asc,toomanyparts")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "bad --order-by string")
+ })
+
+ t.Run("tooManyParts2", func(t *testing.T) {
+ _, _, err := newLess("size,mixed,50,toomanyparts")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "bad --order-by string")
+ })
+
+ t.Run("badMixed", func(t *testing.T) {
+ _, _, err := newLess("size,mixed,32.7")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "bad mixed fraction")
+ })
+
+ t.Run("unknownComparison", func(t *testing.T) {
+ _, _, err := newLess("potato")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "unknown --order-by comparison")
+ })
+
+ t.Run("unknownSortDirection", func(t *testing.T) {
+ _, _, err := newLess("name,sideways")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "unknown --order-by sort direction")
+ })
+
+ var (
+ obj1 = mockobject.New("b").WithContent([]byte("1"), mockobject.SeekModeNone)
+ obj2 = mockobject.New("a").WithContent([]byte("22"), mockobject.SeekModeNone)
+ pair1 = fs.ObjectPair{Src: obj1}
+ pair2 = fs.ObjectPair{Src: obj2}
+ )
+
+ for _, test := range []struct {
+ orderBy string
+ pair1LessPair2 bool
+ pair2LessPair1 bool
+ wantFraction int
+ }{
+ {"size", true, false, -1},
+ {"name", false, true, -1},
+ {"modtime", false, false, -1},
+ {"size,ascending", true, false, -1},
+ {"name,asc", false, true, -1},
+ {"modtime,ascending", false, false, -1},
+ {"size,descending", false, true, -1},
+ {"name,desc", true, false, -1},
+ {"modtime,descending", true, true, -1},
+ {"modtime,mixed", false, false, 50},
+ {"modtime,mixed,30", false, false, 30},
+ } {
+ t.Run(test.orderBy, func(t *testing.T) {
+ less, gotFraction, err := newLess(test.orderBy)
+ assert.Equal(t, test.wantFraction, gotFraction)
+ require.NoError(t, err)
+ require.NotNil(t, less)
+ pair1LessPair2 := less(pair1, pair2)
+ assert.Equal(t, test.pair1LessPair2, pair1LessPair2)
+ pair2LessPair1 := less(pair2, pair1)
+ assert.Equal(t, test.pair2LessPair1, pair2LessPair1)
+ })
+ }
+
+}
diff --git a/fs/sync/rc.go b/fs/sync/rc.go
new file mode 100644
index 0000000..6577e49
--- /dev/null
+++ b/fs/sync/rc.go
@@ -0,0 +1,61 @@
+package sync
+
+import (
+ "context"
+
+ "github.com/rclone/rclone/fs/rc"
+)
+
+func init() {
+ for _, name := range []string{"sync", "copy", "move"} {
+ moveHelp := ""
+ if name == "move" {
+ moveHelp = "- deleteEmptySrcDirs - delete empty src directories if set\n"
+ }
+ rc.Add(rc.Call{
+ Path: "sync/" + name,
+ AuthRequired: true,
+ Fn: func(ctx context.Context, in rc.Params) (rc.Params, error) {
+ return rcSyncCopyMove(ctx, in, name)
+ },
+ Title: name + " a directory from source remote to destination remote",
+ Help: `This takes the following parameters:
+
+- srcFs - a remote name string e.g. "drive:src" for the source
+- dstFs - a remote name string e.g. "drive:dst" for the destination
+- createEmptySrcDirs - create empty src directories on destination if set
+` + moveHelp + `
+
+See the [` + name + `](/commands/rclone_` + name + `/) command for more information on the above.`,
+ })
+ }
+}
+
+// Sync/Copy/Move a file
+func rcSyncCopyMove(ctx context.Context, in rc.Params, name string) (out rc.Params, err error) {
+ srcFs, err := rc.GetFsNamed(ctx, in, "srcFs")
+ if err != nil {
+ return nil, err
+ }
+ dstFs, err := rc.GetFsNamed(ctx, in, "dstFs")
+ if err != nil {
+ return nil, err
+ }
+ createEmptySrcDirs, err := in.GetBool("createEmptySrcDirs")
+ if rc.NotErrParamNotFound(err) {
+ return nil, err
+ }
+ switch name {
+ case "sync":
+ return nil, Sync(ctx, dstFs, srcFs, createEmptySrcDirs)
+ case "copy":
+ return nil, CopyDir(ctx, dstFs, srcFs, createEmptySrcDirs)
+ case "move":
+ deleteEmptySrcDirs, err := in.GetBool("deleteEmptySrcDirs")
+ if rc.NotErrParamNotFound(err) {
+ return nil, err
+ }
+ return nil, MoveDir(ctx, dstFs, srcFs, deleteEmptySrcDirs, createEmptySrcDirs)
+ }
+ panic("unknown rcSyncCopyMove type")
+}
diff --git a/fs/sync/rc_test.go b/fs/sync/rc_test.go
new file mode 100644
index 0000000..a6b66e7
--- /dev/null
+++ b/fs/sync/rc_test.go
@@ -0,0 +1,97 @@
+package sync
+
+import (
+ "context"
+ "testing"
+
+ _ "github.com/mewsen/rclone-studip-backend-oot/backend/studip"
+ "github.com/rclone/rclone/fs/cache"
+ "github.com/rclone/rclone/fs/rc"
+ "github.com/rclone/rclone/fstest"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func rcNewRun(t *testing.T, method string) (*fstest.Run, *rc.Call) {
+ if *fstest.RemoteName != "" {
+ t.Skip("Skipping test on non local remote")
+ }
+ r := fstest.NewRun(t)
+ call := rc.Calls.Get(method)
+ assert.NotNil(t, call)
+ cache.Put(r.LocalName, r.Flocal)
+ cache.Put(r.FremoteName, r.Fremote)
+ return r, call
+}
+
+// sync/copy: copy a directory from source remote to destination remote
+func TestRcCopy(t *testing.T) {
+ r, call := rcNewRun(t, "sync/copy")
+ r.Mkdir(context.Background(), r.Fremote)
+
+ file1 := r.WriteBoth(context.Background(), "file1", "file1 contents", t1)
+ file2 := r.WriteFile("subdir/file2", "file2 contents", t2)
+ file3 := r.WriteObject(context.Background(), "subdir/subsubdir/file3", "file3 contents", t3)
+
+ r.CheckLocalItems(t, file1, file2)
+ r.CheckRemoteItems(t, file1, file3)
+
+ in := rc.Params{
+ "srcFs": r.LocalName,
+ "dstFs": r.FremoteName,
+ }
+ out, err := call.Fn(context.Background(), in)
+ require.NoError(t, err)
+ assert.Equal(t, rc.Params(nil), out)
+
+ r.CheckLocalItems(t, file1, file2)
+ r.CheckRemoteItems(t, file1, file2, file3)
+}
+
+// sync/move: move a directory from source remote to destination remote
+func TestRcMove(t *testing.T) {
+ r, call := rcNewRun(t, "sync/move")
+ r.Mkdir(context.Background(), r.Fremote)
+
+ file1 := r.WriteBoth(context.Background(), "file1", "file1 contents", t1)
+ file2 := r.WriteFile("subdir/file2", "file2 contents", t2)
+ file3 := r.WriteObject(context.Background(), "subdir/subsubdir/file3", "file3 contents", t3)
+
+ r.CheckLocalItems(t, file1, file2)
+ r.CheckRemoteItems(t, file1, file3)
+
+ in := rc.Params{
+ "srcFs": r.LocalName,
+ "dstFs": r.FremoteName,
+ }
+ out, err := call.Fn(context.Background(), in)
+ require.NoError(t, err)
+ assert.Equal(t, rc.Params(nil), out)
+
+ r.CheckLocalItems(t)
+ r.CheckRemoteItems(t, file1, file2, file3)
+}
+
+// sync/sync: sync a directory from source remote to destination remote
+func TestRcSync(t *testing.T) {
+ r, call := rcNewRun(t, "sync/sync")
+ r.Mkdir(context.Background(), r.Fremote)
+
+ file1 := r.WriteBoth(context.Background(), "file1", "file1 contents", t1)
+ file2 := r.WriteFile("subdir/file2", "file2 contents", t2)
+ file3 := r.WriteObject(context.Background(), "subdir/subsubdir/file3", "file3 contents", t3)
+
+ r.CheckLocalItems(t, file1, file2)
+ r.CheckRemoteItems(t, file1, file3)
+
+ in := rc.Params{
+ "srcFs": r.LocalName,
+ "dstFs": r.FremoteName,
+ }
+ out, err := call.Fn(context.Background(), in)
+ require.NoError(t, err)
+ assert.Equal(t, rc.Params(nil), out)
+
+ r.CheckLocalItems(t, file1, file2)
+ r.CheckRemoteItems(t, file1, file2)
+}
diff --git a/fs/sync/sync.go b/fs/sync/sync.go
new file mode 100644
index 0000000..a425345
--- /dev/null
+++ b/fs/sync/sync.go
@@ -0,0 +1,1423 @@
+// Package sync is the implementation of sync/copy/move
+package sync
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "path"
+ "slices"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/fs/accounting"
+ "github.com/rclone/rclone/fs/filter"
+ "github.com/rclone/rclone/fs/fserrors"
+ "github.com/rclone/rclone/fs/hash"
+ "github.com/rclone/rclone/fs/march"
+ "github.com/rclone/rclone/fs/operations"
+ "github.com/rclone/rclone/lib/errcount"
+ "github.com/rclone/rclone/lib/transform"
+ "golang.org/x/sync/errgroup"
+)
+
+// ErrorMaxDurationReached defines error when transfer duration is reached
+// Used for checking on exit and matching to correct exit code.
+var ErrorMaxDurationReached = errors.New("max transfer duration reached as set by --max-duration")
+
+// ErrorMaxDurationReachedFatal is returned from when the max
+// duration limit is reached.
+var ErrorMaxDurationReachedFatal = fserrors.FatalError(ErrorMaxDurationReached)
+
+type syncCopyMove struct {
+ // parameters
+ fdst fs.Fs
+ fsrc fs.Fs
+ deleteMode fs.DeleteMode // how we are doing deletions
+ DoMove bool
+ copyEmptySrcDirs bool
+ deleteEmptySrcDirs bool
+ dir string
+ // internal state
+ ci *fs.ConfigInfo // global config
+ fi *filter.Filter // filter config
+ ctx context.Context // internal context for controlling go-routines
+ cancel func() // cancel the context
+ inCtx context.Context // internal context for controlling march
+ inCancel func() // cancel the march context
+ noTraverse bool // if set don't traverse the dst
+ noCheckDest bool // if set transfer all objects regardless without checking dst
+ noUnicodeNormalization bool // don't normalize unicode characters in filenames
+ deletersWg sync.WaitGroup // for delete before go routine
+ deleteFilesCh chan fs.Object // channel to receive deletes if delete before
+ trackRenames bool // set if we should do server-side renames
+ trackRenamesStrategy trackRenamesStrategy // strategies used for tracking renames
+ dstFilesMu sync.Mutex // protect dstFiles
+ dstFiles map[string]fs.Object // dst files, always filled
+ srcFiles map[string]fs.Object // src files, only used if deleteBefore
+ srcFilesChan chan fs.Object // passes src objects
+ srcFilesResult chan error // error result of src listing
+ dstFilesResult chan error // error result of dst listing
+ dstEmptyDirsMu sync.Mutex // protect dstEmptyDirs
+ dstEmptyDirs map[string]fs.DirEntry // potentially empty directories
+ srcEmptyDirsMu sync.Mutex // protect srcEmptyDirs
+ srcEmptyDirs map[string]fs.DirEntry // potentially empty directories
+ srcMoveEmptyDirs map[string]fs.DirEntry // potentially empty directories when moving files out of them
+ checkerWg sync.WaitGroup // wait for checkers
+ toBeChecked *pipe // checkers channel
+ transfersWg sync.WaitGroup // wait for transfers
+ toBeUploaded *pipe // copiers channel
+ errorMu sync.Mutex // Mutex covering the errors variables
+ err error // normal error from copy process
+ noRetryErr error // error with NoRetry set
+ fatalErr error // fatal error
+ commonHash hash.Type // common hash type between src and dst
+ modifyWindow time.Duration // modify window between fsrc, fdst
+ renameMapMu sync.Mutex // mutex to protect the below
+ renameMap map[string][]fs.Object // dst files by hash - only used by trackRenames
+ renamerWg sync.WaitGroup // wait for renamers
+ toBeRenamed *pipe // renamers channel
+ trackRenamesWg sync.WaitGroup // wg for background track renames
+ trackRenamesCh chan fs.Object // objects are pumped in here
+ renameCheck []fs.Object // accumulate files to check for rename here
+ compareCopyDest []fs.Fs // place to check for files to server side copy
+ backupDir fs.Fs // place to store overwrites/deletes
+ checkFirst bool // if set run all the checkers before starting transfers
+ maxDurationEndTime time.Time // end time if --max-duration is set
+ logger operations.LoggerFn // LoggerFn used to report the results of a sync (or bisync) to an io.Writer
+ usingLogger bool // whether we are using logger
+ setDirMetadata bool // if set we set the directory metadata
+ setDirModTime bool // if set we set the directory modtimes
+ setDirModTimeAfter bool // if set we set the directory modtimes at the end of the sync
+ setDirModTimeMu sync.Mutex // protect setDirModTimes and modifiedDirs
+ setDirModTimes []setDirModTime // directories that need their modtime set
+ setDirModTimesMaxLevel int // max level of the directories to set
+ modifiedDirs map[string]struct{} // dirs with changed contents (if s.setDirModTimeAfter)
+ allowOverlap bool // whether we allow src and dst to overlap (i.e. for convmv)
+}
+
+// For keeping track of delayed modtime sets
+type setDirModTime struct {
+ src fs.Directory
+ dst fs.Directory
+ dir string
+ modTime time.Time
+ level int // the level of the directory, 0 is root
+}
+
+type trackRenamesStrategy byte
+
+const (
+ trackRenamesStrategyHash trackRenamesStrategy = 1 << iota
+ trackRenamesStrategyModtime
+ trackRenamesStrategyLeaf
+)
+
+func (strategy trackRenamesStrategy) hash() bool {
+ return (strategy & trackRenamesStrategyHash) != 0
+}
+
+func (strategy trackRenamesStrategy) modTime() bool {
+ return (strategy & trackRenamesStrategyModtime) != 0
+}
+
+func (strategy trackRenamesStrategy) leaf() bool {
+ return (strategy & trackRenamesStrategyLeaf) != 0
+}
+
+func newSyncCopyMove(ctx context.Context, fdst, fsrc fs.Fs, deleteMode fs.DeleteMode, DoMove bool, deleteEmptySrcDirs bool, copyEmptySrcDirs bool, allowOverlap bool) (*syncCopyMove, error) {
+ if (deleteMode != fs.DeleteModeOff || DoMove) && operations.OverlappingFilterCheck(ctx, fdst, fsrc) && !allowOverlap {
+ return nil, fserrors.FatalError(fs.ErrorOverlapping)
+ }
+ ci := fs.GetConfig(ctx)
+ fi := filter.GetConfig(ctx)
+ s := &syncCopyMove{
+ ci: ci,
+ fi: fi,
+ fdst: fdst,
+ fsrc: fsrc,
+ deleteMode: deleteMode,
+ DoMove: DoMove,
+ copyEmptySrcDirs: copyEmptySrcDirs,
+ deleteEmptySrcDirs: deleteEmptySrcDirs,
+ dir: "",
+ srcFilesChan: make(chan fs.Object, ci.Checkers+ci.Transfers),
+ srcFilesResult: make(chan error, 1),
+ dstFilesResult: make(chan error, 1),
+ dstEmptyDirs: make(map[string]fs.DirEntry),
+ srcEmptyDirs: make(map[string]fs.DirEntry),
+ srcMoveEmptyDirs: make(map[string]fs.DirEntry),
+ noTraverse: ci.NoTraverse,
+ noCheckDest: ci.NoCheckDest,
+ noUnicodeNormalization: ci.NoUnicodeNormalization,
+ deleteFilesCh: make(chan fs.Object, ci.Checkers),
+ trackRenames: ci.TrackRenames,
+ commonHash: fsrc.Hashes().Overlap(fdst.Hashes()).GetOne(),
+ modifyWindow: fs.GetModifyWindow(ctx, fsrc, fdst),
+ trackRenamesCh: make(chan fs.Object, ci.Checkers),
+ checkFirst: ci.CheckFirst,
+ setDirMetadata: ci.Metadata && fsrc.Features().ReadDirMetadata && fdst.Features().WriteDirMetadata,
+ setDirModTime: (!ci.NoUpdateDirModTime && fsrc.Features().CanHaveEmptyDirectories) && (fdst.Features().WriteDirSetModTime || fdst.Features().MkdirMetadata != nil || fdst.Features().DirSetModTime != nil),
+ setDirModTimeAfter: !ci.NoUpdateDirModTime && (!copyEmptySrcDirs || fsrc.Features().CanHaveEmptyDirectories && fdst.Features().DirModTimeUpdatesOnWrite),
+ modifiedDirs: make(map[string]struct{}),
+ allowOverlap: allowOverlap,
+ }
+
+ s.logger, s.usingLogger = operations.GetLogger(ctx)
+
+ if deleteMode == fs.DeleteModeOff {
+ loggerOpt := operations.GetLoggerOpt(ctx)
+ loggerOpt.DeleteModeOff = true
+ loggerOpt.LoggerFn = s.logger
+ ctx = operations.WithLoggerOpt(ctx, loggerOpt)
+ }
+
+ backlog := ci.MaxBacklog
+ if s.checkFirst {
+ fs.Infof(s.fdst, "Running all checks before starting transfers")
+ backlog = -1
+ }
+ var err error
+ s.toBeChecked, err = newPipe(ci.OrderBy, accounting.Stats(ctx).SetCheckQueue, backlog)
+ if err != nil {
+ return nil, err
+ }
+ s.toBeUploaded, err = newPipe(ci.OrderBy, accounting.Stats(ctx).SetTransferQueue, backlog)
+ if err != nil {
+ return nil, err
+ }
+ s.toBeRenamed, err = newPipe(ci.OrderBy, accounting.Stats(ctx).SetRenameQueue, backlog)
+ if err != nil {
+ return nil, err
+ }
+ if ci.MaxDuration > 0 {
+ s.maxDurationEndTime = time.Now().Add(time.Duration(ci.MaxDuration))
+ fs.Infof(s.fdst, "Transfer session %v deadline: %s", ci.CutoffMode, s.maxDurationEndTime.Format("2006/01/02 15:04:05"))
+ }
+ // If a max session duration has been defined add a deadline
+ // to the main context if cutoff mode is hard. This will cut
+ // the transfers off.
+ if !s.maxDurationEndTime.IsZero() && ci.CutoffMode == fs.CutoffModeHard {
+ s.ctx, s.cancel = context.WithDeadline(ctx, s.maxDurationEndTime)
+ } else {
+ s.ctx, s.cancel = context.WithCancel(ctx)
+ }
+ // Input context - cancel this for graceful stop.
+ //
+ // If a max session duration has been defined add a deadline
+ // to the input context if cutoff mode is graceful or soft.
+ // This won't stop the transfers but will cut the
+ // list/check/transfer pipelines.
+ if !s.maxDurationEndTime.IsZero() && ci.CutoffMode != fs.CutoffModeHard {
+ s.inCtx, s.inCancel = context.WithDeadline(s.ctx, s.maxDurationEndTime)
+ } else {
+ s.inCtx, s.inCancel = context.WithCancel(s.ctx)
+ }
+ if s.noTraverse && s.deleteMode != fs.DeleteModeOff {
+ if !fi.HaveFilesFrom() {
+ fs.Errorf(nil, "Ignoring --no-traverse with sync")
+ }
+ s.noTraverse = false
+ }
+ s.trackRenamesStrategy, err = parseTrackRenamesStrategy(ci.TrackRenamesStrategy)
+ if err != nil {
+ return nil, err
+ }
+ if s.noCheckDest {
+ if s.deleteMode != fs.DeleteModeOff {
+ return nil, errors.New("can't use --no-check-dest with sync: use copy instead")
+ }
+ if ci.Immutable {
+ return nil, errors.New("can't use --no-check-dest with --immutable")
+ }
+ if s.backupDir != nil {
+ return nil, errors.New("can't use --no-check-dest with --backup-dir")
+ }
+ }
+ if s.trackRenames {
+ // Don't track renames for remotes without server-side move support.
+ if !operations.CanServerSideMove(fdst) {
+ fs.Errorf(fdst, "Ignoring --track-renames as the destination does not support server-side move or copy")
+ s.trackRenames = false
+ }
+ if s.trackRenamesStrategy.hash() && s.commonHash == hash.None {
+ fs.Errorf(fdst, "Ignoring --track-renames as the source and destination do not have a common hash")
+ s.trackRenames = false
+ }
+
+ if s.trackRenamesStrategy.modTime() && s.modifyWindow == fs.ModTimeNotSupported {
+ fs.Errorf(fdst, "Ignoring --track-renames as either the source or destination do not support modtime")
+ s.trackRenames = false
+ }
+
+ if s.deleteMode == fs.DeleteModeOff {
+ fs.Errorf(fdst, "Ignoring --track-renames as it doesn't work with copy or move, only sync")
+ s.trackRenames = false
+ }
+ }
+ if s.trackRenames {
+ // track renames needs delete after
+ if s.deleteMode != fs.DeleteModeOff {
+ s.deleteMode = fs.DeleteModeAfter
+ }
+ if s.noTraverse {
+ fs.Errorf(nil, "Ignoring --no-traverse with --track-renames")
+ s.noTraverse = false
+ }
+ }
+ // Make Fs for --backup-dir if required
+ if ci.BackupDir != "" || ci.Suffix != "" {
+ var err error
+ s.backupDir, err = operations.BackupDir(ctx, fdst, fsrc, "")
+ if err != nil {
+ return nil, err
+ }
+ }
+ if len(ci.CompareDest) > 0 {
+ var err error
+ s.compareCopyDest, err = operations.GetCompareDest(ctx)
+ if err != nil {
+ return nil, err
+ }
+ } else if len(ci.CopyDest) > 0 {
+ var err error
+ s.compareCopyDest, err = operations.GetCopyDest(ctx, fdst)
+ if err != nil {
+ return nil, err
+ }
+ }
+ return s, nil
+}
+
+// Check to see if the context has been cancelled
+func (s *syncCopyMove) aborting() bool {
+ return s.ctx.Err() != nil
+}
+
+// This reads the map and pumps it into the channel passed in, closing
+// the channel at the end
+func (s *syncCopyMove) pumpMapToChan(files map[string]fs.Object, out chan<- fs.Object) {
+outer:
+ for _, o := range files {
+ if s.aborting() {
+ break outer
+ }
+ select {
+ case out <- o:
+ case <-s.ctx.Done():
+ break outer
+ }
+ }
+ close(out)
+ s.srcFilesResult <- nil
+}
+
+// This checks the types of errors returned while copying files
+func (s *syncCopyMove) processError(err error) {
+ if err == nil {
+ return
+ }
+ if err == context.DeadlineExceeded {
+ err = fserrors.NoRetryError(err)
+ } else if err == accounting.ErrorMaxTransferLimitReachedGraceful {
+ if s.inCtx.Err() == nil {
+ fs.Logf(nil, "%v - stopping transfers", err)
+ // Cancel the march and stop the pipes
+ s.inCancel()
+ }
+ } else if err == context.Canceled && s.inCtx.Err() != nil {
+ // Ignore context Canceled if we have called s.inCancel()
+ return
+ }
+ s.errorMu.Lock()
+ defer s.errorMu.Unlock()
+ switch {
+ case fserrors.IsFatalError(err):
+ if !s.aborting() {
+ fs.Errorf(nil, "Cancelling sync due to fatal error: %v", err)
+ s.cancel()
+ }
+ s.fatalErr = err
+ case fserrors.IsNoRetryError(err):
+ s.noRetryErr = err
+ default:
+ s.err = err
+ }
+}
+
+// Returns the current error (if any) in the order of precedence
+//
+// fatalErr
+// normal error
+// noRetryErr
+func (s *syncCopyMove) currentError() error {
+ s.errorMu.Lock()
+ defer s.errorMu.Unlock()
+ if s.fatalErr != nil {
+ return s.fatalErr
+ }
+ if s.err != nil {
+ return s.err
+ }
+ return s.noRetryErr
+}
+
+// pairChecker reads Objects~s on in send to out if they need transferring.
+//
+// FIXME potentially doing lots of hashes at once
+func (s *syncCopyMove) pairChecker(in *pipe, out *pipe, fraction int, wg *sync.WaitGroup) {
+ defer wg.Done()
+ for {
+ pair, ok := in.GetMax(s.inCtx, fraction)
+ if !ok {
+ return
+ }
+ src := pair.Src
+ var err error
+ tr := accounting.Stats(s.ctx).NewCheckingTransfer(src, "checking")
+ // Check to see if can store this
+ if src.Storable() {
+ needTransfer := operations.NeedTransfer(s.ctx, pair.Dst, pair.Src)
+ if needTransfer {
+ NoNeedTransfer, err := operations.CompareOrCopyDest(s.ctx, s.fdst, pair.Dst, pair.Src, s.compareCopyDest, s.backupDir)
+ if err != nil {
+ s.processError(err)
+ s.logger(s.ctx, operations.TransferError, pair.Src, pair.Dst, err)
+ }
+ if NoNeedTransfer {
+ needTransfer = false
+ }
+ }
+ // Fix case for case insensitive filesystems
+ if s.ci.FixCase && !s.ci.Immutable && src.Remote() != pair.Dst.Remote() {
+ if newDst, err := operations.Move(s.ctx, s.fdst, nil, src.Remote(), pair.Dst); err != nil {
+ fs.Errorf(pair.Dst, "Error while attempting to rename to %s: %v", src.Remote(), err)
+ s.processError(err)
+ } else {
+ fs.Infof(pair.Dst, "Fixed case by renaming to: %s", src.Remote())
+ pair.Dst = newDst
+ }
+ }
+ if needTransfer {
+ // If files are treated as immutable, fail if destination exists and does not match
+ if s.ci.Immutable && pair.Dst != nil {
+ err := fs.CountError(s.ctx, fserrors.NoRetryError(fs.ErrorImmutableModified))
+ fs.Errorf(pair.Dst, "Source and destination exist but do not match: %v", err)
+ s.processError(err)
+ } else {
+ if pair.Dst != nil {
+ s.markDirModifiedObject(pair.Dst)
+ } else {
+ s.markDirModifiedObject(src)
+ }
+ // If destination already exists, then we must move it into --backup-dir if required
+ if pair.Dst != nil && s.backupDir != nil {
+ err := operations.MoveBackupDir(s.ctx, s.backupDir, pair.Dst)
+ if err != nil {
+ s.processError(err)
+ s.logger(s.ctx, operations.TransferError, pair.Src, pair.Dst, err)
+ } else {
+ // If successful zero out the dst as it is no longer there and copy the file
+ pair.Dst = nil
+ ok = out.Put(s.inCtx, pair)
+ if !ok {
+ return
+ }
+ }
+ } else {
+ ok = out.Put(s.inCtx, pair)
+ if !ok {
+ return
+ }
+ }
+ }
+ } else {
+ // If moving need to delete the files we don't need to copy
+ if s.DoMove {
+ // Delete src if no error on copy
+ if operations.SameObject(src, pair.Dst) {
+ fs.Logf(src, "Not removing source file as it is the same file as the destination")
+ } else if s.ci.IgnoreExisting {
+ fs.Debugf(src, "Not removing source file as destination file exists and --ignore-existing is set")
+ } else if s.checkFirst && s.ci.OrderBy != "" {
+ // If we want perfect ordering then use the transfers to delete the file
+ //
+ // We send src == dst, to say we want the src deleted
+ ok = out.Put(s.inCtx, fs.ObjectPair{Src: src, Dst: src})
+ if !ok {
+ return
+ }
+ } else {
+ deleteFileErr := operations.DeleteFile(s.ctx, src)
+ s.processError(deleteFileErr)
+ s.logger(s.ctx, operations.TransferError, pair.Src, pair.Dst, deleteFileErr)
+ }
+ }
+ }
+ }
+ tr.Done(s.ctx, err)
+ }
+}
+
+// pairRenamer reads Objects~s on in and attempts to rename them,
+// otherwise it sends them out if they need transferring.
+func (s *syncCopyMove) pairRenamer(in *pipe, out *pipe, fraction int, wg *sync.WaitGroup) {
+ defer wg.Done()
+ for {
+ pair, ok := in.GetMax(s.inCtx, fraction)
+ if !ok {
+ return
+ }
+ src := pair.Src
+ if !s.tryRename(src) {
+ // pass on if not renamed
+ fs.Debugf(src, "Need to transfer - No matching file found at Destination")
+ ok = out.Put(s.inCtx, pair)
+ if !ok {
+ return
+ }
+ }
+ }
+}
+
+// pairCopyOrMove reads Objects on in and moves or copies them.
+func (s *syncCopyMove) pairCopyOrMove(ctx context.Context, in *pipe, fdst fs.Fs, fraction int, wg *sync.WaitGroup) {
+ defer wg.Done()
+ var err error
+ for {
+ pair, ok := in.GetMax(s.inCtx, fraction)
+ if !ok {
+ return
+ }
+ src := pair.Src
+ dst := pair.Dst
+ if s.DoMove {
+ if src != dst {
+ _, err = operations.MoveTransfer(ctx, fdst, dst, src.Remote(), src)
+ } else {
+ // src == dst signals delete the src
+ err = operations.DeleteFile(ctx, src)
+ }
+ } else {
+ _, err = operations.Copy(ctx, fdst, dst, src.Remote(), src)
+ }
+ s.processError(err)
+ if err != nil {
+ s.logger(ctx, operations.TransferError, src, dst, err)
+ }
+ }
+}
+
+// This starts the background checkers.
+func (s *syncCopyMove) startCheckers() {
+ s.checkerWg.Add(s.ci.Checkers)
+ for i := range s.ci.Checkers {
+ fraction := (100 * i) / s.ci.Checkers
+ go s.pairChecker(s.toBeChecked, s.toBeUploaded, fraction, &s.checkerWg)
+ }
+}
+
+// This stops the background checkers
+func (s *syncCopyMove) stopCheckers() {
+ s.toBeChecked.Close()
+ fs.Debugf(s.fdst, "Waiting for checks to finish")
+ s.checkerWg.Wait()
+}
+
+// This starts the background transfers
+func (s *syncCopyMove) startTransfers() {
+ s.transfersWg.Add(s.ci.Transfers)
+ for i := range s.ci.Transfers {
+ fraction := (100 * i) / s.ci.Transfers
+ go s.pairCopyOrMove(s.ctx, s.toBeUploaded, s.fdst, fraction, &s.transfersWg)
+ }
+}
+
+// This stops the background transfers
+func (s *syncCopyMove) stopTransfers() {
+ s.toBeUploaded.Close()
+ fs.Debugf(s.fdst, "Waiting for transfers to finish")
+ s.transfersWg.Wait()
+}
+
+// This starts the background renamers.
+func (s *syncCopyMove) startRenamers() {
+ if !s.trackRenames {
+ return
+ }
+ s.renamerWg.Add(s.ci.Checkers)
+ for i := range s.ci.Checkers {
+ fraction := (100 * i) / s.ci.Checkers
+ go s.pairRenamer(s.toBeRenamed, s.toBeUploaded, fraction, &s.renamerWg)
+ }
+}
+
+// This stops the background renamers
+func (s *syncCopyMove) stopRenamers() {
+ if !s.trackRenames {
+ return
+ }
+ s.toBeRenamed.Close()
+ fs.Debugf(s.fdst, "Waiting for renames to finish")
+ s.renamerWg.Wait()
+}
+
+// This starts the collection of possible renames
+func (s *syncCopyMove) startTrackRenames() {
+ if !s.trackRenames {
+ return
+ }
+ s.trackRenamesWg.Add(1)
+ go func() {
+ defer s.trackRenamesWg.Done()
+ for o := range s.trackRenamesCh {
+ s.renameCheck = append(s.renameCheck, o)
+ }
+ }()
+}
+
+// This stops the background rename collection
+func (s *syncCopyMove) stopTrackRenames() {
+ if !s.trackRenames {
+ return
+ }
+ close(s.trackRenamesCh)
+ s.trackRenamesWg.Wait()
+}
+
+// This starts the background deletion of files for --delete-during
+func (s *syncCopyMove) startDeleters() {
+ if s.deleteMode != fs.DeleteModeDuring && s.deleteMode != fs.DeleteModeOnly {
+ return
+ }
+ s.deletersWg.Add(1)
+ go func() {
+ defer s.deletersWg.Done()
+ err := operations.DeleteFilesWithBackupDir(s.ctx, s.deleteFilesCh, s.backupDir)
+ s.processError(err)
+ }()
+}
+
+// This stops the background deleters
+func (s *syncCopyMove) stopDeleters() {
+ if s.deleteMode != fs.DeleteModeDuring && s.deleteMode != fs.DeleteModeOnly {
+ return
+ }
+ close(s.deleteFilesCh)
+ s.deletersWg.Wait()
+}
+
+// This deletes the files in the dstFiles map. If checkSrcMap is set
+// then it checks to see if they exist first in srcFiles the source
+// file map, otherwise it unconditionally deletes them. If
+// checkSrcMap is clear then it assumes that the any source files that
+// have been found have been removed from dstFiles already.
+func (s *syncCopyMove) deleteFiles(checkSrcMap bool) error {
+ if accounting.Stats(s.ctx).Errored() && !s.ci.IgnoreErrors {
+ fs.Errorf(s.fdst, "%v", fs.ErrorNotDeleting)
+ // log all deletes as errors
+ for remote, o := range s.dstFiles {
+ if checkSrcMap {
+ _, exists := s.srcFiles[remote]
+ if exists {
+ continue
+ }
+ }
+ s.logger(s.ctx, operations.TransferError, nil, o, fs.ErrorNotDeleting)
+ }
+ return fs.ErrorNotDeleting
+ }
+
+ // Delete the spare files
+ toDelete := make(fs.ObjectsChan, s.ci.Checkers)
+ go func() {
+ outer:
+ for remote, o := range s.dstFiles {
+ if checkSrcMap {
+ _, exists := s.srcFiles[remote]
+ if exists {
+ continue
+ }
+ }
+ if s.aborting() {
+ break
+ }
+ select {
+ case <-s.ctx.Done():
+ break outer
+ case toDelete <- o:
+ }
+ }
+ close(toDelete)
+ }()
+ return operations.DeleteFilesWithBackupDir(s.ctx, toDelete, s.backupDir)
+}
+
+// This deletes the empty directories in the slice passed in. It
+// ignores any errors deleting directories
+func (s *syncCopyMove) deleteEmptyDirectories(ctx context.Context, f fs.Fs, entriesMap map[string]fs.DirEntry) error {
+ if len(entriesMap) == 0 {
+ return nil
+ }
+ if accounting.Stats(ctx).Errored() && !s.ci.IgnoreErrors {
+ fs.Errorf(f, "%v", fs.ErrorNotDeletingDirs)
+ return fs.ErrorNotDeletingDirs
+ }
+
+ var entries fs.DirEntries
+ for _, entry := range entriesMap {
+ entries = append(entries, entry)
+ }
+ // Now delete the empty directories starting from the longest path
+ sort.Sort(entries)
+ var errorCount int
+ var okCount int
+ for i := len(entries) - 1; i >= 0; i-- {
+ entry := entries[i]
+ dir, ok := entry.(fs.Directory)
+ if ok {
+ // TryRmdir only deletes empty directories
+ err := operations.TryRmdir(ctx, f, dir.Remote())
+ if err != nil {
+ fs.Debugf(fs.LogDirName(f, dir.Remote()), "Failed to Rmdir: %v", err)
+ errorCount++
+ } else {
+ okCount++
+ }
+ } else {
+ fs.Errorf(f, "Not a directory: %v", entry)
+ }
+ }
+ if errorCount > 0 {
+ fs.Debugf(f, "failed to delete %d directories", errorCount)
+ }
+ if okCount > 0 {
+ fs.Debugf(f, "deleted %d directories", okCount)
+ }
+ return nil
+}
+
+// mark the parent of entry as not empty and if entry is a directory mark it as potentially empty.
+func (s *syncCopyMove) markParentNotEmpty(entry fs.DirEntry) {
+ s.srcEmptyDirsMu.Lock()
+ defer s.srcEmptyDirsMu.Unlock()
+ // Mark entry as potentially empty if it is a directory
+ _, isDir := entry.(fs.Directory)
+ if isDir {
+ s.srcEmptyDirs[entry.Remote()] = entry
+ // if DoMove and --delete-empty-src-dirs flag is set then record the parent but
+ // don't remove any as we are about to move files out of them them making the
+ // directory empty.
+ if s.DoMove && s.deleteEmptySrcDirs {
+ s.srcMoveEmptyDirs[entry.Remote()] = entry
+ }
+ }
+ parentDir := path.Dir(entry.Remote())
+ if isDir && s.copyEmptySrcDirs {
+ // Mark its parent as not empty
+ if parentDir == "." {
+ parentDir = ""
+ }
+ delete(s.srcEmptyDirs, parentDir)
+ }
+ if !isDir {
+ // Mark ALL its parents as not empty
+ for {
+ if parentDir == "." {
+ parentDir = ""
+ }
+ delete(s.srcEmptyDirs, parentDir)
+ if parentDir == "" || parentDir == "/" {
+ break
+ }
+ parentDir = path.Dir(parentDir)
+ }
+ }
+}
+
+// parseTrackRenamesStrategy turns a config string into a trackRenamesStrategy
+func parseTrackRenamesStrategy(strategies string) (strategy trackRenamesStrategy, err error) {
+ if len(strategies) == 0 {
+ return strategy, nil
+ }
+ for s := range strings.SplitSeq(strategies, ",") {
+ switch s {
+ case "hash":
+ strategy |= trackRenamesStrategyHash
+ case "modtime":
+ strategy |= trackRenamesStrategyModtime
+ case "leaf":
+ strategy |= trackRenamesStrategyLeaf
+ case "size":
+ // ignore
+ default:
+ return strategy, fmt.Errorf("unknown track renames strategy %q", s)
+ }
+ }
+ return strategy, nil
+}
+
+// renameID makes a string with the size and the other identifiers of the requested rename strategies
+//
+// it may return an empty string in which case no hash could be made
+func (s *syncCopyMove) renameID(obj fs.Object, renamesStrategy trackRenamesStrategy, precision time.Duration) string {
+ var builder strings.Builder
+
+ fmt.Fprintf(&builder, "%d", obj.Size())
+
+ if renamesStrategy.hash() {
+ var err error
+ hash, err := obj.Hash(s.ctx, s.commonHash)
+ if err != nil {
+ fs.Debugf(obj, "Hash failed: %v", err)
+ return ""
+ }
+ if hash == "" {
+ return ""
+ }
+
+ builder.WriteRune(',')
+ builder.WriteString(hash)
+ }
+
+ // for renamesStrategy.modTime() we don't add to the hash but we check the times in
+ // popRenameMap
+
+ if renamesStrategy.leaf() {
+ builder.WriteRune(',')
+ builder.WriteString(path.Base(obj.Remote()))
+ }
+
+ return builder.String()
+}
+
+// pushRenameMap adds the object with hash to the rename map
+func (s *syncCopyMove) pushRenameMap(hash string, obj fs.Object) {
+ s.renameMapMu.Lock()
+ s.renameMap[hash] = append(s.renameMap[hash], obj)
+ s.renameMapMu.Unlock()
+}
+
+// popRenameMap finds the object with hash and pop the first match from
+// renameMap or returns nil if not found.
+func (s *syncCopyMove) popRenameMap(hash string, src fs.Object) (dst fs.Object) {
+ s.renameMapMu.Lock()
+ defer s.renameMapMu.Unlock()
+ dsts, ok := s.renameMap[hash]
+ if ok && len(dsts) > 0 {
+ // Element to remove
+ i := 0
+
+ // If using track renames strategy modtime then we need to check the modtimes here
+ if s.trackRenamesStrategy.modTime() {
+ i = -1
+ srcModTime := src.ModTime(s.ctx)
+ for j, dst := range dsts {
+ dstModTime := dst.ModTime(s.ctx)
+ dt := dstModTime.Sub(srcModTime)
+ if dt < s.modifyWindow && dt > -s.modifyWindow {
+ i = j
+ break
+ }
+ }
+ // If nothing matched then return nil
+ if i < 0 {
+ return nil
+ }
+ }
+
+ // Remove the entry and return it
+ dst = dsts[i]
+ dsts = slices.Delete(dsts, i, i+1)
+ if len(dsts) > 0 {
+ s.renameMap[hash] = dsts
+ } else {
+ delete(s.renameMap, hash)
+ }
+ }
+ return dst
+}
+
+// makeRenameMap builds a map of the destination files by hash that
+// match sizes in the slice of objects in s.renameCheck
+func (s *syncCopyMove) makeRenameMap() {
+ fs.Infof(s.fdst, "Making map for --track-renames")
+
+ // first make a map of possible sizes we need to check
+ possibleSizes := map[int64]struct{}{}
+ for _, obj := range s.renameCheck {
+ possibleSizes[obj.Size()] = struct{}{}
+ }
+
+ // pump all the dstFiles into in
+ in := make(chan fs.Object, s.ci.Checkers)
+ go s.pumpMapToChan(s.dstFiles, in)
+
+ // now make a map of size,hash for all dstFiles
+ s.renameMap = make(map[string][]fs.Object)
+ var wg sync.WaitGroup
+ wg.Add(s.ci.Checkers)
+ for range s.ci.Checkers {
+ go func() {
+ defer wg.Done()
+ for obj := range in {
+ // only create hash for dst fs.Object if its size could match
+ if _, found := possibleSizes[obj.Size()]; found {
+ tr := accounting.Stats(s.ctx).NewCheckingTransfer(obj, "renaming")
+ hash := s.renameID(obj, s.trackRenamesStrategy, s.modifyWindow)
+
+ if hash != "" {
+ s.pushRenameMap(hash, obj)
+ }
+
+ tr.Done(s.ctx, nil)
+ }
+ }
+ }()
+ }
+ wg.Wait()
+ fs.Infof(s.fdst, "Finished making map for --track-renames")
+}
+
+// tryRename renames an src object when doing track renames if
+// possible, it returns true if the object was renamed.
+func (s *syncCopyMove) tryRename(src fs.Object) bool {
+ // Calculate the hash of the src object
+ hash := s.renameID(src, s.trackRenamesStrategy, fs.GetModifyWindow(s.ctx, s.fsrc, s.fdst))
+
+ if hash == "" {
+ return false
+ }
+
+ // Get a match on fdst
+ dst := s.popRenameMap(hash, src)
+ if dst == nil {
+ return false
+ }
+
+ // Find dst object we are about to overwrite if it exists
+ dstOverwritten, _ := s.fdst.NewObject(s.ctx, src.Remote())
+
+ // Rename dst to have name src.Remote()
+ _, err := operations.Move(s.ctx, s.fdst, dstOverwritten, src.Remote(), dst)
+ if err != nil {
+ fs.Debugf(src, "Failed to rename to %q: %v", dst.Remote(), err)
+ return false
+ }
+
+ // remove file from dstFiles if present
+ s.dstFilesMu.Lock()
+ delete(s.dstFiles, dst.Remote())
+ s.dstFilesMu.Unlock()
+
+ fs.Infof(src, "Renamed from %q", dst.Remote())
+ return true
+}
+
+// Syncs fsrc into fdst
+//
+// If Delete is true then it deletes any files in fdst that aren't in fsrc
+//
+// If DoMove is true then files will be moved instead of copied.
+//
+// dir is the start directory, "" for root
+func (s *syncCopyMove) run() error {
+ if operations.Same(s.fdst, s.fsrc) && !s.allowOverlap {
+ fs.Errorf(s.fdst, "Nothing to do as source and destination are the same")
+ return nil
+ }
+
+ // Start background checking and transferring pipeline
+ s.startCheckers()
+ s.startRenamers()
+ if !s.checkFirst {
+ s.startTransfers()
+ }
+ s.startDeleters()
+ s.dstFiles = make(map[string]fs.Object)
+
+ s.startTrackRenames()
+
+ // set up a march over fdst and fsrc
+ m := &march.March{
+ Ctx: s.inCtx,
+ Fdst: s.fdst,
+ Fsrc: s.fsrc,
+ Dir: s.dir,
+ NoTraverse: s.noTraverse,
+ Callback: s,
+ DstIncludeAll: s.fi.Opt.DeleteExcluded,
+ NoCheckDest: s.noCheckDest,
+ NoUnicodeNormalization: s.noUnicodeNormalization,
+ }
+ s.processError(m.Run(s.ctx))
+
+ s.stopTrackRenames()
+ if s.trackRenames {
+ // Build the map of the remaining dstFiles by hash
+ s.makeRenameMap()
+ // Attempt renames for all the files which don't have a matching dst
+ for _, src := range s.renameCheck {
+ ok := s.toBeRenamed.Put(s.inCtx, fs.ObjectPair{Src: src, Dst: nil})
+ if !ok {
+ break
+ }
+ }
+ }
+
+ // Stop background checking and transferring pipeline
+ s.stopCheckers()
+ if s.checkFirst {
+ fs.Infof(s.fdst, "Checks finished, now starting transfers")
+ s.startTransfers()
+ }
+ s.stopRenamers()
+ s.stopTransfers()
+ s.stopDeleters()
+
+ // Delete files after
+ if s.deleteMode == fs.DeleteModeAfter {
+ if s.currentError() != nil && !s.ci.IgnoreErrors {
+ fs.Errorf(s.fdst, "%v", fs.ErrorNotDeleting)
+ } else {
+ s.processError(s.deleteFiles(false))
+ }
+ }
+
+ // Update modtimes for directories if necessary
+ if s.setDirModTime && s.setDirModTimeAfter {
+ s.processError(s.setDelayedDirModTimes(s.ctx))
+ }
+
+ // Prune empty directories
+ if s.deleteMode != fs.DeleteModeOff {
+ if s.currentError() != nil && !s.ci.IgnoreErrors {
+ fs.Errorf(s.fdst, "%v", fs.ErrorNotDeletingDirs)
+ } else {
+ s.processError(s.deleteEmptyDirectories(s.ctx, s.fdst, s.dstEmptyDirs))
+ }
+ }
+
+ // Delete empty fsrc subdirectories
+ // if DoMove and --delete-empty-src-dirs flag is set
+ if s.DoMove && s.deleteEmptySrcDirs {
+ // delete potentially empty subdirectories that were part of the move
+ s.processError(s.deleteEmptyDirectories(s.ctx, s.fsrc, s.srcMoveEmptyDirs))
+ }
+
+ // Read the error out of the contexts if there is one
+ s.processError(s.ctx.Err())
+ s.processError(s.inCtx.Err())
+
+ // If the duration was exceeded then add a Fatal Error so we don't retry
+ if !s.maxDurationEndTime.IsZero() && time.Since(s.maxDurationEndTime) > 0 {
+ fs.Errorf(s.fdst, "%v", ErrorMaxDurationReachedFatal)
+ s.processError(ErrorMaxDurationReachedFatal)
+ }
+
+ // Print nothing to transfer message if there were no transfers and no errors
+ if s.deleteMode != fs.DeleteModeOnly && accounting.Stats(s.ctx).GetTransfers() == 0 && s.currentError() == nil {
+ fs.Infof(nil, "There was nothing to transfer")
+ }
+
+ // cancel the contexts to free resources
+ s.inCancel()
+ s.cancel()
+ return s.currentError()
+}
+
+// DstOnly have an object which is in the destination only
+func (s *syncCopyMove) DstOnly(dst fs.DirEntry) (recurse bool) {
+ if s.deleteMode == fs.DeleteModeOff {
+ if s.usingLogger {
+ switch x := dst.(type) {
+ case fs.Object:
+ s.logger(s.ctx, operations.MissingOnSrc, nil, x, nil)
+ case fs.Directory:
+ // it's a directory that we'd normally skip, because we're not deleting anything on the dest
+ // however, to make sure every file is logged, we need to list it, so we need to return true here.
+ // we skip this when not using logger.
+ s.logger(s.ctx, operations.MissingOnSrc, nil, dst, fs.ErrorIsDir)
+ return true
+ }
+ }
+ return false
+ }
+ switch x := dst.(type) {
+ case fs.Object:
+ s.logger(s.ctx, operations.MissingOnSrc, nil, x, nil)
+ switch s.deleteMode {
+ case fs.DeleteModeAfter:
+ // record object as needs deleting
+ s.dstFilesMu.Lock()
+ s.dstFiles[x.Remote()] = x
+ s.dstFilesMu.Unlock()
+ case fs.DeleteModeDuring, fs.DeleteModeOnly:
+ select {
+ case <-s.ctx.Done():
+ return
+ case s.deleteFilesCh <- x:
+ }
+ default:
+ panic(fmt.Sprintf("unexpected delete mode %d", s.deleteMode))
+ }
+ case fs.Directory:
+ // Do the same thing to the entire contents of the directory
+ // Record directory as it is potentially empty and needs deleting
+ if s.fdst.Features().CanHaveEmptyDirectories {
+ s.dstEmptyDirsMu.Lock()
+ s.dstEmptyDirs[dst.Remote()] = dst
+ s.dstEmptyDirsMu.Unlock()
+ s.logger(s.ctx, operations.MissingOnSrc, nil, dst, fs.ErrorIsDir)
+ }
+ return true
+ default:
+ panic("Bad object in DirEntries")
+
+ }
+ return false
+}
+
+// keeps track of dirs with changed contents, to avoid setting modtimes on dirs that haven't changed
+func (s *syncCopyMove) markDirModified(dir string) {
+ if !s.setDirModTimeAfter {
+ return
+ }
+ s.setDirModTimeMu.Lock()
+ defer s.setDirModTimeMu.Unlock()
+ s.modifiedDirs[dir] = struct{}{}
+}
+
+// like markDirModified, but accepts an Object instead of a string.
+// the marked dir will be this object's parent.
+func (s *syncCopyMove) markDirModifiedObject(o fs.Object) {
+ dir := path.Dir(o.Remote())
+ if dir == "." {
+ dir = ""
+ }
+ s.markDirModified(dir)
+}
+
+// copyDirMetadata copies the src directory modTime or Metadata to dst
+// or f if nil. If dst is nil then it uses dir as the name of the new
+// directory.
+//
+// It returns the destination directory if possible. Note that this may
+// be nil.
+func (s *syncCopyMove) copyDirMetadata(ctx context.Context, f fs.Fs, dst fs.Directory, dir string, src fs.Directory) (newDst fs.Directory) {
+ var err error
+ if dst != nil && src.Remote() == dst.Remote() && operations.OverlappingFilterCheck(ctx, s.fdst, s.fsrc) {
+ return nil // src and dst can be the same in convmv
+ }
+ equal := operations.DirsEqual(ctx, src, dst, operations.DirsEqualOpt{ModifyWindow: s.modifyWindow, SetDirModtime: s.setDirModTime, SetDirMetadata: s.setDirMetadata})
+ if !s.setDirModTimeAfter && equal {
+ return nil
+ }
+ newDst = dst
+ if !equal {
+ if s.setDirMetadata && s.copyEmptySrcDirs {
+ newDst, err = operations.CopyDirMetadata(ctx, f, dst, dir, src)
+ } else if dst == nil && s.setDirModTime && s.copyEmptySrcDirs {
+ newDst, err = operations.MkdirModTime(ctx, f, dir, src.ModTime(ctx))
+ } else if dst == nil && s.copyEmptySrcDirs {
+ err = operations.Mkdir(ctx, f, dir)
+ } else if dst != nil && s.setDirModTime {
+ newDst, err = operations.SetDirModTime(ctx, f, dst, dir, src.ModTime(ctx))
+ }
+ }
+ if transform.Transforming(ctx) && newDst != nil && src.Remote() != newDst.Remote() {
+ s.markParentNotEmpty(src)
+ }
+ // If we need to set modtime after and we created a dir, then save it for later
+ if s.setDirModTime && s.setDirModTimeAfter && err == nil {
+ if newDst != nil {
+ dir = newDst.Remote()
+ }
+ level := strings.Count(dir, "/") + 1
+ // The root directory "" is at the top level
+ if dir == "" {
+ level = 0
+ }
+ s.setDirModTimeMu.Lock()
+ // Keep track of the maximum level inserted
+ if level > s.setDirModTimesMaxLevel {
+ s.setDirModTimesMaxLevel = level
+ }
+ set := setDirModTime{
+ src: src,
+ dst: newDst,
+ dir: dir,
+ modTime: src.ModTime(ctx),
+ level: level,
+ }
+ s.setDirModTimes = append(s.setDirModTimes, set)
+ s.setDirModTimeMu.Unlock()
+ fs.Debugf(nil, "Added delayed dir = %q, newDst=%v", dir, newDst)
+ }
+ s.processError(err)
+ if err != nil {
+ return nil
+ }
+ return newDst
+}
+
+// Set the modtimes for directories
+func (s *syncCopyMove) setDelayedDirModTimes(ctx context.Context) error {
+ s.setDirModTimeMu.Lock()
+ defer s.setDirModTimeMu.Unlock()
+
+ // Timestamp all directories at the same level in parallel, deepest first
+ // We do this by iterating the slice multiple times to save memory
+ // There could be a lot of directories in this slice.
+ errCount := errcount.New()
+ for level := s.setDirModTimesMaxLevel; level >= 0; level-- {
+ g, gCtx := errgroup.WithContext(ctx)
+ g.SetLimit(s.ci.Checkers)
+ for _, item := range s.setDirModTimes {
+ if item.level != level {
+ continue
+ }
+ // End early if error
+ if gCtx.Err() != nil {
+ break
+ }
+ if _, ok := s.modifiedDirs[item.dir]; !ok {
+ continue
+ }
+ if !s.copyEmptySrcDirs {
+ if _, isEmpty := s.srcEmptyDirs[item.dir]; isEmpty {
+ continue
+ }
+ }
+ item := item
+ if s.setDirModTimeAfter { // mark dir's parent as modified
+ dir := path.Dir(item.dir)
+ if dir == "." {
+ dir = ""
+ }
+ s.modifiedDirs[dir] = struct{}{} // lock is already held
+ }
+ g.Go(func() error {
+ var err error
+ if s.setDirMetadata {
+ _, err = operations.CopyDirMetadata(gCtx, s.fdst, item.dst, item.dir, item.src)
+ } else {
+ _, err = operations.SetDirModTime(gCtx, s.fdst, item.dst, item.dir, item.modTime)
+ }
+ if err != nil {
+ err = fs.CountError(ctx, err)
+ fs.Errorf(item.dir, "Failed to update directory timestamp or metadata: %v", err)
+ errCount.Add(err)
+ }
+ return nil // don't return errors, just count them
+ })
+ }
+ err := g.Wait()
+ if err != nil {
+ return err
+ }
+ }
+ return errCount.Err("failed to set directory modtime")
+}
+
+// SrcOnly have an object which is in the source only
+func (s *syncCopyMove) SrcOnly(src fs.DirEntry) (recurse bool) {
+ if s.deleteMode == fs.DeleteModeOnly {
+ return false
+ }
+ switch x := src.(type) {
+ case fs.Object:
+ s.logger(s.ctx, operations.MissingOnDst, x, nil, nil)
+ s.markParentNotEmpty(src)
+
+ if s.trackRenames {
+ // Save object to check for a rename later
+ select {
+ case <-s.ctx.Done():
+ return
+ case s.trackRenamesCh <- x:
+ }
+ } else {
+ // Check CompareDest && CopyDest
+ NoNeedTransfer, err := operations.CompareOrCopyDest(s.ctx, s.fdst, nil, x, s.compareCopyDest, s.backupDir)
+ if err != nil {
+ s.processError(err)
+ s.logger(s.ctx, operations.TransferError, x, nil, err)
+ }
+ if !NoNeedTransfer {
+ // No need to check since doesn't exist
+ fs.Debugf(src, "Need to transfer - File not found at Destination")
+ s.markDirModifiedObject(x)
+ ok := s.toBeUploaded.Put(s.inCtx, fs.ObjectPair{Src: x, Dst: nil})
+ if !ok {
+ return
+ }
+ }
+ }
+ case fs.Directory:
+ // Do the same thing to the entire contents of the directory
+ s.markParentNotEmpty(src)
+ s.logger(s.ctx, operations.MissingOnDst, src, nil, fs.ErrorIsDir)
+
+ // Create the directory and make sure the Metadata/ModTime is correct
+ s.copyDirMetadata(s.ctx, s.fdst, nil, transform.Path(s.ctx, x.Remote(), true), x)
+ s.markDirModified(transform.Path(s.ctx, x.Remote(), true))
+ return true
+ default:
+ panic("Bad object in DirEntries")
+ }
+ return false
+}
+
+// Match is called when src and dst are present, so sync src to dst
+func (s *syncCopyMove) Match(ctx context.Context, dst, src fs.DirEntry) (recurse bool) {
+ switch srcX := src.(type) {
+ case fs.Object:
+ s.markParentNotEmpty(src)
+
+ if s.deleteMode == fs.DeleteModeOnly {
+ return false
+ }
+ dstX, ok := dst.(fs.Object)
+ if ok {
+ // No logger here because we'll handle it in equal()
+ ok = s.toBeChecked.Put(s.inCtx, fs.ObjectPair{Src: srcX, Dst: dstX})
+ if !ok {
+ return false
+ }
+ } else {
+ // FIXME src is file, dst is directory
+ err := errors.New("can't overwrite directory with file")
+ fs.Errorf(dst, "%v", err)
+ s.processError(err)
+ s.logger(ctx, operations.TransferError, srcX, dstX, err)
+ }
+ case fs.Directory:
+ // Do the same thing to the entire contents of the directory
+ srcX = fs.NewOverrideDirectory(srcX, transform.Path(ctx, src.Remote(), true))
+ src = srcX
+ if !transform.Transforming(ctx) || src.Remote() != dst.Remote() {
+ s.markParentNotEmpty(src)
+ }
+ dstX, ok := dst.(fs.Directory)
+ if ok {
+ s.logger(s.ctx, operations.Match, src, dst, fs.ErrorIsDir)
+ // Create the directory and make sure the Metadata/ModTime is correct
+ s.copyDirMetadata(s.ctx, s.fdst, dstX, "", srcX)
+
+ if s.ci.FixCase && !s.ci.Immutable && src.Remote() != dst.Remote() {
+ // Fix case for case insensitive filesystems
+ // Fix each dir before recursing into subdirs and files
+ err := operations.DirMoveCaseInsensitive(s.ctx, s.fdst, dst.Remote(), src.Remote())
+ if err != nil {
+ fs.Errorf(dst, "Error while attempting to rename to %s: %v", src.Remote(), err)
+ s.processError(err)
+ } else {
+ fs.Infof(dst, "Fixed case by renaming to: %s", src.Remote())
+ }
+ }
+
+ return true
+ }
+ // FIXME src is dir, dst is file
+ err := errors.New("can't overwrite file with directory")
+ fs.Errorf(dst, "%v", err)
+ s.processError(err)
+ s.logger(ctx, operations.TransferError, src.(fs.ObjectInfo), dst.(fs.ObjectInfo), err)
+ default:
+ panic("Bad object in DirEntries")
+ }
+ return false
+}
+
+// Syncs fsrc into fdst
+//
+// If Delete is true then it deletes any files in fdst that aren't in fsrc
+//
+// If DoMove is true then files will be moved instead of copied.
+//
+// dir is the start directory, "" for root
+func runSyncCopyMove(ctx context.Context, fdst, fsrc fs.Fs, deleteMode fs.DeleteMode, DoMove bool, deleteEmptySrcDirs bool, copyEmptySrcDirs bool, allowOverlap bool) error {
+ ci := fs.GetConfig(ctx)
+ if deleteMode != fs.DeleteModeOff && DoMove {
+ return fserrors.FatalError(errors.New("can't delete and move at the same time"))
+ }
+ // Run an extra pass to delete only
+ if deleteMode == fs.DeleteModeBefore {
+ if ci.TrackRenames {
+ return fserrors.FatalError(errors.New("can't use --delete-before with --track-renames"))
+ }
+ // only delete stuff during in this pass
+ do, err := newSyncCopyMove(ctx, fdst, fsrc, fs.DeleteModeOnly, false, deleteEmptySrcDirs, copyEmptySrcDirs, allowOverlap)
+ if err != nil {
+ return err
+ }
+ err = do.run()
+ if err != nil {
+ return err
+ }
+ // Next pass does a copy only
+ deleteMode = fs.DeleteModeOff
+ }
+ do, err := newSyncCopyMove(ctx, fdst, fsrc, deleteMode, DoMove, deleteEmptySrcDirs, copyEmptySrcDirs, allowOverlap)
+ if err != nil {
+ return err
+ }
+ return do.run()
+}
+
+// Sync fsrc into fdst
+func Sync(ctx context.Context, fdst, fsrc fs.Fs, copyEmptySrcDirs bool) error {
+ ci := fs.GetConfig(ctx)
+ return runSyncCopyMove(ctx, fdst, fsrc, ci.DeleteMode, false, false, copyEmptySrcDirs, false)
+}
+
+// CopyDir copies fsrc into fdst
+func CopyDir(ctx context.Context, fdst, fsrc fs.Fs, copyEmptySrcDirs bool) error {
+ return runSyncCopyMove(ctx, fdst, fsrc, fs.DeleteModeOff, false, false, copyEmptySrcDirs, false)
+}
+
+// moveDir moves fsrc into fdst
+func moveDir(ctx context.Context, fdst, fsrc fs.Fs, deleteEmptySrcDirs bool, copyEmptySrcDirs bool) error {
+ return runSyncCopyMove(ctx, fdst, fsrc, fs.DeleteModeOff, true, deleteEmptySrcDirs, copyEmptySrcDirs, false)
+}
+
+// Transform renames fdst in place
+func Transform(ctx context.Context, fdst fs.Fs, deleteEmptySrcDirs bool, copyEmptySrcDirs bool) error {
+ return runSyncCopyMove(ctx, fdst, fdst, fs.DeleteModeOff, true, deleteEmptySrcDirs, copyEmptySrcDirs, true)
+}
+
+// MoveDir moves fsrc into fdst
+func MoveDir(ctx context.Context, fdst, fsrc fs.Fs, deleteEmptySrcDirs bool, copyEmptySrcDirs bool) error {
+ fi := filter.GetConfig(ctx)
+ if operations.Same(fdst, fsrc) {
+ fs.Errorf(fdst, "Nothing to do as source and destination are the same")
+ return nil
+ }
+
+ // First attempt to use DirMover if exists, same Fs and no filters are active
+ if fdstDirMove := fdst.Features().DirMove; fdstDirMove != nil && operations.SameConfig(fsrc, fdst) && fi.InActive() {
+ if operations.SkipDestructive(ctx, fdst, "server-side directory move") {
+ return nil
+ }
+ fs.Debugf(fdst, "Using server-side directory move")
+ err := fdstDirMove(ctx, fsrc, "", "")
+ switch err {
+ case fs.ErrorCantDirMove, fs.ErrorDirExists:
+ fs.Infof(fdst, "Server side directory move failed - fallback to file moves: %v", err)
+ case nil:
+ fs.Infof(fdst, "Server side directory move succeeded")
+ return nil
+ default:
+ err = fs.CountError(ctx, err)
+ fs.Errorf(fdst, "Server side directory move failed: %v", err)
+ return err
+ }
+ }
+
+ // Otherwise move the files one by one
+ return moveDir(ctx, fdst, fsrc, deleteEmptySrcDirs, copyEmptySrcDirs)
+}
diff --git a/fs/sync/sync_test.go b/fs/sync/sync_test.go
new file mode 100644
index 0000000..9865ecc
--- /dev/null
+++ b/fs/sync/sync_test.go
@@ -0,0 +1,3108 @@
+// Test sync/copy/move
+
+package sync
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "runtime"
+ "sort"
+ "strings"
+ "testing"
+ "time"
+
+ mutex "sync" // renamed as "sync" already in use
+
+ _ "github.com/mewsen/rclone-studip-backend-oot/backend/studip"
+ _ "github.com/rclone/rclone/backend/all" // import all backends
+ "github.com/rclone/rclone/cmd/bisync/bilib"
+ "github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/fs/accounting"
+ "github.com/rclone/rclone/fs/filter"
+ "github.com/rclone/rclone/fs/fserrors"
+ "github.com/rclone/rclone/fs/hash"
+ "github.com/rclone/rclone/fs/operations"
+ "github.com/rclone/rclone/fstest"
+ "github.com/rclone/rclone/lib/transform"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/text/unicode/norm"
+)
+
+// Some times used in the tests
+var (
+ t1 = fstest.Time("2001-02-03T04:05:06.499999999Z")
+ t2 = fstest.Time("2011-12-25T12:59:59.123456789Z")
+ t3 = fstest.Time("2011-12-30T12:59:59.000000000Z")
+)
+
+// TestMain drives the tests
+func TestMain(m *testing.M) {
+ fstest.TestMain(m)
+}
+
+// Check dry run is working
+func TestCopyWithDryRun(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+ file1 := r.WriteFile("sub dir/hello world", "hello world", t1)
+ r.Mkdir(ctx, r.Fremote)
+
+ ci.DryRun = true
+ ctx = predictDstFromLogger(ctx)
+ err := CopyDir(ctx, r.Fremote, r.Flocal, false)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t) // error expected here because dry-run
+ require.NoError(t, err)
+
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t)
+}
+
+// Now without dry run
+func TestCopy(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ file1 := r.WriteFile("sub dir/hello world", "hello world", t1)
+ _, err := operations.SetDirModTime(ctx, r.Flocal, nil, "sub dir", t2)
+ if err != nil && !errors.Is(err, fs.ErrorNotImplemented) {
+ require.NoError(t, err)
+ }
+ r.Mkdir(ctx, r.Fremote)
+
+ ctx = predictDstFromLogger(ctx)
+ err = CopyDir(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, file1)
+
+ // Check that the modtimes of the directories are as expected
+ r.CheckDirectoryModTimes(t, "sub dir")
+}
+
+func testCopyMetadata(t *testing.T, createEmptySrcDirs bool) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ ci.Metadata = true
+ r := fstest.NewRun(t)
+ features := r.Fremote.Features()
+
+ if !features.ReadMetadata && !features.WriteMetadata && !features.UserMetadata &&
+ !features.ReadDirMetadata && !features.WriteDirMetadata && !features.UserDirMetadata {
+ t.Skip("Skipping as metadata not supported")
+ }
+
+ if createEmptySrcDirs && !features.CanHaveEmptyDirectories {
+ t.Skip("Skipping as can't have empty directories")
+ }
+
+ const content = "hello metadata world!"
+ const dirPath = "metadata sub dir"
+ const emptyDirPath = "empty metadata sub dir"
+ const filePath = dirPath + "/hello metadata world"
+
+ fileMetadata := fs.Metadata{
+ // System metadata supported by all backends
+ "mtime": t1.Format(time.RFC3339Nano),
+ // User metadata
+ "potato": "jersey",
+ }
+
+ dirMetadata := fs.Metadata{
+ // System metadata supported by all backends
+ "mtime": t2.Format(time.RFC3339Nano),
+ // User metadata
+ "potato": "king edward",
+ }
+
+ // Make the directory with metadata - may fall back to Mkdir
+ _, err := operations.MkdirMetadata(ctx, r.Flocal, dirPath, dirMetadata)
+ require.NoError(t, err)
+
+ // Make the empty directory with metadata - may fall back to Mkdir
+ _, err = operations.MkdirMetadata(ctx, r.Flocal, emptyDirPath, dirMetadata)
+ require.NoError(t, err)
+
+ // Upload the file with metadata
+ in := io.NopCloser(bytes.NewBufferString(content))
+ _, err = operations.Rcat(ctx, r.Flocal, filePath, in, t1, fileMetadata)
+ require.NoError(t, err)
+ file1 := fstest.NewItem(filePath, content, t1)
+
+ // Reset the time of the directory
+ _, err = operations.SetDirModTime(ctx, r.Flocal, nil, dirPath, t2)
+ if err != nil && !errors.Is(err, fs.ErrorNotImplemented) {
+ require.NoError(t, err)
+ }
+
+ ctx = predictDstFromLogger(ctx)
+ err = CopyDir(ctx, r.Fremote, r.Flocal, createEmptySrcDirs)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, file1)
+
+ // Check that the modtimes of the directories are as expected
+ r.CheckDirectoryModTimes(t, dirPath)
+
+ // Check that the metadata on the directory and file is correct
+ if features.WriteMetadata && features.ReadMetadata {
+ fstest.CheckEntryMetadata(ctx, t, r.Fremote, fstest.NewObject(ctx, t, r.Fremote, filePath), fileMetadata)
+ }
+ if features.WriteDirMetadata && features.ReadDirMetadata {
+ fstest.CheckEntryMetadata(ctx, t, r.Fremote, fstest.NewDirectory(ctx, t, r.Fremote, dirPath), dirMetadata)
+ }
+ if !createEmptySrcDirs {
+ // dir must not exist
+ _, err := fstest.NewDirectoryRetries(ctx, t, r.Fremote, emptyDirPath, 1)
+ assert.Error(t, err, "Not expecting to find empty directory")
+ assert.True(t, errors.Is(err, fs.ErrorDirNotFound), fmt.Sprintf("expecting wrapped %#v not: %#v", fs.ErrorDirNotFound, err))
+ } else {
+ // dir must exist
+ dir := fstest.NewDirectory(ctx, t, r.Fremote, emptyDirPath)
+ if features.ReadDirMetadata {
+ fstest.CheckEntryMetadata(ctx, t, r.Fremote, dir, dirMetadata)
+ }
+ }
+}
+
+func TestCopyMetadata(t *testing.T) {
+ testCopyMetadata(t, true)
+}
+
+func TestCopyMetadataNoEmptyDirs(t *testing.T) {
+ testCopyMetadata(t, false)
+}
+
+func TestCopyMissingDirectory(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ r.Mkdir(ctx, r.Fremote)
+
+ nonExistingFs, err := fs.NewFs(ctx, "/non-existing")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ ctx = predictDstFromLogger(ctx)
+ err = CopyDir(ctx, r.Fremote, nonExistingFs, false)
+ require.Error(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+}
+
+// Now with --no-traverse
+func TestCopyNoTraverse(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+
+ ci.NoTraverse = true
+
+ file1 := r.WriteFile("sub dir/hello world", "hello world", t1)
+
+ ctx = predictDstFromLogger(ctx)
+ err := CopyDir(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, file1)
+}
+
+func TestCopyNoTraverseDeadlock(t *testing.T) {
+ r := fstest.NewRun(t)
+ if !r.Fremote.Features().IsLocal {
+ t.Skip("Only runs on local")
+ }
+ const nFiles = 200
+ t1 := fstest.Time("2001-02-03T04:05:06.499999999Z")
+
+ // Create lots of source files.
+ items := make([]fstest.Item, nFiles)
+ for i := range items {
+ name := fmt.Sprintf("file%d.txt", i)
+ items[i] = r.WriteFile(name, fmt.Sprintf("content%d", i), t1)
+ }
+ r.CheckLocalItems(t, items...)
+
+ // Set --no-traverse
+ ctx, ci := fs.AddConfig(context.Background())
+ ci.NoTraverse = true
+
+ // Initial copy to establish destination.
+ require.NoError(t, CopyDir(ctx, r.Fremote, r.Flocal, false))
+ r.CheckRemoteItems(t, items...)
+
+ // Second copy which shouldn't deadlock
+ require.NoError(t, CopyDir(ctx, r.Flocal, r.Fremote, false))
+ r.CheckRemoteItems(t, items...)
+}
+
+// Now with --check-first
+func TestCopyCheckFirst(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+
+ ci.CheckFirst = true
+
+ file1 := r.WriteFile("sub dir/hello world", "hello world", t1)
+
+ ctx = predictDstFromLogger(ctx)
+ err := CopyDir(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, file1)
+}
+
+// Now with --no-traverse
+func TestSyncNoTraverse(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+
+ ci.NoTraverse = true
+
+ file1 := r.WriteFile("sub dir/hello world", "hello world", t1)
+
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ err := Sync(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, file1)
+}
+
+// Test copy with depth
+func TestCopyWithDepth(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+ file1 := r.WriteFile("sub dir/hello world", "hello world", t1)
+ file2 := r.WriteFile("hello world2", "hello world2", t2)
+
+ // Check the MaxDepth too
+ ci.MaxDepth = 1
+
+ ctx = predictDstFromLogger(ctx)
+ err := CopyDir(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ r.CheckLocalItems(t, file1, file2)
+ r.CheckRemoteItems(t, file2)
+}
+
+// Test copy with files from
+func testCopyWithFilesFrom(t *testing.T, noTraverse bool) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+ file1 := r.WriteFile("potato2", "hello world", t1)
+ file2 := r.WriteFile("hello world2", "hello world2", t2)
+
+ // Set the --files-from equivalent
+ f, err := filter.NewFilter(nil)
+ require.NoError(t, err)
+ require.NoError(t, f.AddFile("potato2"))
+ require.NoError(t, f.AddFile("notfound"))
+
+ // Change the active filter
+ ctx = filter.ReplaceConfig(ctx, f)
+
+ ci.NoTraverse = noTraverse
+
+ ctx = predictDstFromLogger(ctx)
+ err = CopyDir(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ r.CheckLocalItems(t, file1, file2)
+ r.CheckRemoteItems(t, file1)
+}
+func TestCopyWithFilesFrom(t *testing.T) { testCopyWithFilesFrom(t, false) }
+func TestCopyWithFilesFromAndNoTraverse(t *testing.T) { testCopyWithFilesFrom(t, true) }
+
+// Test copy empty directories
+func TestCopyEmptyDirectories(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ file1 := r.WriteFile("sub dir/hello world", "hello world", t1)
+ _, err := operations.MkdirModTime(ctx, r.Flocal, "sub dir2/sub sub dir2", t2)
+ require.NoError(t, err)
+ _, err = operations.SetDirModTime(ctx, r.Flocal, nil, "sub dir2", t2)
+ require.NoError(t, err)
+ r.Mkdir(ctx, r.Fremote)
+
+ // Set the modtime on "sub dir" to something specific
+ // Without this it fails on the CI and in VirtualBox with variances of up to 10mS
+ _, err = operations.SetDirModTime(ctx, r.Flocal, nil, "sub dir", t1)
+ require.NoError(t, err)
+
+ ctx = predictDstFromLogger(ctx)
+ err = CopyDir(ctx, r.Fremote, r.Flocal, true)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ r.CheckRemoteListing(
+ t,
+ []fstest.Item{
+ file1,
+ },
+ []string{
+ "sub dir",
+ "sub dir2",
+ "sub dir2/sub sub dir2",
+ },
+ )
+
+ // Check that the modtimes of the directories are as expected
+ r.CheckDirectoryModTimes(t, "sub dir", "sub dir2", "sub dir2/sub sub dir2")
+}
+
+// Test copy empty directories when we are configured not to create them
+func TestCopyNoEmptyDirectories(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ file1 := r.WriteFile("sub dir/hello world", "hello world", t1)
+ err := operations.Mkdir(ctx, r.Flocal, "sub dir2")
+ require.NoError(t, err)
+ _, err = operations.MkdirModTime(ctx, r.Flocal, "sub dir2/sub sub dir2", t2)
+ require.NoError(t, err)
+ r.Mkdir(ctx, r.Fremote)
+
+ err = CopyDir(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+
+ r.CheckRemoteListing(
+ t,
+ []fstest.Item{
+ file1,
+ },
+ []string{
+ "sub dir",
+ },
+ )
+}
+
+// Test move empty directories
+func TestMoveEmptyDirectories(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ file1 := r.WriteFile("sub dir/hello world", "hello world", t1)
+ _, err := operations.MkdirModTime(ctx, r.Flocal, "sub dir2", t2)
+ require.NoError(t, err)
+ subDir := fstest.NewDirectory(ctx, t, r.Flocal, "sub dir")
+ subDirT := subDir.ModTime(ctx)
+ r.Mkdir(ctx, r.Fremote)
+
+ ctx = predictDstFromLogger(ctx)
+ err = MoveDir(ctx, r.Fremote, r.Flocal, false, true)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ r.CheckRemoteListing(
+ t,
+ []fstest.Item{
+ file1,
+ },
+ []string{
+ "sub dir",
+ "sub dir2",
+ },
+ )
+
+ // Check that the modtimes of the directories are as expected
+ r.CheckDirectoryModTimes(t, "sub dir2")
+ // Note that "sub dir" mod time is updated when file1 is deleted from it
+ // So check it more manually
+ got := fstest.NewDirectory(ctx, t, r.Fremote, "sub dir")
+ fstest.CheckDirModTime(ctx, t, r.Fremote, got, subDirT)
+}
+
+// Test that --no-update-dir-modtime is working
+func TestSyncNoUpdateDirModtime(t *testing.T) {
+ r := fstest.NewRun(t)
+ if r.Fremote.Features().DirSetModTime == nil {
+ t.Skip("Skipping test as backend does not support DirSetModTime")
+ }
+
+ ctx, ci := fs.AddConfig(context.Background())
+ ci.NoUpdateDirModTime = true
+ const name = "sub dir no update dir modtime"
+
+ // Set the modtime on name to something specific
+ _, err := operations.MkdirModTime(ctx, r.Flocal, name, t1)
+ require.NoError(t, err)
+
+ // Create the remote directory with the current time
+ require.NoError(t, r.Fremote.Mkdir(ctx, name))
+
+ // Read its modification time
+ wantT := fstest.NewDirectory(ctx, t, r.Fremote, name).ModTime(ctx)
+
+ ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, r.Fremote, r.Flocal, true)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ r.CheckRemoteListing(
+ t,
+ []fstest.Item{},
+ []string{
+ name,
+ },
+ )
+
+ // Read the new directory modification time - it should not have changed
+ gotT := fstest.NewDirectory(ctx, t, r.Fremote, name).ModTime(ctx)
+ fstest.AssertTimeEqualWithPrecision(t, name, wantT, gotT, r.Fremote.Precision())
+}
+
+// Test move empty directories when we are not configured to create them
+func TestMoveNoEmptyDirectories(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ file1 := r.WriteFile("sub dir/hello world", "hello world", t1)
+ err := operations.Mkdir(ctx, r.Flocal, "sub dir2")
+ require.NoError(t, err)
+ r.Mkdir(ctx, r.Fremote)
+
+ err = MoveDir(ctx, r.Fremote, r.Flocal, false, false)
+ require.NoError(t, err)
+
+ r.CheckRemoteListing(
+ t,
+ []fstest.Item{
+ file1,
+ },
+ []string{
+ "sub dir",
+ },
+ )
+}
+
+// Test sync empty directories
+func TestSyncEmptyDirectories(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ file1 := r.WriteFile("sub dir/hello world", "hello world", t1)
+ _, err := operations.MkdirModTime(ctx, r.Flocal, "sub dir2", t2)
+ require.NoError(t, err)
+
+ // Set the modtime on "sub dir" to something specific
+ // Without this it fails on the CI and in VirtualBox with variances of up to 10mS
+ _, err = operations.SetDirModTime(ctx, r.Flocal, nil, "sub dir", t1)
+ require.NoError(t, err)
+
+ r.Mkdir(ctx, r.Fremote)
+
+ ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, r.Fremote, r.Flocal, true)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ r.CheckRemoteListing(
+ t,
+ []fstest.Item{
+ file1,
+ },
+ []string{
+ "sub dir",
+ "sub dir2",
+ },
+ )
+
+ // Check that the modtimes of the directories are as expected
+ r.CheckDirectoryModTimes(t, "sub dir", "sub dir2")
+}
+
+// Test delayed mod time setting
+func TestSyncSetDelayedModTimes(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+
+ if !r.Fremote.Features().DirModTimeUpdatesOnWrite {
+ t.Skip("Backend doesn't have DirModTimeUpdatesOnWrite set")
+ }
+
+ // Create directories without timestamps
+ require.NoError(t, r.Flocal.Mkdir(ctx, "a1/b1/c1/d1/e1/f1"))
+ require.NoError(t, r.Flocal.Mkdir(ctx, "a1/b2/c1/d1/e1/f1"))
+ require.NoError(t, r.Flocal.Mkdir(ctx, "a1/b1/c1/d2/e1/f1"))
+ require.NoError(t, r.Flocal.Mkdir(ctx, "a1/b1/c1/d2/e1/f2"))
+
+ dirs := []string{
+ "a1",
+ "a1/b1",
+ "a1/b1/c1",
+ "a1/b1/c1/d1",
+ "a1/b1/c1/d1/e1",
+ "a1/b1/c1/d1/e1/f1",
+ "a1/b1/c1/d2",
+ "a1/b1/c1/d2/e1",
+ "a1/b1/c1/d2/e1/f1",
+ "a1/b1/c1/d2/e1/f2",
+ "a1/b2",
+ "a1/b2/c1",
+ "a1/b2/c1/d1",
+ "a1/b2/c1/d1/e1",
+ "a1/b2/c1/d1/e1/f1",
+ }
+ r.CheckLocalListing(t, []fstest.Item{}, dirs)
+
+ // Timestamp the directories in reverse order
+ ts := t1
+ for i := len(dirs) - 1; i >= 0; i-- {
+ dir := dirs[i]
+ _, err := operations.SetDirModTime(ctx, r.Flocal, nil, dir, ts)
+ require.NoError(t, err)
+ ts = ts.Add(time.Minute)
+ }
+
+ r.Mkdir(ctx, r.Fremote)
+
+ ctx = predictDstFromLogger(ctx)
+ err := Sync(ctx, r.Fremote, r.Flocal, true)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ r.CheckRemoteListing(t, []fstest.Item{}, dirs)
+
+ // Check that the modtimes of the directories are as expected
+ r.CheckDirectoryModTimes(t, dirs...)
+}
+
+// Test sync empty directories when we are not configured to create them
+func TestSyncNoEmptyDirectories(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ file1 := r.WriteFile("sub dir/hello world", "hello world", t1)
+ err := operations.Mkdir(ctx, r.Flocal, "sub dir2")
+ require.NoError(t, err)
+ r.Mkdir(ctx, r.Fremote)
+
+ err = Sync(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+
+ r.CheckRemoteListing(
+ t,
+ []fstest.Item{
+ file1,
+ },
+ []string{
+ "sub dir",
+ },
+ )
+}
+
+// Test a server-side copy if possible, or the backup path if not
+func TestServerSideCopy(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ file1 := r.WriteObject(ctx, "sub dir/hello world", "hello world", t1)
+ r.CheckRemoteItems(t, file1)
+
+ FremoteCopy, _, finaliseCopy, err := fstest.RandomRemote()
+ require.NoError(t, err)
+ defer finaliseCopy()
+ t.Logf("Server side copy (if possible) %v -> %v", r.Fremote, FremoteCopy)
+
+ ctx = predictDstFromLogger(ctx)
+ err = CopyDir(ctx, FremoteCopy, r.Fremote, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ fstest.CheckItems(t, FremoteCopy, file1)
+}
+
+// Test copying a file over itself
+func TestCopyOverSelf(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ file1 := r.WriteObject(ctx, "sub dir/hello world", "hello world", t1)
+ r.CheckRemoteItems(t, file1)
+ file2 := r.WriteFile("sub dir/hello world", "hello world again", t2)
+ r.CheckLocalItems(t, file2)
+
+ ctx = predictDstFromLogger(ctx)
+ err := CopyDir(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ r.CheckRemoteItems(t, file2)
+}
+
+// Test server-side copying a file over itself
+func TestServerSideCopyOverSelf(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ file1 := r.WriteObject(ctx, "sub dir/hello world", "hello world", t1)
+ r.CheckRemoteItems(t, file1)
+
+ FremoteCopy, _, finaliseCopy, err := fstest.RandomRemote()
+ require.NoError(t, err)
+ defer finaliseCopy()
+ t.Logf("Server side copy (if possible) %v -> %v", r.Fremote, FremoteCopy)
+
+ ctx = predictDstFromLogger(ctx)
+ err = CopyDir(ctx, FremoteCopy, r.Fremote, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, FremoteCopy, r.Fremote, operations.GetLoggerOpt(ctx).JSON, t)
+ fstest.CheckItems(t, FremoteCopy, file1)
+
+ file2 := r.WriteObject(ctx, "sub dir/hello world", "hello world again", t2)
+ r.CheckRemoteItems(t, file2)
+
+ ctx = predictDstFromLogger(ctx)
+ err = CopyDir(ctx, FremoteCopy, r.Fremote, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, FremoteCopy, r.Fremote, operations.GetLoggerOpt(ctx).JSON, t)
+ fstest.CheckItems(t, FremoteCopy, file2)
+}
+
+// Test moving a file over itself
+func TestMoveOverSelf(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ file1 := r.WriteObject(ctx, "sub dir/hello world", "hello world", t1)
+ r.CheckRemoteItems(t, file1)
+ file2 := r.WriteFile("sub dir/hello world", "hello world again", t2)
+ r.CheckLocalItems(t, file2)
+
+ ctx = predictDstFromLogger(ctx)
+ err := MoveDir(ctx, r.Fremote, r.Flocal, false, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ r.CheckLocalItems(t)
+ r.CheckRemoteItems(t, file2)
+}
+
+// Test server-side moving a file over itself
+func TestServerSideMoveOverSelf(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ file1 := r.WriteObject(ctx, "sub dir/hello world", "hello world", t1)
+ r.CheckRemoteItems(t, file1)
+
+ FremoteCopy, _, finaliseCopy, err := fstest.RandomRemote()
+ require.NoError(t, err)
+ defer finaliseCopy()
+ t.Logf("Server side copy (if possible) %v -> %v", r.Fremote, FremoteCopy)
+
+ ctx = predictDstFromLogger(ctx)
+ err = CopyDir(ctx, FremoteCopy, r.Fremote, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, FremoteCopy, r.Fremote, operations.GetLoggerOpt(ctx).JSON, t)
+ fstest.CheckItems(t, FremoteCopy, file1)
+
+ file2 := r.WriteObject(ctx, "sub dir/hello world", "hello world again", t2)
+ r.CheckRemoteItems(t, file2)
+
+ // ctx = predictDstFromLogger(ctx)
+ err = MoveDir(ctx, FremoteCopy, r.Fremote, false, false)
+ require.NoError(t, err)
+ // testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t) // not currently supported
+ r.CheckRemoteItems(t)
+ fstest.CheckItems(t, FremoteCopy, file2)
+
+ // check that individual file moves also work without MoveDir
+ file3 := r.WriteObject(ctx, "sub dir/hello world", "hello world a third time", t3)
+ r.CheckRemoteItems(t, file3)
+
+ ctx = predictDstFromLogger(ctx)
+ fs.Debugf(nil, "testing file moves")
+ err = moveDir(ctx, FremoteCopy, r.Fremote, false, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, FremoteCopy, r.Fremote, operations.GetLoggerOpt(ctx).JSON, t)
+ r.CheckRemoteItems(t)
+ fstest.CheckItems(t, FremoteCopy, file3)
+}
+
+// Check that if the local file doesn't exist when we copy it up,
+// nothing happens to the remote file
+func TestCopyAfterDelete(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ file1 := r.WriteObject(ctx, "sub dir/hello world", "hello world", t1)
+ r.CheckLocalItems(t)
+ r.CheckRemoteItems(t, file1)
+
+ err := operations.Mkdir(ctx, r.Flocal, "")
+ require.NoError(t, err)
+
+ ctx = predictDstFromLogger(ctx)
+ err = CopyDir(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ r.CheckLocalItems(t)
+ r.CheckRemoteItems(t, file1)
+}
+
+// Check the copy downloading a file
+func TestCopyRedownload(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ file1 := r.WriteObject(ctx, "sub dir/hello world", "hello world", t1)
+ r.CheckRemoteItems(t, file1)
+
+ ctx = predictDstFromLogger(ctx)
+ err := CopyDir(ctx, r.Flocal, r.Fremote, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ // Test with combined precision of local and remote as we copied it there and back
+ r.CheckLocalListing(t, []fstest.Item{file1}, nil)
+}
+
+// Create a file and sync it. Change the last modified date and resync.
+// If we're only doing sync by size and checksum, we expect nothing to
+// to be transferred on the second sync.
+func TestSyncBasedOnCheckSum(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+ ci.CheckSum = true
+
+ file1 := r.WriteFile("check sum", "-", t1)
+ r.CheckLocalItems(t, file1)
+
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ err := Sync(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ // We should have transferred exactly one file.
+ assert.Equal(t, toyFileTransfers(r), accounting.GlobalStats().GetTransfers())
+ r.CheckRemoteItems(t, file1)
+
+ // Change last modified date only
+ file2 := r.WriteFile("check sum", "-", t2)
+ r.CheckLocalItems(t, file2)
+
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ // We should have transferred no files
+ assert.Equal(t, int64(0), accounting.GlobalStats().GetTransfers())
+ r.CheckLocalItems(t, file2)
+ r.CheckRemoteItems(t, file1)
+}
+
+// Create a file and sync it. Change the last modified date and the
+// file contents but not the size. If we're only doing sync by size
+// only, we expect nothing to be transferred on the second sync.
+func TestSyncSizeOnly(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+ ci.SizeOnly = true
+
+ file1 := r.WriteFile("sizeonly", "potato", t1)
+ r.CheckLocalItems(t, file1)
+
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ err := Sync(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ // We should have transferred exactly one file.
+ assert.Equal(t, toyFileTransfers(r), accounting.GlobalStats().GetTransfers())
+ r.CheckRemoteItems(t, file1)
+
+ // Update mtime, md5sum but not length of file
+ file2 := r.WriteFile("sizeonly", "POTATO", t2)
+ r.CheckLocalItems(t, file2)
+
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ // We should have transferred no files
+ assert.Equal(t, int64(0), accounting.GlobalStats().GetTransfers())
+ r.CheckLocalItems(t, file2)
+ r.CheckRemoteItems(t, file1)
+}
+
+// Create a file and sync it. Keep the last modified date but change
+// the size. With --ignore-size we expect nothing to be
+// transferred on the second sync.
+func TestSyncIgnoreSize(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+ ci.IgnoreSize = true
+
+ file1 := r.WriteFile("ignore-size", "contents", t1)
+ r.CheckLocalItems(t, file1)
+
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ err := Sync(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ // We should have transferred exactly one file.
+ assert.Equal(t, toyFileTransfers(r), accounting.GlobalStats().GetTransfers())
+ r.CheckRemoteItems(t, file1)
+
+ // Update size but not date of file
+ file2 := r.WriteFile("ignore-size", "longer contents but same date", t1)
+ r.CheckLocalItems(t, file2)
+
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ // We should have transferred no files
+ assert.Equal(t, int64(0), accounting.GlobalStats().GetTransfers())
+ r.CheckLocalItems(t, file2)
+ r.CheckRemoteItems(t, file1)
+}
+
+func TestSyncIgnoreTimes(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+ file1 := r.WriteBoth(ctx, "existing", "potato", t1)
+ r.CheckRemoteItems(t, file1)
+
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ err := Sync(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ // We should have transferred exactly 0 files because the
+ // files were identical.
+ assert.Equal(t, int64(0), accounting.GlobalStats().GetTransfers())
+
+ ci.IgnoreTimes = true
+
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ // We should have transferred exactly one file even though the
+ // files were identical.
+ assert.Equal(t, toyFileTransfers(r), accounting.GlobalStats().GetTransfers())
+
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, file1)
+}
+
+func TestSyncIgnoreExisting(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+ file1 := r.WriteFile("existing", "potato", t1)
+
+ ci.IgnoreExisting = true
+
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ err := Sync(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, file1)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ // Change everything
+ r.WriteFile("existing", "newpotatoes", t2)
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ // Items should not change
+ r.CheckRemoteItems(t, file1)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+}
+
+func TestSyncIgnoreErrors(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+ ci.IgnoreErrors = true
+ file1 := r.WriteFile("a/potato2", "------------------------------------------------------------", t1)
+ file2 := r.WriteObject(ctx, "b/potato", "SMALLER BUT SAME DATE", t2)
+ file3 := r.WriteBoth(ctx, "c/non empty space", "AhHa!", t2)
+ require.NoError(t, operations.Mkdir(ctx, r.Fremote, "d"))
+
+ r.CheckLocalListing(
+ t,
+ []fstest.Item{
+ file1,
+ file3,
+ },
+ []string{
+ "a",
+ "c",
+ },
+ )
+ r.CheckRemoteListing(
+ t,
+ []fstest.Item{
+ file2,
+ file3,
+ },
+ []string{
+ "b",
+ "c",
+ "d",
+ },
+ )
+
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ _ = fs.CountError(ctx, errors.New("boom"))
+ assert.NoError(t, Sync(ctx, r.Fremote, r.Flocal, false))
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ r.CheckLocalListing(
+ t,
+ []fstest.Item{
+ file1,
+ file3,
+ },
+ []string{
+ "a",
+ "c",
+ },
+ )
+ r.CheckRemoteListing(
+ t,
+ []fstest.Item{
+ file1,
+ file3,
+ },
+ []string{
+ "a",
+ "c",
+ },
+ )
+}
+
+func TestSyncAfterChangingModtimeOnly(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+ file1 := r.WriteFile("empty space", "-", t2)
+ file2 := r.WriteObject(ctx, "empty space", "-", t1)
+
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, file2)
+
+ ci.DryRun = true
+
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ err := Sync(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, file2)
+
+ ci.DryRun = false
+
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, file1)
+}
+
+func TestSyncAfterChangingModtimeOnlyWithNoUpdateModTime(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+
+ if r.Fremote.Hashes().Count() == 0 {
+ t.Logf("Can't check this if no hashes supported")
+ return
+ }
+
+ ci.NoUpdateModTime = true
+
+ file1 := r.WriteFile("empty space", "-", t2)
+ file2 := r.WriteObject(ctx, "empty space", "-", t1)
+
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, file2)
+
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ err := Sync(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, file2)
+}
+
+func TestSyncDoesntUpdateModtime(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ if fs.GetModifyWindow(ctx, r.Fremote) == fs.ModTimeNotSupported {
+ t.Skip("Can't run this test on fs which doesn't support mod time")
+ }
+
+ file1 := r.WriteFile("foo", "foo", t2)
+ file2 := r.WriteObject(ctx, "foo", "bar", t1)
+
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, file2)
+
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ err := Sync(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, file1)
+
+ // We should have transferred exactly one file, not set the mod time
+ assert.Equal(t, toyFileTransfers(r), accounting.GlobalStats().GetTransfers())
+}
+
+func TestSyncAfterAddingAFile(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ file1 := r.WriteBoth(ctx, "empty space", "-", t2)
+ file2 := r.WriteFile("potato", "------------------------------------------------------------", t3)
+
+ r.CheckLocalItems(t, file1, file2)
+ r.CheckRemoteItems(t, file1)
+
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ err := Sync(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ r.CheckLocalItems(t, file1, file2)
+ r.CheckRemoteItems(t, file1, file2)
+}
+
+func TestSyncAfterChangingFilesSizeOnly(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ file1 := r.WriteObject(ctx, "potato", "------------------------------------------------------------", t3)
+ file2 := r.WriteFile("potato", "smaller but same date", t3)
+ r.CheckRemoteItems(t, file1)
+ r.CheckLocalItems(t, file2)
+
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ err := Sync(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ r.CheckLocalItems(t, file2)
+ r.CheckRemoteItems(t, file2)
+}
+
+// Sync after changing a file's contents, changing modtime but length
+// remaining the same
+func TestSyncAfterChangingContentsOnly(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ var file1 fstest.Item
+ if r.Fremote.Precision() == fs.ModTimeNotSupported {
+ t.Logf("ModTimeNotSupported so forcing file to be a different size")
+ file1 = r.WriteObject(ctx, "potato", "different size to make sure it syncs", t3)
+ } else {
+ file1 = r.WriteObject(ctx, "potato", "smaller but same date", t3)
+ }
+ file2 := r.WriteFile("potato", "SMALLER BUT SAME DATE", t2)
+ r.CheckRemoteItems(t, file1)
+ r.CheckLocalItems(t, file2)
+
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ err := Sync(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ r.CheckLocalItems(t, file2)
+ r.CheckRemoteItems(t, file2)
+}
+
+// Sync after removing a file and adding a file --dry-run
+func TestSyncAfterRemovingAFileAndAddingAFileDryRun(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+ file1 := r.WriteFile("potato2", "------------------------------------------------------------", t1)
+ file2 := r.WriteObject(ctx, "potato", "SMALLER BUT SAME DATE", t2)
+ file3 := r.WriteBoth(ctx, "empty space", "-", t2)
+
+ ci.DryRun = true
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ err := Sync(ctx, r.Fremote, r.Flocal, false)
+ ci.DryRun = false
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ r.CheckLocalItems(t, file3, file1)
+ r.CheckRemoteItems(t, file3, file2)
+}
+
+// Sync after removing a file and adding a file
+func testSyncAfterRemovingAFileAndAddingAFile(ctx context.Context, t *testing.T) {
+ r := fstest.NewRun(t)
+ file1 := r.WriteFile("potato2", "------------------------------------------------------------", t1)
+ file2 := r.WriteObject(ctx, "potato", "SMALLER BUT SAME DATE", t2)
+ file3 := r.WriteBoth(ctx, "empty space", "-", t2)
+ r.CheckRemoteItems(t, file2, file3)
+ r.CheckLocalItems(t, file1, file3)
+
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ err := Sync(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ r.CheckLocalItems(t, file1, file3)
+ r.CheckRemoteItems(t, file1, file3)
+}
+
+func TestSyncAfterRemovingAFileAndAddingAFile(t *testing.T) {
+ testSyncAfterRemovingAFileAndAddingAFile(context.Background(), t)
+}
+
+// Sync after removing a file and adding a file
+func testSyncAfterRemovingAFileAndAddingAFileSubDir(ctx context.Context, t *testing.T) {
+ r := fstest.NewRun(t)
+ file1 := r.WriteFile("a/potato2", "------------------------------------------------------------", t1)
+ file2 := r.WriteObject(ctx, "b/potato", "SMALLER BUT SAME DATE", t2)
+ file3 := r.WriteBoth(ctx, "c/non empty space", "AhHa!", t2)
+ require.NoError(t, operations.Mkdir(ctx, r.Fremote, "d"))
+ require.NoError(t, operations.Mkdir(ctx, r.Fremote, "d/e"))
+
+ r.CheckLocalListing(
+ t,
+ []fstest.Item{
+ file1,
+ file3,
+ },
+ []string{
+ "a",
+ "c",
+ },
+ )
+ r.CheckRemoteListing(
+ t,
+ []fstest.Item{
+ file2,
+ file3,
+ },
+ []string{
+ "b",
+ "c",
+ "d",
+ "d/e",
+ },
+ )
+
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ err := Sync(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ r.CheckLocalListing(
+ t,
+ []fstest.Item{
+ file1,
+ file3,
+ },
+ []string{
+ "a",
+ "c",
+ },
+ )
+ r.CheckRemoteListing(
+ t,
+ []fstest.Item{
+ file1,
+ file3,
+ },
+ []string{
+ "a",
+ "c",
+ },
+ )
+}
+
+func TestSyncAfterRemovingAFileAndAddingAFileSubDir(t *testing.T) {
+ testSyncAfterRemovingAFileAndAddingAFileSubDir(context.Background(), t)
+}
+
+// Sync after removing a file and adding a file with IO Errors
+func TestSyncAfterRemovingAFileAndAddingAFileSubDirWithErrors(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ file1 := r.WriteFile("a/potato2", "------------------------------------------------------------", t1)
+ file2 := r.WriteObject(ctx, "b/potato", "SMALLER BUT SAME DATE", t2)
+ file3 := r.WriteBoth(ctx, "c/non empty space", "AhHa!", t2)
+ require.NoError(t, operations.Mkdir(ctx, r.Fremote, "d"))
+
+ r.CheckLocalListing(
+ t,
+ []fstest.Item{
+ file1,
+ file3,
+ },
+ []string{
+ "a",
+ "c",
+ },
+ )
+ r.CheckRemoteListing(
+ t,
+ []fstest.Item{
+ file2,
+ file3,
+ },
+ []string{
+ "b",
+ "c",
+ "d",
+ },
+ )
+
+ ctx = predictDstFromLogger(ctx)
+ accounting.GlobalStats().ResetCounters()
+ _ = fs.CountError(ctx, errors.New("boom"))
+ err := Sync(ctx, r.Fremote, r.Flocal, false)
+ assert.Equal(t, fs.ErrorNotDeleting, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ accounting.GlobalStats().ResetCounters()
+
+ r.CheckLocalListing(
+ t,
+ []fstest.Item{
+ file1,
+ file3,
+ },
+ []string{
+ "a",
+ "c",
+ },
+ )
+ r.CheckRemoteListing(
+ t,
+ []fstest.Item{
+ file1,
+ file2,
+ file3,
+ },
+ []string{
+ "a",
+ "b",
+ "c",
+ "d",
+ },
+ )
+}
+
+// Sync test delete after
+func TestSyncDeleteAfter(t *testing.T) {
+ ctx := context.Background()
+ ci := fs.GetConfig(ctx)
+ // This is the default so we've checked this already
+ // check it is the default
+ require.Equal(t, ci.DeleteMode, fs.DeleteModeAfter, "Didn't default to --delete-after")
+}
+
+// Sync test delete during
+func TestSyncDeleteDuring(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ ci.DeleteMode = fs.DeleteModeDuring
+
+ testSyncAfterRemovingAFileAndAddingAFile(ctx, t)
+}
+
+// Sync test delete before
+func TestSyncDeleteBefore(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ ci.DeleteMode = fs.DeleteModeBefore
+
+ testSyncAfterRemovingAFileAndAddingAFile(ctx, t)
+}
+
+// Copy test delete before - shouldn't delete anything
+func TestCopyDeleteBefore(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+
+ ci.DeleteMode = fs.DeleteModeBefore
+
+ file1 := r.WriteObject(ctx, "potato", "hopefully not deleted", t1)
+ file2 := r.WriteFile("potato2", "hopefully copied in", t1)
+ r.CheckRemoteItems(t, file1)
+ r.CheckLocalItems(t, file2)
+
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ err := CopyDir(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ r.CheckRemoteItems(t, file1, file2)
+ r.CheckLocalItems(t, file2)
+}
+
+// Test with exclude
+func TestSyncWithExclude(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ file1 := r.WriteBoth(ctx, "potato2", "------------------------------------------------------------", t1)
+ file2 := r.WriteBoth(ctx, "empty space", "-", t2)
+ file3 := r.WriteFile("enormous", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", t1) // 100 bytes
+ r.CheckRemoteItems(t, file1, file2)
+ r.CheckLocalItems(t, file1, file2, file3)
+
+ fi, err := filter.NewFilter(nil)
+ require.NoError(t, err)
+ fi.Opt.MaxSize = 40
+ ctx = filter.ReplaceConfig(ctx, fi)
+
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ r.CheckRemoteItems(t, file2, file1)
+
+ // Now sync the other way round and check enormous doesn't get
+ // deleted as it is excluded from the sync
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, r.Flocal, r.Fremote, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ r.CheckLocalItems(t, file2, file1, file3)
+}
+
+// Test with exclude and delete excluded
+func TestSyncWithExcludeAndDeleteExcluded(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ file1 := r.WriteBoth(ctx, "potato2", "------------------------------------------------------------", t1) // 60 bytes
+ file2 := r.WriteBoth(ctx, "empty space", "-", t2)
+ file3 := r.WriteBoth(ctx, "enormous", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", t1) // 100 bytes
+ r.CheckRemoteItems(t, file1, file2, file3)
+ r.CheckLocalItems(t, file1, file2, file3)
+
+ fi, err := filter.NewFilter(nil)
+ require.NoError(t, err)
+ fi.Opt.MaxSize = 40
+ fi.Opt.DeleteExcluded = true
+ ctx = filter.ReplaceConfig(ctx, fi)
+
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ r.CheckRemoteItems(t, file2)
+
+ // Check sync the other way round to make sure enormous gets
+ // deleted even though it is excluded
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, r.Flocal, r.Fremote, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ r.CheckLocalItems(t, file2)
+}
+
+// Test with UpdateOlder set
+func TestSyncWithUpdateOlder(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+ if fs.GetModifyWindow(ctx, r.Fremote) == fs.ModTimeNotSupported {
+ t.Skip("Can't run this test on fs which doesn't support mod time")
+ }
+ t2plus := t2.Add(time.Second / 2)
+ t2minus := t2.Add(time.Second / 2)
+ oneF := r.WriteFile("one", "one", t1)
+ twoF := r.WriteFile("two", "two", t3)
+ threeF := r.WriteFile("three", "three", t2)
+ fourF := r.WriteFile("four", "four", t2)
+ fiveF := r.WriteFile("five", "five", t2)
+ r.CheckLocalItems(t, oneF, twoF, threeF, fourF, fiveF)
+ oneO := r.WriteObject(ctx, "one", "ONE", t2)
+ twoO := r.WriteObject(ctx, "two", "TWO", t2)
+ threeO := r.WriteObject(ctx, "three", "THREE", t2plus)
+ fourO := r.WriteObject(ctx, "four", "FOURFOUR", t2minus)
+ r.CheckRemoteItems(t, oneO, twoO, threeO, fourO)
+
+ ci.UpdateOlder = true
+ ci.ModifyWindow = fs.Duration(fs.ModTimeNotSupported)
+
+ ctx = predictDstFromLogger(ctx)
+ err := Sync(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ r.CheckRemoteItems(t, oneO, twoF, threeO, fourF, fiveF)
+ // testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t) // no modtime
+
+ if r.Fremote.Hashes().Count() == 0 {
+ t.Logf("Skip test with --checksum as no hashes supported")
+ return
+ }
+
+ // now enable checksum
+ ci.CheckSum = true
+
+ err = Sync(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ r.CheckRemoteItems(t, oneO, twoF, threeF, fourF, fiveF)
+}
+
+// Test with a max transfer duration
+func testSyncWithMaxDuration(t *testing.T, cutoffMode fs.CutoffMode) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ if *fstest.RemoteName != "" {
+ t.Skip("Skipping test on non local remote")
+ }
+ r := fstest.NewRun(t)
+
+ maxDuration := fs.Duration(250 * time.Millisecond)
+ ci.MaxDuration = maxDuration
+ ci.CutoffMode = cutoffMode
+ ci.CheckFirst = true
+ ci.OrderBy = "size"
+ ci.Transfers = 1
+ ci.Checkers = 1
+ bytesPerSecond := 10 * 1024
+ accounting.TokenBucket.SetBwLimit(fs.BwPair{Tx: fs.SizeSuffix(bytesPerSecond), Rx: fs.SizeSuffix(bytesPerSecond)})
+ defer accounting.TokenBucket.SetBwLimit(fs.BwPair{Tx: -1, Rx: -1})
+
+ // write one small file which we expect to transfer and one big one which we don't
+ file1 := r.WriteFile("file1", string(make([]byte, 16)), t1)
+ file2 := r.WriteFile("file2", string(make([]byte, 50*1024)), t1)
+ r.CheckLocalItems(t, file1, file2)
+ r.CheckRemoteItems(t)
+
+ if runtime.GOOS == "darwin" {
+ r.Flocal.Features().Disable("Copy") // macOS cloning is too fast for this test!
+ if r.Fremote.Features().IsLocal {
+ r.Fremote.Features().Disable("Copy") // macOS cloning is too fast for this test!
+ }
+ }
+ accounting.GlobalStats().ResetCounters()
+ // ctx = predictDstFromLogger(ctx) // not currently supported (but tests do pass for CutoffModeSoft)
+ startTime := time.Now()
+ err := Sync(ctx, r.Fremote, r.Flocal, false)
+ // testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ require.True(t, errors.Is(err, ErrorMaxDurationReached))
+
+ if cutoffMode == fs.CutoffModeHard {
+ r.CheckRemoteItems(t, file1)
+ assert.Equal(t, int64(1), accounting.GlobalStats().GetTransfers())
+ } else {
+ r.CheckRemoteItems(t, file1, file2)
+ assert.Equal(t, int64(2), accounting.GlobalStats().GetTransfers())
+ }
+
+ elapsed := time.Since(startTime)
+ const maxTransferTime = 20 * time.Second
+
+ what := fmt.Sprintf("expecting elapsed time %v between %v and %v", elapsed, maxDuration, maxTransferTime)
+ assert.True(t, elapsed >= time.Duration(maxDuration), what)
+ assert.True(t, elapsed < maxTransferTime, what)
+}
+
+func TestSyncWithMaxDuration(t *testing.T) {
+ t.Run("Hard", func(t *testing.T) {
+ testSyncWithMaxDuration(t, fs.CutoffModeHard)
+ })
+ t.Run("Soft", func(t *testing.T) {
+ testSyncWithMaxDuration(t, fs.CutoffModeSoft)
+ })
+}
+
+// Test with TrackRenames set
+func TestSyncWithTrackRenames(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+
+ ci.TrackRenames = true
+ defer func() {
+ ci.TrackRenames = false
+ }()
+
+ haveHash := r.Fremote.Hashes().Overlap(r.Flocal.Hashes()).GetOne() != hash.None
+ canTrackRenames := haveHash && operations.CanServerSideMove(r.Fremote)
+ t.Logf("Can track renames: %v", canTrackRenames)
+
+ f1 := r.WriteFile("potato", "Potato Content", t1)
+ f2 := r.WriteFile("yam", "Yam Content", t2)
+
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ require.NoError(t, Sync(ctx, r.Fremote, r.Flocal, false))
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ r.CheckRemoteItems(t, f1, f2)
+ r.CheckLocalItems(t, f1, f2)
+
+ // Now rename locally.
+ f2 = r.RenameFile(f2, "yaml")
+
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ require.NoError(t, Sync(ctx, r.Fremote, r.Flocal, false))
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ r.CheckRemoteItems(t, f1, f2)
+
+ // Check we renamed something if we should have
+ if canTrackRenames {
+ renames := accounting.GlobalStats().Renames(0)
+ assert.Equal(t, canTrackRenames, renames != 0, fmt.Sprintf("canTrackRenames=%v, renames=%d", canTrackRenames, renames))
+ }
+}
+
+func TestParseRenamesStrategyModtime(t *testing.T) {
+ for _, test := range []struct {
+ in string
+ want trackRenamesStrategy
+ wantErr bool
+ }{
+ {"", 0, false},
+ {"modtime", trackRenamesStrategyModtime, false},
+ {"hash", trackRenamesStrategyHash, false},
+ {"size", 0, false},
+ {"modtime,hash", trackRenamesStrategyModtime | trackRenamesStrategyHash, false},
+ {"hash,modtime,size", trackRenamesStrategyModtime | trackRenamesStrategyHash, false},
+ {"size,boom", 0, true},
+ } {
+ got, err := parseTrackRenamesStrategy(test.in)
+ assert.Equal(t, test.want, got, test.in)
+ assert.Equal(t, test.wantErr, err != nil, test.in)
+ }
+}
+
+func TestRenamesStrategyModtime(t *testing.T) {
+ both := trackRenamesStrategyHash | trackRenamesStrategyModtime
+ hash := trackRenamesStrategyHash
+ modTime := trackRenamesStrategyModtime
+
+ assert.True(t, both.hash())
+ assert.True(t, both.modTime())
+ assert.True(t, hash.hash())
+ assert.False(t, hash.modTime())
+ assert.False(t, modTime.hash())
+ assert.True(t, modTime.modTime())
+}
+
+func TestSyncWithTrackRenamesStrategyModtime(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+
+ ci.TrackRenames = true
+ ci.TrackRenamesStrategy = "modtime"
+
+ canTrackRenames := operations.CanServerSideMove(r.Fremote) && r.Fremote.Precision() != fs.ModTimeNotSupported
+ t.Logf("Can track renames: %v", canTrackRenames)
+
+ f1 := r.WriteFile("potato", "Potato Content", t1)
+ f2 := r.WriteFile("yam", "Yam Content", t2)
+
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ require.NoError(t, Sync(ctx, r.Fremote, r.Flocal, false))
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ r.CheckRemoteItems(t, f1, f2)
+ r.CheckLocalItems(t, f1, f2)
+
+ // Now rename locally.
+ f2 = r.RenameFile(f2, "yaml")
+
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ require.NoError(t, Sync(ctx, r.Fremote, r.Flocal, false))
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ r.CheckRemoteItems(t, f1, f2)
+
+ // Check we renamed something if we should have
+ if canTrackRenames {
+ renames := accounting.GlobalStats().Renames(0)
+ assert.Equal(t, canTrackRenames, renames != 0, fmt.Sprintf("canTrackRenames=%v, renames=%d", canTrackRenames, renames))
+ }
+}
+
+func TestSyncWithTrackRenamesStrategyLeaf(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+
+ ci.TrackRenames = true
+ ci.TrackRenamesStrategy = "leaf"
+
+ canTrackRenames := operations.CanServerSideMove(r.Fremote) && r.Fremote.Precision() != fs.ModTimeNotSupported
+ t.Logf("Can track renames: %v", canTrackRenames)
+
+ f1 := r.WriteFile("potato", "Potato Content", t1)
+ f2 := r.WriteFile("sub/yam", "Yam Content", t2)
+
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ require.NoError(t, Sync(ctx, r.Fremote, r.Flocal, false))
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ r.CheckRemoteItems(t, f1, f2)
+ r.CheckLocalItems(t, f1, f2)
+
+ // Now rename locally.
+ f2 = r.RenameFile(f2, "yam")
+
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ require.NoError(t, Sync(ctx, r.Fremote, r.Flocal, false))
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ r.CheckRemoteItems(t, f1, f2)
+
+ // Check we renamed something if we should have
+ if canTrackRenames {
+ renames := accounting.GlobalStats().Renames(0)
+ assert.Equal(t, canTrackRenames, renames != 0, fmt.Sprintf("canTrackRenames=%v, renames=%d", canTrackRenames, renames))
+ }
+}
+
+func toyFileTransfers(r *fstest.Run) int64 {
+ remote := r.Fremote.Name()
+ transfers := 1
+ if strings.HasPrefix(remote, "TestChunker") && strings.HasSuffix(remote, "S3") {
+ transfers++ // Extra Copy because S3 emulates Move as Copy+Delete.
+ }
+ return int64(transfers)
+}
+
+// Test a server-side move if possible, or the backup path if not
+func testServerSideMove(ctx context.Context, t *testing.T, r *fstest.Run, withFilter, testDeleteEmptyDirs bool) {
+ FremoteMove, _, finaliseMove, err := fstest.RandomRemote()
+ require.NoError(t, err)
+ defer finaliseMove()
+
+ file1 := r.WriteBoth(ctx, "potato2", "------------------------------------------------------------", t1)
+ file2 := r.WriteBoth(ctx, "empty space", "-", t2)
+ file3u := r.WriteBoth(ctx, "potato3", "------------------------------------------------------------ UPDATED", t2)
+
+ if testDeleteEmptyDirs {
+ err := operations.Mkdir(ctx, r.Fremote, "tomatoDir")
+ require.NoError(t, err)
+ }
+
+ r.CheckRemoteItems(t, file2, file1, file3u)
+
+ t.Logf("Server side move (if possible) %v -> %v", r.Fremote, FremoteMove)
+
+ // Write just one file in the new remote
+ r.WriteObjectTo(ctx, FremoteMove, "empty space", "-", t2, false)
+ file3 := r.WriteObjectTo(ctx, FremoteMove, "potato3", "------------------------------------------------------------", t1, false)
+ fstest.CheckItems(t, FremoteMove, file2, file3)
+
+ // Do server-side move
+ accounting.GlobalStats().ResetCounters()
+ // ctx = predictDstFromLogger(ctx) // not currently supported -- doesn't list all contents of dir.
+ err = MoveDir(ctx, FremoteMove, r.Fremote, testDeleteEmptyDirs, false)
+ require.NoError(t, err)
+ // testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ if withFilter {
+ r.CheckRemoteItems(t, file2)
+ } else {
+ r.CheckRemoteItems(t)
+ }
+
+ if testDeleteEmptyDirs {
+ r.CheckRemoteListing(t, nil, []string{})
+ }
+
+ fstest.CheckItems(t, FremoteMove, file2, file1, file3u)
+
+ // Create a new empty remote for stuff to be moved into
+ FremoteMove2, _, finaliseMove2, err := fstest.RandomRemote()
+ require.NoError(t, err)
+ defer finaliseMove2()
+
+ if testDeleteEmptyDirs {
+ err := operations.Mkdir(ctx, FremoteMove, "tomatoDir")
+ require.NoError(t, err)
+ }
+
+ // Move it back to a new empty remote, dst does not exist this time
+ accounting.GlobalStats().ResetCounters()
+ // ctx = predictDstFromLogger(ctx)
+ err = MoveDir(ctx, FremoteMove2, FremoteMove, testDeleteEmptyDirs, false)
+ require.NoError(t, err)
+ // testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ if withFilter {
+ fstest.CheckItems(t, FremoteMove2, file1, file3u)
+ fstest.CheckItems(t, FremoteMove, file2)
+ } else {
+ fstest.CheckItems(t, FremoteMove2, file2, file1, file3u)
+ fstest.CheckItems(t, FremoteMove)
+ }
+
+ if testDeleteEmptyDirs {
+ fstest.CheckListingWithPrecision(t, FremoteMove, nil, []string{}, fs.GetModifyWindow(ctx, r.Fremote))
+ }
+}
+
+// Test MoveDir on Local
+func TestServerSideMoveLocal(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ f1 := r.WriteFile("dir1/file1.txt", "hello", t1)
+ f2 := r.WriteFile("dir2/file2.txt", "hello again", t2)
+ r.CheckLocalItems(t, f1, f2)
+
+ dir1, err := fs.NewFs(ctx, r.Flocal.Root()+"/dir1")
+ require.NoError(t, err)
+ dir2, err := fs.NewFs(ctx, r.Flocal.Root()+"/dir2")
+ require.NoError(t, err)
+ err = MoveDir(ctx, dir2, dir1, false, false)
+ require.NoError(t, err)
+}
+
+// Test move
+func TestMoveWithDeleteEmptySrcDirs(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ file1 := r.WriteFile("sub dir/hello world", "hello world", t1)
+ file2 := r.WriteFile("nested/sub dir/file", "nested", t1)
+ r.Mkdir(ctx, r.Fremote)
+
+ // run move with --delete-empty-src-dirs
+ ctx = predictDstFromLogger(ctx)
+ err := MoveDir(ctx, r.Fremote, r.Flocal, true, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ r.CheckLocalListing(
+ t,
+ nil,
+ []string{},
+ )
+ r.CheckRemoteItems(t, file1, file2)
+}
+
+func TestMoveWithoutDeleteEmptySrcDirs(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ file1 := r.WriteFile("sub dir/hello world", "hello world", t1)
+ file2 := r.WriteFile("nested/sub dir/file", "nested", t1)
+ r.Mkdir(ctx, r.Fremote)
+
+ ctx = predictDstFromLogger(ctx)
+ err := MoveDir(ctx, r.Fremote, r.Flocal, false, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ r.CheckLocalListing(
+ t,
+ nil,
+ []string{
+ "sub dir",
+ "nested",
+ "nested/sub dir",
+ },
+ )
+ r.CheckRemoteItems(t, file1, file2)
+}
+
+func TestMoveWithIgnoreExisting(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+ file1 := r.WriteFile("existing", "potato", t1)
+ file2 := r.WriteFile("existing-b", "tomato", t1)
+
+ ci.IgnoreExisting = true
+
+ accounting.GlobalStats().ResetCounters()
+ // ctx = predictDstFromLogger(ctx)
+ err := MoveDir(ctx, r.Fremote, r.Flocal, false, false)
+ require.NoError(t, err)
+ // testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ r.CheckLocalListing(
+ t,
+ []fstest.Item{},
+ []string{},
+ )
+ r.CheckRemoteListing(
+ t,
+ []fstest.Item{
+ file1,
+ file2,
+ },
+ []string{},
+ )
+
+ // Recreate first file with modified content
+ file1b := r.WriteFile("existing", "newpotatoes", t2)
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ err = MoveDir(ctx, r.Fremote, r.Flocal, false, false)
+ require.NoError(t, err)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ // Source items should still exist in modified state
+ r.CheckLocalListing(
+ t,
+ []fstest.Item{
+ file1b,
+ },
+ []string{},
+ )
+ // Dest items should not have changed
+ r.CheckRemoteListing(
+ t,
+ []fstest.Item{
+ file1,
+ file2,
+ },
+ []string{},
+ )
+}
+
+// Test a server-side move if possible, or the backup path if not
+func TestServerSideMove(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ testServerSideMove(ctx, t, r, false, false)
+}
+
+// Test a server-side move if possible, or the backup path if not
+func TestServerSideMoveWithFilter(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+
+ fi, err := filter.NewFilter(nil)
+ require.NoError(t, err)
+ fi.Opt.MinSize = 40
+ ctx = filter.ReplaceConfig(ctx, fi)
+
+ testServerSideMove(ctx, t, r, true, false)
+}
+
+// Test a server-side move if possible
+func TestServerSideMoveDeleteEmptySourceDirs(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ testServerSideMove(ctx, t, r, false, true)
+}
+
+// Test a server-side move with overlap
+func TestServerSideMoveOverlap(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+
+ if r.Fremote.Features().DirMove != nil {
+ t.Skip("Skipping test as remote supports DirMove")
+ }
+
+ subRemoteName := r.FremoteName + "/rclone-move-test"
+ FremoteMove, err := fs.NewFs(ctx, subRemoteName)
+ require.NoError(t, err)
+
+ file1 := r.WriteObject(ctx, "potato2", "------------------------------------------------------------", t1)
+ r.CheckRemoteItems(t, file1)
+
+ // Subdir move with no filters should return ErrorCantMoveOverlapping
+ // ctx = predictDstFromLogger(ctx)
+ err = MoveDir(ctx, FremoteMove, r.Fremote, false, false)
+ assert.EqualError(t, err, fs.ErrorOverlapping.Error())
+ // testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ // Now try with a filter which should also fail with ErrorCantMoveOverlapping
+ fi, err := filter.NewFilter(nil)
+ require.NoError(t, err)
+ fi.Opt.MinSize = 40
+ ctx = filter.ReplaceConfig(ctx, fi)
+
+ // ctx = predictDstFromLogger(ctx)
+ err = MoveDir(ctx, FremoteMove, r.Fremote, false, false)
+ assert.EqualError(t, err, fs.ErrorOverlapping.Error())
+ // testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+}
+
+// Test a sync with overlap
+func TestSyncOverlap(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+
+ subRemoteName := r.FremoteName + "/rclone-sync-test"
+ FremoteSync, err := fs.NewFs(ctx, subRemoteName)
+ require.NoError(t, err)
+
+ checkErr := func(err error) {
+ require.Error(t, err)
+ assert.True(t, fserrors.IsFatalError(err))
+ assert.Equal(t, fs.ErrorOverlapping.Error(), err.Error())
+ }
+
+ ctx = predictDstFromLogger(ctx)
+ checkErr(Sync(ctx, FremoteSync, r.Fremote, false))
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ ctx = predictDstFromLogger(ctx)
+ checkErr(Sync(ctx, r.Fremote, FremoteSync, false))
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ ctx = predictDstFromLogger(ctx)
+ checkErr(Sync(ctx, r.Fremote, r.Fremote, false))
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ ctx = predictDstFromLogger(ctx)
+ checkErr(Sync(ctx, FremoteSync, FremoteSync, false))
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+}
+
+// Test a sync with filtered overlap
+func TestSyncOverlapWithFilter(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+
+ fi, err := filter.NewFilter(nil)
+ require.NoError(t, err)
+ require.NoError(t, fi.Add(false, "/rclone-sync-test/"))
+ require.NoError(t, fi.Add(false, "*/layer2/"))
+ fi.Opt.ExcludeFile = []string{".ignore"}
+ filterCtx := filter.ReplaceConfig(ctx, fi)
+
+ subRemoteName := r.FremoteName + "/rclone-sync-test"
+ FremoteSync, err := fs.NewFs(ctx, subRemoteName)
+ require.NoError(t, FremoteSync.Mkdir(ctx, ""))
+ require.NoError(t, err)
+
+ subRemoteName2 := r.FremoteName + "/rclone-sync-test-include/layer2"
+ FremoteSync2, err := fs.NewFs(ctx, subRemoteName2)
+ require.NoError(t, FremoteSync2.Mkdir(ctx, ""))
+ require.NoError(t, err)
+
+ subRemoteName3 := r.FremoteName + "/rclone-sync-test-ignore-file"
+ FremoteSync3, err := fs.NewFs(ctx, subRemoteName3)
+ require.NoError(t, FremoteSync3.Mkdir(ctx, ""))
+ require.NoError(t, err)
+ r.WriteObject(context.Background(), "rclone-sync-test-ignore-file/.ignore", "-", t1)
+
+ checkErr := func(err error) {
+ require.Error(t, err)
+ assert.True(t, fserrors.IsFatalError(err))
+ assert.Equal(t, fs.ErrorOverlapping.Error(), err.Error())
+ accounting.GlobalStats().ResetCounters()
+ }
+
+ checkNoErr := func(err error) {
+ require.NoError(t, err)
+ }
+
+ accounting.GlobalStats().ResetCounters()
+ filterCtx = predictDstFromLogger(filterCtx)
+ checkNoErr(Sync(filterCtx, FremoteSync, r.Fremote, false))
+ checkErr(Sync(ctx, FremoteSync, r.Fremote, false))
+ checkNoErr(Sync(filterCtx, r.Fremote, FremoteSync, false))
+ testLoggerVsLsf(filterCtx, r.Fremote, r.Flocal, operations.GetLoggerOpt(filterCtx).JSON, t)
+ filterCtx = predictDstFromLogger(filterCtx)
+ checkErr(Sync(ctx, r.Fremote, FremoteSync, false))
+ testLoggerVsLsf(filterCtx, r.Fremote, r.Flocal, operations.GetLoggerOpt(filterCtx).JSON, t)
+ filterCtx = predictDstFromLogger(filterCtx)
+ checkErr(Sync(filterCtx, r.Fremote, r.Fremote, false))
+ testLoggerVsLsf(filterCtx, r.Fremote, r.Flocal, operations.GetLoggerOpt(filterCtx).JSON, t)
+ filterCtx = predictDstFromLogger(filterCtx)
+ checkErr(Sync(ctx, r.Fremote, r.Fremote, false))
+ testLoggerVsLsf(filterCtx, r.Fremote, r.Flocal, operations.GetLoggerOpt(filterCtx).JSON, t)
+ filterCtx = predictDstFromLogger(filterCtx)
+ checkErr(Sync(filterCtx, FremoteSync, FremoteSync, false))
+ testLoggerVsLsf(filterCtx, r.Fremote, r.Flocal, operations.GetLoggerOpt(filterCtx).JSON, t)
+ filterCtx = predictDstFromLogger(filterCtx)
+ checkErr(Sync(ctx, FremoteSync, FremoteSync, false))
+ testLoggerVsLsf(filterCtx, r.Fremote, r.Flocal, operations.GetLoggerOpt(filterCtx).JSON, t)
+ filterCtx = predictDstFromLogger(filterCtx)
+
+ checkNoErr(Sync(filterCtx, FremoteSync2, r.Fremote, false))
+ testLoggerVsLsf(filterCtx, r.Fremote, r.Flocal, operations.GetLoggerOpt(filterCtx).JSON, t)
+ filterCtx = predictDstFromLogger(filterCtx)
+ checkErr(Sync(ctx, FremoteSync2, r.Fremote, false))
+ testLoggerVsLsf(filterCtx, r.Fremote, r.Flocal, operations.GetLoggerOpt(filterCtx).JSON, t)
+ filterCtx = predictDstFromLogger(filterCtx)
+ checkNoErr(Sync(filterCtx, r.Fremote, FremoteSync2, false))
+ testLoggerVsLsf(filterCtx, r.Fremote, r.Flocal, operations.GetLoggerOpt(filterCtx).JSON, t)
+ filterCtx = predictDstFromLogger(filterCtx)
+ checkErr(Sync(ctx, r.Fremote, FremoteSync2, false))
+ testLoggerVsLsf(filterCtx, r.Fremote, r.Flocal, operations.GetLoggerOpt(filterCtx).JSON, t)
+ filterCtx = predictDstFromLogger(filterCtx)
+ checkErr(Sync(filterCtx, FremoteSync2, FremoteSync2, false))
+ testLoggerVsLsf(filterCtx, r.Fremote, r.Flocal, operations.GetLoggerOpt(filterCtx).JSON, t)
+ filterCtx = predictDstFromLogger(filterCtx)
+ checkErr(Sync(ctx, FremoteSync2, FremoteSync2, false))
+ testLoggerVsLsf(filterCtx, r.Fremote, r.Flocal, operations.GetLoggerOpt(filterCtx).JSON, t)
+ filterCtx = predictDstFromLogger(filterCtx)
+
+ checkNoErr(Sync(filterCtx, FremoteSync3, r.Fremote, false))
+ testLoggerVsLsf(filterCtx, r.Fremote, r.Flocal, operations.GetLoggerOpt(filterCtx).JSON, t)
+ filterCtx = predictDstFromLogger(filterCtx)
+ checkErr(Sync(ctx, FremoteSync3, r.Fremote, false))
+ testLoggerVsLsf(filterCtx, r.Fremote, r.Flocal, operations.GetLoggerOpt(filterCtx).JSON, t)
+ filterCtx = predictDstFromLogger(filterCtx)
+ // Destination is excluded so this test makes no sense
+ // checkNoErr(Sync(filterCtx, r.Fremote, FremoteSync3, false))
+ checkErr(Sync(ctx, r.Fremote, FremoteSync3, false))
+ testLoggerVsLsf(filterCtx, r.Fremote, r.Flocal, operations.GetLoggerOpt(filterCtx).JSON, t)
+ filterCtx = predictDstFromLogger(filterCtx)
+ checkErr(Sync(filterCtx, FremoteSync3, FremoteSync3, false))
+ testLoggerVsLsf(filterCtx, r.Fremote, r.Flocal, operations.GetLoggerOpt(filterCtx).JSON, t)
+ filterCtx = predictDstFromLogger(filterCtx)
+ checkErr(Sync(ctx, FremoteSync3, FremoteSync3, false))
+ testLoggerVsLsf(filterCtx, r.Fremote, r.Flocal, operations.GetLoggerOpt(filterCtx).JSON, t)
+}
+
+// Test with CompareDest set
+func TestSyncCompareDest(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+
+ ci.CompareDest = []string{r.FremoteName + "/CompareDest"}
+
+ fdst, err := fs.NewFs(ctx, r.FremoteName+"/dst")
+ require.NoError(t, err)
+
+ // check empty dest, empty compare
+ file1 := r.WriteFile("one", "one", t1)
+ r.CheckLocalItems(t, file1)
+
+ accounting.GlobalStats().ResetCounters()
+ // ctx = predictDstFromLogger(ctx) // not currently supported due to duplicate equal() checks
+ err = Sync(ctx, fdst, r.Flocal, false)
+ // testLoggerVsLsf(ctx, fdst, operations.GetLoggerOpt(ctx).JSON, t)
+ require.NoError(t, err)
+
+ file1dst := file1
+ file1dst.Path = "dst/one"
+
+ r.CheckRemoteItems(t, file1dst)
+
+ // check old dest, empty compare
+ file1b := r.WriteFile("one", "onet2", t2)
+ r.CheckRemoteItems(t, file1dst)
+ r.CheckLocalItems(t, file1b)
+
+ accounting.GlobalStats().ResetCounters()
+ // ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, fdst, r.Flocal, false)
+ // testLoggerVsLsf(ctx, fdst, operations.GetLoggerOpt(ctx).JSON, t)
+ require.NoError(t, err)
+
+ file1bdst := file1b
+ file1bdst.Path = "dst/one"
+
+ r.CheckRemoteItems(t, file1bdst)
+
+ // check old dest, new compare
+ file3 := r.WriteObject(ctx, "dst/one", "one", t1)
+ file2 := r.WriteObject(ctx, "CompareDest/one", "onet2", t2)
+ file1c := r.WriteFile("one", "onet2", t2)
+ r.CheckRemoteItems(t, file2, file3)
+ r.CheckLocalItems(t, file1c)
+
+ accounting.GlobalStats().ResetCounters()
+ // ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, fdst, r.Flocal, false)
+ // testLoggerVsLsf(ctx, fdst, operations.GetLoggerOpt(ctx).JSON, t)
+ require.NoError(t, err)
+
+ r.CheckRemoteItems(t, file2, file3)
+
+ // check empty dest, new compare
+ file4 := r.WriteObject(ctx, "CompareDest/two", "two", t2)
+ file5 := r.WriteFile("two", "two", t2)
+ r.CheckRemoteItems(t, file2, file3, file4)
+ r.CheckLocalItems(t, file1c, file5)
+
+ accounting.GlobalStats().ResetCounters()
+ // ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, fdst, r.Flocal, false)
+ // testLoggerVsLsf(ctx, fdst, operations.GetLoggerOpt(ctx).JSON, t)
+ require.NoError(t, err)
+
+ r.CheckRemoteItems(t, file2, file3, file4)
+
+ // check new dest, new compare
+ accounting.GlobalStats().ResetCounters()
+ // ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, fdst, r.Flocal, false)
+ // testLoggerVsLsf(ctx, fdst, operations.GetLoggerOpt(ctx).JSON, t)
+ require.NoError(t, err)
+
+ r.CheckRemoteItems(t, file2, file3, file4)
+
+ // Work out if we actually have hashes for uploaded files
+ haveHash := false
+ if ht := fdst.Hashes().GetOne(); ht != hash.None {
+ file2obj, err := fdst.NewObject(ctx, "one")
+ if err == nil {
+ file2objHash, err := file2obj.Hash(ctx, ht)
+ if err == nil {
+ haveHash = file2objHash != ""
+ }
+ }
+ }
+
+ // check new dest, new compare, src timestamp differs
+ //
+ // we only check this if we the file we uploaded previously
+ // actually has a hash otherwise the differing timestamp is
+ // always copied.
+ if haveHash {
+ file5b := r.WriteFile("two", "two", t3)
+ r.CheckLocalItems(t, file1c, file5b)
+
+ accounting.GlobalStats().ResetCounters()
+ // ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, fdst, r.Flocal, false)
+ // testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ require.NoError(t, err)
+
+ r.CheckRemoteItems(t, file2, file3, file4)
+ } else {
+ t.Log("No hash on uploaded file so skipping compare timestamp test")
+ }
+
+ // check empty dest, old compare
+ file5c := r.WriteFile("two", "twot3", t3)
+ r.CheckRemoteItems(t, file2, file3, file4)
+ r.CheckLocalItems(t, file1c, file5c)
+
+ accounting.GlobalStats().ResetCounters()
+ // ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, fdst, r.Flocal, false)
+ // testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ require.NoError(t, err)
+
+ file5cdst := file5c
+ file5cdst.Path = "dst/two"
+
+ r.CheckRemoteItems(t, file2, file3, file4, file5cdst)
+}
+
+// Test with multiple CompareDest
+func TestSyncMultipleCompareDest(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+ precision := fs.GetModifyWindow(ctx, r.Fremote, r.Flocal)
+
+ ci.CompareDest = []string{r.FremoteName + "/pre-dest1", r.FremoteName + "/pre-dest2"}
+
+ // check empty dest, new compare
+ fsrc1 := r.WriteFile("1", "1", t1)
+ fsrc2 := r.WriteFile("2", "2", t1)
+ fsrc3 := r.WriteFile("3", "3", t1)
+ r.CheckLocalItems(t, fsrc1, fsrc2, fsrc3)
+
+ fdest1 := r.WriteObject(ctx, "pre-dest1/1", "1", t1)
+ fdest2 := r.WriteObject(ctx, "pre-dest2/2", "2", t1)
+ r.CheckRemoteItems(t, fdest1, fdest2)
+
+ accounting.GlobalStats().ResetCounters()
+ fdst, err := fs.NewFs(ctx, r.FremoteName+"/dest")
+ require.NoError(t, err)
+ // ctx = predictDstFromLogger(ctx)
+ require.NoError(t, Sync(ctx, fdst, r.Flocal, false))
+ // testLoggerVsLsf(ctx, fdst, operations.GetLoggerOpt(ctx).JSON, t)
+
+ fdest3 := fsrc3
+ fdest3.Path = "dest/3"
+
+ fstest.CheckItemsWithPrecision(t, fdst, precision, fsrc3)
+ r.CheckRemoteItems(t, fdest1, fdest2, fdest3)
+}
+
+// Test with CopyDest set
+func TestSyncCopyDest(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+
+ if r.Fremote.Features().Copy == nil {
+ t.Skip("Skipping test as remote does not support server-side copy")
+ }
+
+ ci.CopyDest = []string{r.FremoteName + "/CopyDest"}
+
+ fdst, err := fs.NewFs(ctx, r.FremoteName+"/dst")
+ require.NoError(t, err)
+
+ // check empty dest, empty copy
+ file1 := r.WriteFile("one", "one", t1)
+ r.CheckLocalItems(t, file1)
+
+ accounting.GlobalStats().ResetCounters()
+ // ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, fdst, r.Flocal, false)
+ // testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t) // not currently supported
+ require.NoError(t, err)
+
+ file1dst := file1
+ file1dst.Path = "dst/one"
+
+ r.CheckRemoteItems(t, file1dst)
+
+ // check old dest, empty copy
+ file1b := r.WriteFile("one", "onet2", t2)
+ r.CheckRemoteItems(t, file1dst)
+ r.CheckLocalItems(t, file1b)
+
+ accounting.GlobalStats().ResetCounters()
+ // ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, fdst, r.Flocal, false)
+ // testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ require.NoError(t, err)
+
+ file1bdst := file1b
+ file1bdst.Path = "dst/one"
+
+ r.CheckRemoteItems(t, file1bdst)
+
+ // check old dest, new copy, backup-dir
+
+ ci.BackupDir = r.FremoteName + "/BackupDir"
+
+ file3 := r.WriteObject(ctx, "dst/one", "one", t1)
+ file2 := r.WriteObject(ctx, "CopyDest/one", "onet2", t2)
+ file1c := r.WriteFile("one", "onet2", t2)
+ r.CheckRemoteItems(t, file2, file3)
+ r.CheckLocalItems(t, file1c)
+
+ accounting.GlobalStats().ResetCounters()
+ // ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, fdst, r.Flocal, false)
+ // testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ require.NoError(t, err)
+
+ file2dst := file2
+ file2dst.Path = "dst/one"
+ file3.Path = "BackupDir/one"
+
+ r.CheckRemoteItems(t, file2, file2dst, file3)
+ ci.BackupDir = ""
+
+ // check empty dest, new copy
+ file4 := r.WriteObject(ctx, "CopyDest/two", "two", t2)
+ file5 := r.WriteFile("two", "two", t2)
+ r.CheckRemoteItems(t, file2, file2dst, file3, file4)
+ r.CheckLocalItems(t, file1c, file5)
+
+ accounting.GlobalStats().ResetCounters()
+ // ctx = predictDstFromLogger(ctx)
+ // testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ err = Sync(ctx, fdst, r.Flocal, false)
+ require.NoError(t, err)
+
+ file4dst := file4
+ file4dst.Path = "dst/two"
+
+ r.CheckRemoteItems(t, file2, file2dst, file3, file4, file4dst)
+
+ // check new dest, new copy
+ accounting.GlobalStats().ResetCounters()
+ // ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, fdst, r.Flocal, false)
+ // testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ require.NoError(t, err)
+
+ r.CheckRemoteItems(t, file2, file2dst, file3, file4, file4dst)
+
+ // check empty dest, old copy
+ file6 := r.WriteObject(ctx, "CopyDest/three", "three", t2)
+ file7 := r.WriteFile("three", "threet3", t3)
+ r.CheckRemoteItems(t, file2, file2dst, file3, file4, file4dst, file6)
+ r.CheckLocalItems(t, file1c, file5, file7)
+
+ accounting.GlobalStats().ResetCounters()
+ // ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, fdst, r.Flocal, false)
+ // testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ require.NoError(t, err)
+
+ file7dst := file7
+ file7dst.Path = "dst/three"
+
+ r.CheckRemoteItems(t, file2, file2dst, file3, file4, file4dst, file6, file7dst)
+}
+
+// Test with BackupDir set
+func testSyncBackupDir(t *testing.T, backupDir string, suffix string, suffixKeepExtension bool) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+
+ if !operations.CanServerSideMove(r.Fremote) {
+ t.Skip("Skipping test as remote does not support server-side move")
+ }
+ r.Mkdir(ctx, r.Fremote)
+
+ if backupDir != "" {
+ ci.BackupDir = r.FremoteName + "/" + backupDir
+ backupDir += "/"
+ } else {
+ ci.BackupDir = ""
+ backupDir = "dst/"
+ // Exclude the suffix from the sync otherwise the sync
+ // deletes the old backup files
+ flt, err := filter.NewFilter(nil)
+ require.NoError(t, err)
+ require.NoError(t, flt.AddRule("- *"+suffix))
+ // Change the active filter
+ ctx = filter.ReplaceConfig(ctx, flt)
+ }
+ ci.Suffix = suffix
+ ci.SuffixKeepExtension = suffixKeepExtension
+
+ // Make the setup so we have one, two, three in the dest
+ // and one (different), two (same) in the source
+ file1 := r.WriteObject(ctx, "dst/one", "one", t1)
+ file2 := r.WriteObject(ctx, "dst/two", "two", t1)
+ file3 := r.WriteObject(ctx, "dst/three.txt", "three", t1)
+ file2a := r.WriteFile("two", "two", t1)
+ file1a := r.WriteFile("one", "oneA", t2)
+
+ r.CheckRemoteItems(t, file1, file2, file3)
+ r.CheckLocalItems(t, file1a, file2a)
+
+ fdst, err := fs.NewFs(ctx, r.FremoteName+"/dst")
+ require.NoError(t, err)
+
+ accounting.GlobalStats().ResetCounters()
+ err = Sync(ctx, fdst, r.Flocal, false)
+ require.NoError(t, err)
+
+ // one should be moved to the backup dir and the new one installed
+ file1.Path = backupDir + "one" + suffix
+ file1a.Path = "dst/one"
+ // two should be unchanged
+ // three should be moved to the backup dir
+ if suffixKeepExtension {
+ file3.Path = backupDir + "three" + suffix + ".txt"
+ } else {
+ file3.Path = backupDir + "three.txt" + suffix
+ }
+
+ r.CheckRemoteItems(t, file1, file2, file3, file1a)
+
+ // Now check what happens if we do it again
+ // Restore a different three and update one in the source
+ file3a := r.WriteObject(ctx, "dst/three.txt", "threeA", t2)
+ file1b := r.WriteFile("one", "oneBB", t3)
+ r.CheckRemoteItems(t, file1, file2, file3, file1a, file3a)
+
+ // This should delete three and overwrite one again, checking
+ // the files got overwritten correctly in backup-dir
+ accounting.GlobalStats().ResetCounters()
+ err = Sync(ctx, fdst, r.Flocal, false)
+ require.NoError(t, err)
+
+ // one should be moved to the backup dir and the new one installed
+ file1a.Path = backupDir + "one" + suffix
+ file1b.Path = "dst/one"
+ // two should be unchanged
+ // three should be moved to the backup dir
+ if suffixKeepExtension {
+ file3a.Path = backupDir + "three" + suffix + ".txt"
+ } else {
+ file3a.Path = backupDir + "three.txt" + suffix
+ }
+
+ r.CheckRemoteItems(t, file1b, file2, file3a, file1a)
+}
+
+func TestSyncBackupDir(t *testing.T) {
+ testSyncBackupDir(t, "backup", "", false)
+}
+
+func TestSyncBackupDirWithSuffix(t *testing.T) {
+ testSyncBackupDir(t, "backup", ".bak", false)
+}
+
+func TestSyncBackupDirWithSuffixKeepExtension(t *testing.T) {
+ testSyncBackupDir(t, "backup", "-2019-01-01", true)
+}
+
+func TestSyncBackupDirSuffixOnly(t *testing.T) {
+ testSyncBackupDir(t, "", ".bak", false)
+}
+
+// Test with Suffix set
+func testSyncSuffix(t *testing.T, suffix string, suffixKeepExtension bool) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+
+ if !operations.CanServerSideMove(r.Fremote) {
+ t.Skip("Skipping test as remote does not support server-side move")
+ }
+ r.Mkdir(ctx, r.Fremote)
+
+ ci.Suffix = suffix
+ ci.SuffixKeepExtension = suffixKeepExtension
+
+ // Make the setup so we have one, two, three in the dest
+ // and one (different), two (same) in the source
+ file1 := r.WriteObject(ctx, "dst/one", "one", t1)
+ file2 := r.WriteObject(ctx, "dst/two", "two", t1)
+ file3 := r.WriteObject(ctx, "dst/three.txt", "three", t1)
+ file2a := r.WriteFile("two", "two", t1)
+ file1a := r.WriteFile("one", "oneA", t2)
+ file3a := r.WriteFile("three.txt", "threeA", t1)
+
+ r.CheckRemoteItems(t, file1, file2, file3)
+ r.CheckLocalItems(t, file1a, file2a, file3a)
+
+ fdst, err := fs.NewFs(ctx, r.FremoteName+"/dst")
+ require.NoError(t, err)
+
+ accounting.GlobalStats().ResetCounters()
+ err = operations.CopyFile(ctx, fdst, r.Flocal, "one", "one")
+ require.NoError(t, err)
+ err = operations.CopyFile(ctx, fdst, r.Flocal, "two", "two")
+ require.NoError(t, err)
+ err = operations.CopyFile(ctx, fdst, r.Flocal, "three.txt", "three.txt")
+ require.NoError(t, err)
+
+ // one should be moved to the backup dir and the new one installed
+ file1.Path = "dst/one" + suffix
+ file1a.Path = "dst/one"
+ // two should be unchanged
+ // three should be moved to the backup dir
+ if suffixKeepExtension {
+ file3.Path = "dst/three" + suffix + ".txt"
+ } else {
+ file3.Path = "dst/three.txt" + suffix
+ }
+ file3a.Path = "dst/three.txt"
+
+ r.CheckRemoteItems(t, file1, file2, file3, file1a, file3a)
+
+ // Now check what happens if we do it again
+ // Restore a different three and update one in the source
+ file3b := r.WriteFile("three.txt", "threeBDifferentSize", t3)
+ file1b := r.WriteFile("one", "oneBB", t3)
+ r.CheckRemoteItems(t, file1, file2, file3, file1a, file3a)
+
+ // This should delete three and overwrite one again, checking
+ // the files got overwritten correctly in backup-dir
+ accounting.GlobalStats().ResetCounters()
+ err = operations.CopyFile(ctx, fdst, r.Flocal, "one", "one")
+ require.NoError(t, err)
+ err = operations.CopyFile(ctx, fdst, r.Flocal, "two", "two")
+ require.NoError(t, err)
+ err = operations.CopyFile(ctx, fdst, r.Flocal, "three.txt", "three.txt")
+ require.NoError(t, err)
+
+ // one should be moved to the backup dir and the new one installed
+ file1a.Path = "dst/one" + suffix
+ file1b.Path = "dst/one"
+ // two should be unchanged
+ // three should be moved to the backup dir
+ if suffixKeepExtension {
+ file3a.Path = "dst/three" + suffix + ".txt"
+ } else {
+ file3a.Path = "dst/three.txt" + suffix
+ }
+ file3b.Path = "dst/three.txt"
+
+ r.CheckRemoteItems(t, file1b, file3b, file2, file3a, file1a)
+}
+func TestSyncSuffix(t *testing.T) { testSyncSuffix(t, ".bak", false) }
+func TestSyncSuffixKeepExtension(t *testing.T) { testSyncSuffix(t, "-2019-01-01", true) }
+
+// Check we can sync two files with differing UTF-8 representations
+func TestSyncUTFNorm(t *testing.T) {
+ ctx := context.Background()
+ if runtime.GOOS == "darwin" {
+ t.Skip("Can't test UTF normalization on OS X")
+ }
+
+ r := fstest.NewRun(t)
+
+ // Two strings with different unicode normalization (from OS X)
+ Encoding1 := "Testêé"
+ Encoding2 := "Testêé"
+ assert.NotEqual(t, Encoding1, Encoding2)
+ assert.Equal(t, norm.NFC.String(Encoding1), norm.NFC.String(Encoding2))
+
+ file1 := r.WriteFile(Encoding1, "This is a test", t1)
+ r.CheckLocalItems(t, file1)
+
+ file2 := r.WriteObject(ctx, Encoding2, "This is a old test", t2)
+ r.CheckRemoteItems(t, file2)
+
+ accounting.GlobalStats().ResetCounters()
+ // ctx = predictDstFromLogger(ctx)
+ err := Sync(ctx, r.Fremote, r.Flocal, false)
+ // testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t) // can't test this on macOS
+ require.NoError(t, err)
+
+ // We should have transferred exactly one file, but kept the
+ // normalized state of the file.
+ assert.Equal(t, toyFileTransfers(r), accounting.GlobalStats().GetTransfers())
+ r.CheckLocalItems(t, file1)
+ file1.Path = file2.Path
+ r.CheckRemoteItems(t, file1)
+}
+
+// Test --immutable
+func TestSyncImmutable(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+
+ ci.Immutable = true
+
+ // Create file on source
+ file1 := r.WriteFile("existing", "potato", t1)
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t)
+
+ // Should succeed
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ err := Sync(ctx, r.Fremote, r.Flocal, false)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ require.NoError(t, err)
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, file1)
+
+ // Modify file data and timestamp on source
+ file2 := r.WriteFile("existing", "tomatoes", t2)
+ r.CheckLocalItems(t, file2)
+ r.CheckRemoteItems(t, file1)
+
+ // Should fail with ErrorImmutableModified and not modify local or remote files
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, r.Fremote, r.Flocal, false)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ assert.EqualError(t, err, fs.ErrorImmutableModified.Error())
+ r.CheckLocalItems(t, file2)
+ r.CheckRemoteItems(t, file1)
+}
+
+// Test --ignore-case-sync
+func TestSyncIgnoreCase(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+
+ // Only test if filesystems are case sensitive
+ if r.Fremote.Features().CaseInsensitive || r.Flocal.Features().CaseInsensitive {
+ t.Skip("Skipping test as local or remote are case-insensitive")
+ }
+
+ ci.IgnoreCaseSync = true
+
+ // Create files with different filename casing
+ file1 := r.WriteFile("existing", "potato", t1)
+ r.CheckLocalItems(t, file1)
+ file2 := r.WriteObject(ctx, "EXISTING", "potato", t1)
+ r.CheckRemoteItems(t, file2)
+
+ // Should not copy files that are differently-cased but otherwise identical
+ accounting.GlobalStats().ResetCounters()
+ // ctx = predictDstFromLogger(ctx)
+ err := Sync(ctx, r.Fremote, r.Flocal, false)
+ // testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t) // can't test this on macOS
+ require.NoError(t, err)
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, file2)
+}
+
+// Test --fix-case
+func TestFixCase(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ r := fstest.NewRun(t)
+
+ // Only test if remote is case insensitive
+ if !r.Fremote.Features().CaseInsensitive {
+ t.Skip("Skipping test as local or remote are case-sensitive")
+ }
+
+ ci.FixCase = true
+
+ // Create files with different filename casing
+ file1a := r.WriteFile("existing", "potato", t1)
+ file1b := r.WriteFile("existingbutdifferent", "donut", t1)
+ file1c := r.WriteFile("subdira/subdirb/subdirc/hello", "donut", t1)
+ file1d := r.WriteFile("subdira/subdirb/subdirc/subdird/filewithoutcasedifferences", "donut", t1)
+ r.CheckLocalItems(t, file1a, file1b, file1c, file1d)
+ file2a := r.WriteObject(ctx, "EXISTING", "potato", t1)
+ file2b := r.WriteObject(ctx, "EXISTINGBUTDIFFERENT", "lemonade", t1)
+ file2c := r.WriteObject(ctx, "SUBDIRA/subdirb/SUBDIRC/HELLO", "lemonade", t1)
+ file2d := r.WriteObject(ctx, "SUBDIRA/subdirb/SUBDIRC/subdird/filewithoutcasedifferences", "lemonade", t1)
+ r.CheckRemoteItems(t, file2a, file2b, file2c, file2d)
+
+ // Should force rename of dest file that is differently-cased
+ accounting.GlobalStats().ResetCounters()
+ err := Sync(ctx, r.Fremote, r.Flocal, false)
+ require.NoError(t, err)
+ r.CheckLocalItems(t, file1a, file1b, file1c, file1d)
+ r.CheckRemoteItems(t, file1a, file1b, file1c, file1d)
+}
+
+// Test that aborting on --max-transfer works
+func TestMaxTransfer(t *testing.T) {
+ ctx := context.Background()
+ ctx, ci := fs.AddConfig(ctx)
+ ci.MaxTransfer = 3 * 1024
+ ci.Transfers = 1
+ ci.Checkers = 1
+ ci.CutoffMode = fs.CutoffModeHard
+
+ test := func(t *testing.T, cutoff fs.CutoffMode) {
+ r := fstest.NewRun(t)
+ ci.CutoffMode = cutoff
+
+ if r.Fremote.Name() != "local" {
+ t.Skip("This test only runs on local")
+ }
+
+ // Create file on source
+ file1 := r.WriteFile("file1", string(make([]byte, 5*1024)), t1)
+ file2 := r.WriteFile("file2", string(make([]byte, 2*1024)), t1)
+ file3 := r.WriteFile("file3", string(make([]byte, 3*1024)), t1)
+ r.CheckLocalItems(t, file1, file2, file3)
+ r.CheckRemoteItems(t)
+
+ if runtime.GOOS == "darwin" {
+ // disable server-side copies as they don't count towards transfer size stats
+ r.Flocal.Features().Disable("Copy")
+ if r.Fremote.Features().IsLocal {
+ r.Fremote.Features().Disable("Copy")
+ }
+ }
+
+ accounting.GlobalStats().ResetCounters()
+
+ // ctx = predictDstFromLogger(ctx) // not currently supported
+ err := Sync(ctx, r.Fremote, r.Flocal, false)
+ // testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ expectedErr := fserrors.FsError(accounting.ErrorMaxTransferLimitReachedFatal)
+ if cutoff != fs.CutoffModeHard {
+ expectedErr = accounting.ErrorMaxTransferLimitReachedGraceful
+ }
+ fserrors.Count(expectedErr)
+ assert.Equal(t, expectedErr, err)
+ }
+
+ t.Run("Hard", func(t *testing.T) { test(t, fs.CutoffModeHard) })
+ t.Run("Soft", func(t *testing.T) { test(t, fs.CutoffModeSoft) })
+ t.Run("Cautious", func(t *testing.T) { test(t, fs.CutoffModeCautious) })
+}
+
+func testSyncConcurrent(t *testing.T, subtest string) {
+ const (
+ NFILES = 20
+ NCHECKERS = 4
+ NTRANSFERS = 4
+ )
+
+ ctx, ci := fs.AddConfig(context.Background())
+ ci.Checkers = NCHECKERS
+ ci.Transfers = NTRANSFERS
+
+ r := fstest.NewRun(t)
+ stats := accounting.GlobalStats()
+
+ itemsBefore := []fstest.Item{}
+ itemsAfter := []fstest.Item{}
+ for i := range NFILES {
+ nameBoth := fmt.Sprintf("both%d", i)
+ nameOnly := fmt.Sprintf("only%d", i)
+ switch subtest {
+ case "delete":
+ fileBoth := r.WriteBoth(ctx, nameBoth, "potato", t1)
+ fileOnly := r.WriteObject(ctx, nameOnly, "potato", t1)
+ itemsBefore = append(itemsBefore, fileBoth, fileOnly)
+ itemsAfter = append(itemsAfter, fileBoth)
+ case "truncate":
+ fileBoth := r.WriteBoth(ctx, nameBoth, "potato", t1)
+ fileFull := r.WriteObject(ctx, nameOnly, "potato", t1)
+ fileEmpty := r.WriteFile(nameOnly, "", t1)
+ itemsBefore = append(itemsBefore, fileBoth, fileFull)
+ itemsAfter = append(itemsAfter, fileBoth, fileEmpty)
+ }
+ }
+
+ r.CheckRemoteItems(t, itemsBefore...)
+ stats.ResetErrors()
+ ctx = predictDstFromLogger(ctx)
+ err := Sync(ctx, r.Fremote, r.Flocal, false)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ if errors.Is(err, fs.ErrorCantUploadEmptyFiles) {
+ t.Skipf("Skip test because remote cannot upload empty files")
+ }
+ assert.NoError(t, err, "Sync must not return a error")
+ assert.False(t, stats.Errored(), "Low level errors must not have happened")
+ r.CheckRemoteItems(t, itemsAfter...)
+}
+
+func TestSyncConcurrentDelete(t *testing.T) {
+ testSyncConcurrent(t, "delete")
+}
+
+func TestSyncConcurrentTruncate(t *testing.T) {
+ testSyncConcurrent(t, "truncate")
+}
+
+// Test that sync replaces dir modtimes in dst if they've changed
+func testSyncReplaceDirModTime(t *testing.T, copyEmptySrcDirs bool) {
+ accounting.GlobalStats().ResetCounters()
+ ctx, _ := fs.AddConfig(context.Background())
+ r := fstest.NewRun(t)
+
+ file1 := r.WriteFile("file1", "file1", t2)
+ file2 := r.WriteFile("test_dir1/file2", "file2", t2)
+ file3 := r.WriteFile("test_dir2/sub_dir/file3", "file3", t2)
+ r.CheckLocalItems(t, file1, file2, file3)
+
+ _, err := operations.MkdirModTime(ctx, r.Flocal, "empty_dir", t2)
+ require.NoError(t, err)
+
+ // A directory that's empty on both src and dst
+ _, err = operations.MkdirModTime(ctx, r.Flocal, "empty_on_remote", t2)
+ require.NoError(t, err)
+ _, err = operations.MkdirModTime(ctx, r.Fremote, "empty_on_remote", t2)
+ require.NoError(t, err)
+
+ // set logging
+ // (this checks log output as DirModtime operations do not yet have stats, and r.CheckDirectoryModTimes also does not tell us what actions were taken)
+ oldLogLevel := fs.GetConfig(context.Background()).LogLevel
+ defer func() { fs.GetConfig(context.Background()).LogLevel = oldLogLevel }() // reset to old val after test
+ // need to do this as fs.Infof only respects the globalConfig
+ fs.GetConfig(context.Background()).LogLevel = fs.LogLevelInfo
+
+ // First run
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ output := bilib.CaptureOutput(func() {
+ err := CopyDir(ctx, r.Fremote, r.Flocal, copyEmptySrcDirs)
+ require.NoError(t, err)
+ })
+ require.NotNil(t, output)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+
+ // Save all dirs
+ dirs := []string{"test_dir1", "test_dir2", "test_dir2/sub_dir", "empty_on_remote"}
+ if copyEmptySrcDirs {
+ dirs = append(dirs, "empty_dir")
+ }
+
+ // Change dir modtimes
+ for _, dir := range dirs {
+ _, err := operations.SetDirModTime(ctx, r.Flocal, nil, dir, t1)
+ if err != nil && !errors.Is(err, fs.ErrorNotImplemented) {
+ require.NoError(t, err)
+ }
+ }
+
+ // Run again
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ output = bilib.CaptureOutput(func() {
+ err := CopyDir(ctx, r.Fremote, r.Flocal, copyEmptySrcDirs)
+ require.NoError(t, err)
+ })
+ require.NotNil(t, output)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ r.CheckLocalItems(t, file1, file2, file3)
+ r.CheckRemoteItems(t, file1, file2, file3)
+
+ // Check that the modtimes of the directories are as expected
+ r.CheckDirectoryModTimes(t, dirs...)
+}
+
+func TestSyncReplaceDirModTime(t *testing.T) {
+ testSyncReplaceDirModTime(t, false)
+}
+
+func TestSyncReplaceDirModTimeWithEmptyDirs(t *testing.T) {
+ testSyncReplaceDirModTime(t, true)
+}
+
+// Tests that nothing is transferred when src and dst already match
+// Run the same sync twice, ensure no action is taken the second time
+func testNothingToTransfer(t *testing.T, copyEmptySrcDirs bool) {
+ accounting.GlobalStats().ResetCounters()
+ ctx, _ := fs.AddConfig(context.Background())
+ r := fstest.NewRun(t)
+ file1 := r.WriteFile("sub dir/hello world", "hello world", t1)
+ file2 := r.WriteFile("sub dir2/very/very/very/very/very/nested/subdir/hello world", "hello world", t1)
+ r.CheckLocalItems(t, file1, file2)
+ _, err := operations.SetDirModTime(ctx, r.Flocal, nil, "sub dir", t2)
+ if err != nil && !errors.Is(err, fs.ErrorNotImplemented) {
+ require.NoError(t, err)
+ }
+ r.Mkdir(ctx, r.Fremote)
+ _, err = operations.MkdirModTime(ctx, r.Fremote, "sub dir", t3)
+ require.NoError(t, err)
+
+ // set logging
+ // (this checks log output as DirModtime operations do not yet have stats, and r.CheckDirectoryModTimes also does not tell us what actions were taken)
+ oldLogLevel := fs.GetConfig(context.Background()).LogLevel
+ defer func() { fs.GetConfig(context.Background()).LogLevel = oldLogLevel }() // reset to old val after test
+ // need to do this as fs.Infof only respects the globalConfig
+ fs.GetConfig(context.Background()).LogLevel = fs.LogLevelInfo
+
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ output := bilib.CaptureOutput(func() {
+ err = CopyDir(ctx, r.Fremote, r.Flocal, copyEmptySrcDirs)
+ require.NoError(t, err)
+ })
+ require.NotNil(t, output)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ r.CheckLocalItems(t, file1, file2)
+ r.CheckRemoteItems(t, file1, file2)
+ // Check that the modtimes of the directories are as expected
+ r.CheckDirectoryModTimes(t, "sub dir", "sub dir2", "sub dir2/very", "sub dir2/very/very", "sub dir2/very/very/very/very/very/nested/subdir")
+
+ // check that actions were taken
+ assert.True(t, strings.Contains(string(output), "Copied"), `expected to find at least one "Copied" log: `+string(output))
+ if r.Fremote.Features().DirSetModTime != nil || r.Fremote.Features().MkdirMetadata != nil {
+ assert.True(t, strings.Contains(string(output), "Set directory modification time"), `expected to find at least one "Set directory modification time" log: `+string(output))
+ }
+ assert.False(t, strings.Contains(string(output), "There was nothing to transfer"), `expected to find no "There was nothing to transfer" logs, but found one: `+string(output))
+ assert.True(t, accounting.GlobalStats().GetTransfers() >= 2)
+
+ // run it again and make sure no actions were taken
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ output = bilib.CaptureOutput(func() {
+ err = CopyDir(ctx, r.Fremote, r.Flocal, copyEmptySrcDirs)
+ require.NoError(t, err)
+ })
+ require.NotNil(t, output)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ r.CheckLocalItems(t, file1, file2)
+ r.CheckRemoteItems(t, file1, file2)
+ // Check that the modtimes of the directories are as expected
+ r.CheckDirectoryModTimes(t, "sub dir", "sub dir2", "sub dir2/very", "sub dir2/very/very", "sub dir2/very/very/very/very/very/nested/subdir")
+
+ // check that actions were NOT taken
+ assert.False(t, strings.Contains(string(output), "Copied"), `expected to find no "Copied" logs, but found one: `+string(output))
+ if r.Fremote.Features().DirSetModTime != nil || r.Fremote.Features().MkdirMetadata != nil {
+ assert.False(t, strings.Contains(string(output), "Set directory modification time"), `expected to find no "Set directory modification time" logs, but found one: `+string(output))
+ assert.False(t, strings.Contains(string(output), "Updated directory metadata"), `expected to find no "Updated directory metadata" logs, but found one: `+string(output))
+ assert.False(t, strings.Contains(string(output), "directory"), `expected to find no "directory"-related logs, but found one: `+string(output)) // catch-all
+ }
+ assert.True(t, strings.Contains(string(output), "There was nothing to transfer"), `expected to find a "There was nothing to transfer" log: `+string(output))
+ assert.Equal(t, int64(0), accounting.GlobalStats().GetTransfers())
+
+ // check nested empty dir behavior (FIXME: probably belongs in a separate test)
+ if r.Fremote.Features().DirSetModTime == nil && r.Fremote.Features().MkdirMetadata == nil {
+ return
+ }
+ file3 := r.WriteFile("sub dir2/sub dir3/hello world", "hello again, world", t1)
+ _, err = operations.SetDirModTime(ctx, r.Flocal, nil, "sub dir2", t1)
+ assert.NoError(t, err)
+ _, err = operations.SetDirModTime(ctx, r.Fremote, nil, "sub dir2", t1)
+ assert.NoError(t, err)
+ _, err = operations.MkdirModTime(ctx, r.Flocal, "sub dirEmpty/sub dirEmpty2", t2)
+ assert.NoError(t, err)
+ _, err = operations.SetDirModTime(ctx, r.Flocal, nil, "sub dirEmpty", t2)
+ assert.NoError(t, err)
+
+ accounting.GlobalStats().ResetCounters()
+ ctx = predictDstFromLogger(ctx)
+ output = bilib.CaptureOutput(func() {
+ err = CopyDir(ctx, r.Fremote, r.Flocal, copyEmptySrcDirs)
+ require.NoError(t, err)
+ })
+ require.NotNil(t, output)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ r.CheckLocalItems(t, file1, file2, file3)
+ r.CheckRemoteItems(t, file1, file2, file3)
+ // Check that the modtimes of the directories are as expected
+ r.CheckDirectoryModTimes(t, "sub dir", "sub dir2", "sub dir2/very", "sub dir2/very/very", "sub dir2/very/very/very/very/very/nested/subdir", "sub dir2/sub dir3")
+ if copyEmptySrcDirs {
+ r.CheckDirectoryModTimes(t, "sub dirEmpty", "sub dirEmpty/sub dirEmpty2")
+ assert.True(t, strings.Contains(string(output), "sub dirEmpty:"), `expected to find at least one "sub dirEmpty:" log: `+string(output))
+ } else {
+ assert.False(t, strings.Contains(string(output), "sub dirEmpty:"), `expected to find no "sub dirEmpty:" logs, but found one (empty dir was synced and shouldn't have been): `+string(output))
+ }
+ assert.True(t, strings.Contains(string(output), "sub dir3:"), `expected to find at least one "sub dir3:" log: `+string(output))
+ assert.False(t, strings.Contains(string(output), "sub dir2/very:"), `expected to find no "sub dir2/very:" logs, but found one (unmodified dir was marked modified): `+string(output))
+}
+
+func TestNothingToTransferWithEmptyDirs(t *testing.T) {
+ testNothingToTransfer(t, true)
+}
+
+func TestNothingToTransferWithoutEmptyDirs(t *testing.T) {
+ testNothingToTransfer(t, false)
+}
+
+// for testing logger:
+func predictDstFromLogger(ctx context.Context) context.Context {
+ opt := operations.NewLoggerOpt()
+ var lock mutex.Mutex
+
+ opt.LoggerFn = func(ctx context.Context, sigil operations.Sigil, src, dst fs.DirEntry, err error) {
+ lock.Lock()
+ defer lock.Unlock()
+
+ // ignore dirs for our purposes here
+ if err == fs.ErrorIsDir {
+ return
+ }
+ winner := operations.WinningSide(ctx, sigil, src, dst, err)
+ if winner.Obj != nil {
+ file := winner.Obj
+ obj, ok := file.(fs.ObjectInfo)
+ checksum := ""
+ timeFormat := "2006-01-02 15:04:05"
+ if ok {
+ if obj.Fs().Hashes().GetOne() == hash.MD5 {
+ // skip if no MD5
+ checksum, _ = obj.Hash(ctx, hash.MD5)
+ }
+ timeFormat = operations.FormatForLSFPrecision(obj.Fs().Precision())
+ }
+ errMsg := ""
+ if winner.Err != nil {
+ errMsg = ";" + winner.Err.Error()
+ }
+ operations.SyncFprintf(opt.JSON, "%s;%s;%v;%s%s\n", file.ModTime(ctx).Local().Format(timeFormat), checksum, file.Size(), transform.Path(ctx, file.Remote(), false), errMsg) // TODO: should the transform be handled in the sync instead of here?
+ }
+ }
+ return operations.WithSyncLogger(ctx, opt)
+}
+
+func DstLsf(ctx context.Context, Fremote fs.Fs) *bytes.Buffer {
+ opt := operations.ListJSONOpt{
+ NoModTime: false,
+ NoMimeType: true,
+ DirsOnly: false,
+ FilesOnly: true,
+ Recurse: true,
+ ShowHash: true,
+ HashTypes: []string{"MD5"},
+ }
+
+ var list operations.ListFormat
+
+ list.SetSeparator(";")
+ timeFormat := operations.FormatForLSFPrecision(Fremote.Precision())
+ if Fremote.Precision() == fs.ModTimeNotSupported {
+ timeFormat = "none"
+ }
+ list.AddModTime(timeFormat)
+ list.AddHash(hash.MD5)
+ list.AddSize()
+ list.AddPath()
+
+ out := new(bytes.Buffer)
+
+ err := operations.ListJSON(ctx, Fremote, "", &opt, func(item *operations.ListJSONItem) error {
+ _, _ = fmt.Fprintln(out, list.Format(item))
+ return nil
+ })
+ if err != nil {
+ fs.Errorf(Fremote, "ListJSON error: %v", err)
+ }
+
+ return out
+}
+
+func LoggerMatchesLsf(logger, lsf *bytes.Buffer) error {
+ loggerSplit := bytes.Split(logger.Bytes(), []byte("\n"))
+ sort.SliceStable(loggerSplit, func(i int, j int) bool { return string(loggerSplit[i]) < string(loggerSplit[j]) })
+ lsfSplit := bytes.Split(lsf.Bytes(), []byte("\n"))
+ sort.SliceStable(lsfSplit, func(i int, j int) bool { return string(lsfSplit[i]) < string(lsfSplit[j]) })
+
+ loggerJoined := bytes.Join(loggerSplit, []byte("\n"))
+ lsfJoined := bytes.Join(lsfSplit, []byte("\n"))
+
+ if bytes.Equal(loggerJoined, lsfJoined) {
+ return nil
+ }
+ Diff(string(loggerJoined), string(lsfJoined))
+ return fmt.Errorf("logger does not match lsf! \nlogger: \n%s \nlsf: \n%s", loggerJoined, lsfJoined)
+}
+
+func Diff(rev1, rev2 string) {
+ fmt.Printf("Diff of %q and %q\n", "logger", "lsf")
+ cmd := exec.Command("bash", "-c", fmt.Sprintf(`diff <(echo "%s") <(echo "%s")`, rev1, rev2))
+ out, _ := cmd.Output()
+ _, _ = os.Stdout.Write(out)
+}
+
+func testLoggerVsLsf(ctx context.Context, fdst, fsrc fs.Fs, logger *bytes.Buffer, t *testing.T) {
+ var newlogger bytes.Buffer
+ canTestModtime := fs.GetModifyWindow(ctx, fdst) != fs.ModTimeNotSupported
+ canTestHash := fdst.Hashes().Contains(hash.MD5)
+ if !canTestHash || !canTestModtime {
+ loggerSplit := bytes.Split(logger.Bytes(), []byte("\n"))
+ for i, line := range loggerSplit {
+ elements := bytes.Split(line, []byte(";"))
+ if len(elements) >= 2 {
+ if !canTestModtime {
+ elements[0] = []byte("none")
+ }
+ if !canTestHash {
+ elements[1] = []byte("")
+ }
+ }
+ loggerSplit[i] = bytes.Join(elements, []byte(";"))
+ }
+ newlogger.Write(bytes.Join(loggerSplit, []byte("\n")))
+ } else {
+ newlogger.Write(logger.Bytes())
+ }
+
+ if fsrc.Precision() == fdst.Precision() && fsrc.Hashes().Contains(hash.MD5) && canTestHash {
+ lsf := DstLsf(ctx, fdst)
+ err := LoggerMatchesLsf(&newlogger, lsf)
+ require.NoError(t, err)
+ }
+}
diff --git a/fs/sync/sync_transform_test.go b/fs/sync/sync_transform_test.go
new file mode 100644
index 0000000..930386a
--- /dev/null
+++ b/fs/sync/sync_transform_test.go
@@ -0,0 +1,515 @@
+// Test transform
+
+package sync
+
+import (
+ "cmp"
+ "context"
+ "fmt"
+ "path"
+ "slices"
+ "strings"
+ "testing"
+
+ _ "github.com/rclone/rclone/backend/all"
+ "github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/fs/accounting"
+ "github.com/rclone/rclone/fs/filter"
+ "github.com/rclone/rclone/fs/operations"
+ "github.com/rclone/rclone/fs/walk"
+ "github.com/rclone/rclone/fstest"
+ "github.com/rclone/rclone/lib/transform"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/text/unicode/norm"
+)
+
+var debug = ``
+
+func TestTransform(t *testing.T) {
+ type args struct {
+ TransformOpt []string
+ TransformBackOpt []string
+ Lossless bool // whether the TransformBackAlgo is always losslessly invertible
+ }
+ tests := []struct {
+ name string
+ args args
+ }{
+ {name: "NFC", args: args{
+ TransformOpt: []string{"nfc"},
+ TransformBackOpt: []string{"nfd"},
+ Lossless: false,
+ }},
+ {name: "NFD", args: args{
+ TransformOpt: []string{"nfd"},
+ TransformBackOpt: []string{"nfc"},
+ Lossless: false,
+ }},
+ {name: "base64", args: args{
+ TransformOpt: []string{"base64encode"},
+ TransformBackOpt: []string{"base64encode"},
+ Lossless: false,
+ }},
+ {name: "prefix", args: args{
+ TransformOpt: []string{"prefix=PREFIX"},
+ TransformBackOpt: []string{"trimprefix=PREFIX"},
+ Lossless: true,
+ }},
+ {name: "suffix", args: args{
+ TransformOpt: []string{"suffix=SUFFIX"},
+ TransformBackOpt: []string{"trimsuffix=SUFFIX"},
+ Lossless: true,
+ }},
+ {name: "truncate", args: args{
+ TransformOpt: []string{"truncate=10"},
+ TransformBackOpt: []string{"truncate=10"},
+ Lossless: false,
+ }},
+ {name: "encoder", args: args{
+ TransformOpt: []string{"encoder=Colon,SquareBracket"},
+ TransformBackOpt: []string{"decoder=Colon,SquareBracket"},
+ Lossless: true,
+ }},
+ {name: "ISO-8859-1", args: args{
+ TransformOpt: []string{"ISO-8859-1"},
+ TransformBackOpt: []string{"ISO-8859-1"},
+ Lossless: false,
+ }},
+ {name: "charmap", args: args{
+ TransformOpt: []string{"all,charmap=ISO-8859-7"},
+ TransformBackOpt: []string{"all,charmap=ISO-8859-7"},
+ Lossless: false,
+ }},
+ {name: "lowercase", args: args{
+ TransformOpt: []string{"all,lowercase"},
+ TransformBackOpt: []string{"all,lowercase"},
+ Lossless: false,
+ }},
+ {name: "ascii", args: args{
+ TransformOpt: []string{"all,ascii"},
+ TransformBackOpt: []string{"all,ascii"},
+ Lossless: false,
+ }},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := fstest.NewRun(t)
+ defer r.Finalise()
+
+ ctx := context.Background()
+ r.Mkdir(ctx, r.Flocal)
+ r.Mkdir(ctx, r.Fremote)
+ items := makeTestFiles(t, r, "dir1")
+ deleteDSStore(t, r)
+ r.CheckRemoteListing(t, items, nil)
+ r.CheckLocalListing(t, items, nil)
+
+ err := transform.SetOptions(ctx, tt.args.TransformOpt...)
+ require.NoError(t, err)
+
+ err = Sync(ctx, r.Fremote, r.Flocal, true)
+ assert.NoError(t, err)
+ compareNames(ctx, t, r, items)
+
+ err = transform.SetOptions(ctx, tt.args.TransformBackOpt...)
+ require.NoError(t, err)
+ err = Sync(ctx, r.Fremote, r.Flocal, true)
+ assert.NoError(t, err)
+ compareNames(ctx, t, r, items)
+
+ if tt.args.Lossless {
+ deleteDSStore(t, r)
+ r.CheckRemoteItems(t, items...)
+ }
+ })
+ }
+}
+
+const alphabet = "abcdefg123456789"
+
+var extras = []string{"apple", "banana", "appleappleapplebanana", "splitbananasplit"}
+
+func makeTestFiles(t *testing.T, r *fstest.Run, dir string) []fstest.Item {
+ t.Helper()
+ n := 0
+ // Create test files
+ items := []fstest.Item{}
+ for _, c := range alphabet {
+ var out strings.Builder
+ for i := range rune(7) {
+ out.WriteRune(c + i)
+ }
+ fileName := path.Join(dir, fmt.Sprintf("%04d-%s.txt", n, out.String()))
+ fileName = strings.ToValidUTF8(fileName, "")
+ fileName = strings.NewReplacer(":", "", "<", "", ">", "", "?", "").Replace(fileName) // remove characters illegal on windows
+
+ if debug != "" {
+ fileName = debug
+ }
+
+ item := r.WriteObject(context.Background(), fileName, fileName, t1)
+ r.WriteFile(fileName, fileName, t1)
+ items = append(items, item)
+ n++
+
+ if debug != "" {
+ break
+ }
+ }
+
+ for _, extra := range extras {
+ item := r.WriteObject(context.Background(), extra, extra, t1)
+ r.WriteFile(extra, extra, t1)
+ items = append(items, item)
+ }
+
+ return items
+}
+
+func deleteDSStore(t *testing.T, r *fstest.Run) {
+ ctxDSStore, fi := filter.AddConfig(context.Background())
+ err := fi.AddRule(`+ *.DS_Store`)
+ assert.NoError(t, err)
+ err = fi.AddRule(`- **`)
+ assert.NoError(t, err)
+ err = operations.Delete(ctxDSStore, r.Fremote)
+ assert.NoError(t, err)
+}
+
+func compareNames(ctx context.Context, t *testing.T, r *fstest.Run, items []fstest.Item) {
+ var entries fs.DirEntries
+
+ deleteDSStore(t, r)
+ err := walk.ListR(context.Background(), r.Fremote, "", true, -1, walk.ListObjects, func(e fs.DirEntries) error {
+ entries = append(entries, e...)
+ return nil
+ })
+ assert.NoError(t, err)
+ entries = slices.DeleteFunc(entries, func(E fs.DirEntry) bool { // remove those pesky .DS_Store files
+ if strings.Contains(E.Remote(), ".DS_Store") {
+ err := operations.DeleteFile(context.Background(), E.(fs.Object))
+ assert.NoError(t, err)
+ return true
+ }
+ return false
+ })
+ require.Equal(t, len(items), entries.Len())
+
+ // sort by CONVERTED name
+ slices.SortStableFunc(items, func(a, b fstest.Item) int {
+ aConv := transform.Path(ctx, a.Path, false)
+ bConv := transform.Path(ctx, b.Path, false)
+ return cmp.Compare(aConv, bConv)
+ })
+ slices.SortStableFunc(entries, func(a, b fs.DirEntry) int {
+ return cmp.Compare(a.Remote(), b.Remote())
+ })
+
+ for i, e := range entries {
+ expect := transform.Path(ctx, items[i].Path, false)
+ msg := fmt.Sprintf("expected %v, got %v", detectEncoding(expect), detectEncoding(e.Remote()))
+ assert.Equal(t, expect, e.Remote(), msg)
+ }
+}
+
+func detectEncoding(s string) string {
+ if norm.NFC.IsNormalString(s) && norm.NFD.IsNormalString(s) {
+ return "BOTH"
+ }
+ if !norm.NFC.IsNormalString(s) && norm.NFD.IsNormalString(s) {
+ return "NFD"
+ }
+ if norm.NFC.IsNormalString(s) && !norm.NFD.IsNormalString(s) {
+ return "NFC"
+ }
+ return "OTHER"
+}
+
+func TestTransformCopy(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ err := transform.SetOptions(ctx, "all,suffix_keep_extension=_somesuffix")
+ require.NoError(t, err)
+ file1 := r.WriteFile("sub dir/hello world.txt", "hello world", t1)
+
+ r.Mkdir(ctx, r.Fremote)
+ ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, r.Fremote, r.Flocal, true)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ require.NoError(t, err)
+
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, fstest.NewItem("sub dir_somesuffix/hello world_somesuffix.txt", "hello world", t1))
+}
+
+func TestDoubleTransform(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ err := transform.SetOptions(ctx, "all,prefix=tac", "all,prefix=tic")
+ require.NoError(t, err)
+ file1 := r.WriteFile("toe/toe", "hello world", t1)
+
+ r.Mkdir(ctx, r.Fremote)
+ ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, r.Fremote, r.Flocal, true)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ require.NoError(t, err)
+
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, fstest.NewItem("tictactoe/tictactoe", "hello world", t1))
+}
+
+func TestFileTag(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ err := transform.SetOptions(ctx, "file,prefix=tac", "file,prefix=tic")
+ require.NoError(t, err)
+ file1 := r.WriteFile("toe/toe/toe", "hello world", t1)
+
+ r.Mkdir(ctx, r.Fremote)
+ ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, r.Fremote, r.Flocal, true)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ require.NoError(t, err)
+
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, fstest.NewItem("toe/toe/tictactoe", "hello world", t1))
+}
+
+func TestNoTag(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ err := transform.SetOptions(ctx, "prefix=tac", "prefix=tic")
+ require.NoError(t, err)
+ file1 := r.WriteFile("toe/toe/toe", "hello world", t1)
+
+ r.Mkdir(ctx, r.Fremote)
+ ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, r.Fremote, r.Flocal, true)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ require.NoError(t, err)
+
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, fstest.NewItem("toe/toe/tictactoe", "hello world", t1))
+}
+
+func TestDirTag(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ err := transform.SetOptions(ctx, "dir,prefix=tac", "dir,prefix=tic")
+ require.NoError(t, err)
+ r.WriteFile("toe/toe/toe.txt", "hello world", t1)
+ _, err = operations.MkdirModTime(ctx, r.Flocal, "empty_dir", t1)
+ require.NoError(t, err)
+
+ r.Mkdir(ctx, r.Fremote)
+ ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, r.Fremote, r.Flocal, true)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ require.NoError(t, err)
+
+ r.CheckLocalListing(t, []fstest.Item{fstest.NewItem("toe/toe/toe.txt", "hello world", t1)}, []string{"empty_dir", "toe", "toe/toe"})
+ r.CheckRemoteListing(t, []fstest.Item{fstest.NewItem("tictactoe/tictactoe/toe.txt", "hello world", t1)}, []string{"tictacempty_dir", "tictactoe", "tictactoe/tictactoe"})
+}
+
+func TestAllTag(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ err := transform.SetOptions(ctx, "all,prefix=tac", "all,prefix=tic")
+ require.NoError(t, err)
+ r.WriteFile("toe/toe/toe.txt", "hello world", t1)
+ _, err = operations.MkdirModTime(ctx, r.Flocal, "empty_dir", t1)
+ require.NoError(t, err)
+
+ r.Mkdir(ctx, r.Fremote)
+ ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, r.Fremote, r.Flocal, true)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ require.NoError(t, err)
+
+ r.CheckLocalListing(t, []fstest.Item{fstest.NewItem("toe/toe/toe.txt", "hello world", t1)}, []string{"empty_dir", "toe", "toe/toe"})
+ r.CheckRemoteListing(t, []fstest.Item{fstest.NewItem("tictactoe/tictactoe/tictactoe.txt", "hello world", t1)}, []string{"tictacempty_dir", "tictactoe", "tictactoe/tictactoe"})
+ err = operations.Check(ctx, &operations.CheckOpt{Fsrc: r.Flocal, Fdst: r.Fremote}) // should not error even though dst has transformed names
+ assert.NoError(t, err)
+}
+
+func TestRunTwice(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ err := transform.SetOptions(ctx, "dir,prefix=tac", "dir,prefix=tic")
+ require.NoError(t, err)
+ file1 := r.WriteFile("toe/toe/toe.txt", "hello world", t1)
+
+ r.Mkdir(ctx, r.Fremote)
+ ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, r.Fremote, r.Flocal, true)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ require.NoError(t, err)
+
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, fstest.NewItem("tictactoe/tictactoe/toe.txt", "hello world", t1))
+
+ // result should not change second time, since src is unchanged
+ ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, r.Fremote, r.Flocal, true)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ require.NoError(t, err)
+
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, fstest.NewItem("tictactoe/tictactoe/toe.txt", "hello world", t1))
+}
+
+func TestSyntax(t *testing.T) {
+ ctx := context.Background()
+ err := transform.SetOptions(ctx, "prefix")
+ assert.Error(t, err) // should error as required value is missing
+
+ err = transform.SetOptions(ctx, "banana")
+ assert.Error(t, err) // should error as unrecognized option
+
+ err = transform.SetOptions(ctx, "=123")
+ assert.Error(t, err) // should error as required key is missing
+
+ err = transform.SetOptions(ctx, "prefix=123")
+ assert.NoError(t, err) // should not error
+}
+
+func TestConflicting(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ err := transform.SetOptions(ctx, "prefix=tac", "trimprefix=tac")
+ require.NoError(t, err)
+ file1 := r.WriteFile("toe/toe/toe", "hello world", t1)
+
+ r.Mkdir(ctx, r.Fremote)
+ ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, r.Fremote, r.Flocal, true)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ require.NoError(t, err)
+
+ // should result in no change as prefix and trimprefix cancel out
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, fstest.NewItem("toe/toe/toe", "hello world", t1))
+}
+
+func TestMove(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ err := transform.SetOptions(ctx, "all,prefix=tac", "all,prefix=tic")
+ require.NoError(t, err)
+ r.WriteFile("toe/toe/toe.txt", "hello world", t1)
+ _, err = operations.MkdirModTime(ctx, r.Flocal, "empty_dir", t1)
+ require.NoError(t, err)
+
+ r.Mkdir(ctx, r.Fremote)
+ ctx = predictDstFromLogger(ctx)
+ err = MoveDir(ctx, r.Fremote, r.Flocal, true, true)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ require.NoError(t, err)
+
+ r.CheckLocalListing(t, []fstest.Item{}, []string{})
+ r.CheckRemoteListing(t, []fstest.Item{fstest.NewItem("tictactoe/tictactoe/tictactoe.txt", "hello world", t1)}, []string{"tictacempty_dir", "tictactoe", "tictactoe/tictactoe"})
+}
+
+func TestTransformFile(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ err := transform.SetOptions(ctx, "all,prefix=tac", "all,prefix=tic")
+ require.NoError(t, err)
+ r.WriteFile("toe/toe/toe.txt", "hello world", t1)
+ _, err = operations.MkdirModTime(ctx, r.Flocal, "empty_dir", t1)
+ require.NoError(t, err)
+
+ r.Mkdir(ctx, r.Fremote)
+ ctx = predictDstFromLogger(ctx)
+ err = MoveDir(ctx, r.Fremote, r.Flocal, true, true)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ require.NoError(t, err)
+
+ r.CheckLocalListing(t, []fstest.Item{}, []string{})
+ r.CheckRemoteListing(t, []fstest.Item{fstest.NewItem("tictactoe/tictactoe/tictactoe.txt", "hello world", t1)}, []string{"tictacempty_dir", "tictactoe", "tictactoe/tictactoe"})
+
+ err = transform.SetOptions(ctx, "all,trimprefix=tic", "all,trimprefix=tac")
+ require.NoError(t, err)
+ err = operations.TransformFile(ctx, r.Fremote, "tictactoe/tictactoe/tictactoe.txt")
+ require.NoError(t, err)
+ r.CheckLocalListing(t, []fstest.Item{}, []string{})
+ r.CheckRemoteListing(t, []fstest.Item{fstest.NewItem("toe/toe/toe.txt", "hello world", t1)}, []string{"tictacempty_dir", "tictactoe", "tictactoe/tictactoe", "toe", "toe/toe"})
+}
+
+func TestManualTransformFile(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+
+ r.Flocal.Features().DisableList([]string{"Copy", "Move"})
+ r.Fremote.Features().DisableList([]string{"Copy", "Move"})
+
+ err := transform.SetOptions(ctx, "all,prefix=tac", "all,prefix=tic")
+ require.NoError(t, err)
+ r.WriteFile("toe/toe/toe.txt", "hello world", t1)
+ _, err = operations.MkdirModTime(ctx, r.Flocal, "empty_dir", t1)
+ require.NoError(t, err)
+
+ r.Mkdir(ctx, r.Fremote)
+ ctx = predictDstFromLogger(ctx)
+ err = MoveDir(ctx, r.Fremote, r.Flocal, true, true)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ require.NoError(t, err)
+
+ r.CheckLocalListing(t, []fstest.Item{}, []string{})
+ r.CheckRemoteListing(t, []fstest.Item{fstest.NewItem("tictactoe/tictactoe/tictactoe.txt", "hello world", t1)}, []string{"tictacempty_dir", "tictactoe", "tictactoe/tictactoe"})
+
+ err = transform.SetOptions(ctx, "all,trimprefix=tic", "all,trimprefix=tac")
+ require.NoError(t, err)
+ err = operations.TransformFile(ctx, r.Fremote, "tictactoe/tictactoe/tictactoe.txt")
+ require.NoError(t, err)
+ r.CheckLocalListing(t, []fstest.Item{}, []string{})
+ r.CheckRemoteListing(t, []fstest.Item{fstest.NewItem("toe/toe/toe.txt", "hello world", t1)}, []string{"tictacempty_dir", "tictactoe", "tictactoe/tictactoe", "toe", "toe/toe"})
+}
+
+func TestBase64(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ err := transform.SetOptions(ctx, "all,base64encode")
+ require.NoError(t, err)
+ file1 := r.WriteFile("toe/toe/toe.txt", "hello world", t1)
+
+ r.Mkdir(ctx, r.Fremote)
+ ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, r.Fremote, r.Flocal, true)
+ testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ require.NoError(t, err)
+
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, fstest.NewItem("dG9l/dG9l/dG9lLnR4dA==", "hello world", t1))
+
+ // round trip
+ err = transform.SetOptions(ctx, "all,base64decode")
+ require.NoError(t, err)
+ ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, r.Flocal, r.Fremote, true)
+ testLoggerVsLsf(ctx, r.Flocal, r.Fremote, operations.GetLoggerOpt(ctx).JSON, t)
+ require.NoError(t, err)
+
+ r.CheckLocalItems(t, file1)
+ r.CheckRemoteItems(t, fstest.NewItem("dG9l/dG9l/dG9lLnR4dA==", "hello world", t1))
+}
+
+func TestError(t *testing.T) {
+ ctx := context.Background()
+ r := fstest.NewRun(t)
+ err := transform.SetOptions(ctx, "all,prefix=ta/c") // has illegal character
+ require.NoError(t, err)
+ file1 := r.WriteFile("toe/toe/toe", "hello world", t1)
+
+ r.Mkdir(ctx, r.Fremote)
+ // ctx = predictDstFromLogger(ctx)
+ err = Sync(ctx, r.Fremote, r.Flocal, true)
+ // testLoggerVsLsf(ctx, r.Fremote, r.Flocal, operations.GetLoggerOpt(ctx).JSON, t)
+ assert.Error(t, err)
+ accounting.GlobalStats().ResetCounters()
+
+ r.CheckLocalListing(t, []fstest.Item{file1}, []string{"toe", "toe/toe"})
+ r.CheckRemoteListing(t, []fstest.Item{file1}, []string{"toe", "toe/toe"})
+}
diff --git a/magefile.go b/magefile.go
index be97194..caca199 100644
--- a/magefile.go
+++ b/magefile.go
@@ -116,6 +116,21 @@ func TestAgainstContainer() error {
if err != nil {
return err
}
+ err = runCommandWithEnv(
+ []string{"RCLONE_CONFIG=" + config.ConfigPath},
+ "go", "-C", "fs/sync", "test", "-parallel=1", "-remote", "TestStudIP:fs/sync", "-v", "-count=1",
+ )
+ if err != nil {
+ return err
+ }
+
+ err = runCommandWithEnv(
+ []string{"RCLONE_CONFIG=" + config.ConfigPath},
+ "go", "-C", "fs/sync", "test", "-parallel=1", "-remote", "TestStudIP:fs/operations", "-v", "-count=1",
+ )
+ if err != nil {
+ return err
+ }
return nil
}