package bisync import ( "context" "fmt" "math" "mime" "path" "strings" "time" "github.com/rclone/rclone/cmd/bisync/bilib" "github.com/rclone/rclone/fs" "github.com/rclone/rclone/fs/operations" "github.com/rclone/rclone/lib/terminal" ) // Prefer describes strategies for resolving sync conflicts type Prefer = fs.Enum[preferChoices] // Supported --conflict-resolve strategies const ( PreferNone Prefer = iota PreferPath1 PreferPath2 PreferNewer PreferOlder PreferLarger PreferSmaller ) type preferChoices struct{} func (preferChoices) Choices() []string { return []string{ PreferNone: "none", PreferNewer: "newer", PreferOlder: "older", PreferLarger: "larger", PreferSmaller: "smaller", PreferPath1: "path1", PreferPath2: "path2", } } func (preferChoices) Type() string { return "string" } // ConflictResolveList is a list of --conflict-resolve flag choices used in the help var ConflictResolveList = Opt.ConflictResolve.Help() // ConflictLoserAction describes possible actions to take on the loser of a sync conflict type ConflictLoserAction = fs.Enum[conflictLoserChoices] // Supported --conflict-loser actions const ( ConflictLoserSkip ConflictLoserAction = iota // Reserved as zero but currently unused ConflictLoserNumber // file.conflict1, file.conflict2, file.conflict3, etc. ConflictLoserPathname // file.path1, file.path2 ConflictLoserDelete // delete the loser, keep winner only ) type conflictLoserChoices struct{} func (conflictLoserChoices) Choices() []string { return []string{ ConflictLoserNumber: "num", ConflictLoserPathname: "pathname", ConflictLoserDelete: "delete", } } func (conflictLoserChoices) Type() string { return "ConflictLoserAction" } // ConflictLoserList is a list of --conflict-loser flag choices used in the help var ConflictLoserList = Opt.ConflictLoser.Help() func (b *bisyncRun) setResolveDefaults(ctx context.Context) error { if b.opt.ConflictLoser == ConflictLoserSkip { b.opt.ConflictLoser = ConflictLoserNumber } if b.opt.ConflictSuffixFlag == "" { b.opt.ConflictSuffixFlag = "conflict" } suffixes := strings.Split(b.opt.ConflictSuffixFlag, ",") if len(suffixes) == 1 { b.opt.ConflictSuffix1 = suffixes[0] b.opt.ConflictSuffix2 = suffixes[0] } else if len(suffixes) == 2 { b.opt.ConflictSuffix1 = suffixes[0] b.opt.ConflictSuffix2 = suffixes[1] } else { return fmt.Errorf("--conflict-suffix cannot have more than 2 comma-separated values. Received %v: %v", len(suffixes), suffixes) } // replace glob variables, if any t := time.Now() // capture static time here so it is the same for all files throughout this run b.opt.ConflictSuffix1 = bilib.AppyTimeGlobs(b.opt.ConflictSuffix1, t) b.opt.ConflictSuffix2 = bilib.AppyTimeGlobs(b.opt.ConflictSuffix2, t) // append dot (intentionally allow more than one) b.opt.ConflictSuffix1 = "." + b.opt.ConflictSuffix1 b.opt.ConflictSuffix2 = "." + b.opt.ConflictSuffix2 // checks and warnings if (b.opt.ConflictResolve == PreferNewer || b.opt.ConflictResolve == PreferOlder) && (b.fs1.Precision() == fs.ModTimeNotSupported || b.fs2.Precision() == fs.ModTimeNotSupported) { fs.Logf(nil, Color(terminal.YellowFg, "WARNING: ignoring --conflict-resolve %s as at least one remote does not support modtimes."), b.opt.ConflictResolve.String()) b.opt.ConflictResolve = PreferNone } else if (b.opt.ConflictResolve == PreferNewer || b.opt.ConflictResolve == PreferOlder) && !b.opt.Compare.Modtime { fs.Logf(nil, Color(terminal.YellowFg, "WARNING: ignoring --conflict-resolve %s as --compare does not include modtime."), b.opt.ConflictResolve.String()) b.opt.ConflictResolve = PreferNone } if (b.opt.ConflictResolve == PreferLarger || b.opt.ConflictResolve == PreferSmaller) && !b.opt.Compare.Size { fs.Logf(nil, Color(terminal.YellowFg, "WARNING: ignoring --conflict-resolve %s as --compare does not include size."), b.opt.ConflictResolve.String()) b.opt.ConflictResolve = PreferNone } return nil } type ( renames map[string]renamesInfo // [originalName]newName (remember the originalName may have an alias) // the newName may be the same as the old name (if winner), but should not be blank, unless we're deleting. // the oldNames may not match each other, if we're normalizing case or unicode // all names should be "remotes" (relative names, without base path) renamesInfo struct { path1 namePair path2 namePair } ) type namePair struct { oldName string newName string } func (b *bisyncRun) resolve(ctxMove context.Context, path1, path2, file, alias string, renameSkipped, copy1to2, copy2to1 *bilib.Names, ds1, ds2 *deltaSet) error { winningPath := 0 if b.opt.ConflictResolve != PreferNone { winningPath = b.conflictWinner(ds1, ds2, file, alias) if winningPath > 0 { fs.Infof(file, Color(terminal.GreenFg, "The winner is: Path%d"), winningPath) } else { fs.Infof(file, Color(terminal.RedFg, "A winner could not be determined.")) //nolint:govet } } suff1 := b.opt.ConflictSuffix1 // copy to new var to make sure our changes here don't persist suff2 := b.opt.ConflictSuffix2 if b.opt.ConflictLoser == ConflictLoserPathname && b.opt.ConflictSuffix1 == b.opt.ConflictSuffix2 { // numerate, but not if user supplied two different suffixes suff1 += "1" suff2 += "2" } r := renamesInfo{ path1: namePair{ oldName: file, newName: SuffixName(ctxMove, file, suff1), }, path2: namePair{ oldName: alias, newName: SuffixName(ctxMove, alias, suff2), }, } // handle auto-numbering // note that we still queue copies for both files, whether or not we renamed // we also set these for ConflictLoserDelete in case there is no winner. if b.opt.ConflictLoser == ConflictLoserNumber || b.opt.ConflictLoser == ConflictLoserDelete { num := b.numerate(ctxMove, 1, file, alias) switch winningPath { case 1: // keep path1, rename path2 r.path1.newName = r.path1.oldName r.path2.newName = SuffixName(ctxMove, r.path2.oldName, b.opt.ConflictSuffix2+fmt.Sprint(num)) case 2: // keep path2, rename path1 r.path1.newName = SuffixName(ctxMove, r.path1.oldName, b.opt.ConflictSuffix1+fmt.Sprint(num)) r.path2.newName = r.path2.oldName default: // no winner, so rename both to different numbers (unless suffixes are already different) if b.opt.ConflictSuffix1 == b.opt.ConflictSuffix2 { r.path1.newName = SuffixName(ctxMove, r.path1.oldName, b.opt.ConflictSuffix1+fmt.Sprint(num)) // let's just make sure num + 1 is available... num2 := b.numerate(ctxMove, num+1, file, alias) r.path2.newName = SuffixName(ctxMove, r.path2.oldName, b.opt.ConflictSuffix2+fmt.Sprint(num2)) } else { // suffixes are different, so numerate independently num = b.numerateSingle(ctxMove, 1, file, alias, 1) r.path1.newName = SuffixName(ctxMove, r.path1.oldName, b.opt.ConflictSuffix1+fmt.Sprint(num)) num = b.numerateSingle(ctxMove, 1, file, alias, 2) r.path2.newName = SuffixName(ctxMove, r.path2.oldName, b.opt.ConflictSuffix2+fmt.Sprint(num)) } } } // when winningPath == 0 (no winner), we ignore settings and rename both, do not delete // note also that deletes and renames are mutually exclusive -- we never delete one path and rename the other. if b.opt.ConflictLoser == ConflictLoserDelete && winningPath == 1 { // delete 2, copy 1 to 2 err = b.delete(ctxMove, r.path2, path2, path1, b.fs2, 2, 1, renameSkipped) if err != nil { return err } r.path2.newName = "" // copy the one that wasn't deleted b.indent("Path1", r.path1.oldName, "Queue copy to Path2") copy1to2.Add(r.path1.oldName) } else if b.opt.ConflictLoser == ConflictLoserDelete && winningPath == 2 { // delete 1, copy 2 to 1 err = b.delete(ctxMove, r.path1, path1, path2, b.fs1, 1, 2, renameSkipped) if err != nil { return err } r.path1.newName = "" // copy the one that wasn't deleted b.indent("Path2", r.path2.oldName, "Queue copy to Path1") copy2to1.Add(r.path2.oldName) } else { err = b.rename(ctxMove, r.path1, path1, path2, b.fs1, 1, 2, winningPath, copy1to2, renameSkipped) if err != nil { return err } err = b.rename(ctxMove, r.path2, path2, path1, b.fs2, 2, 1, winningPath, copy2to1, renameSkipped) if err != nil { return err } } b.renames[r.path1.oldName] = r // note map index is path1's oldName, which may be different from path2 if aliases return nil } // SuffixName adds the current --conflict-suffix to the remote, obeying // --suffix-keep-extension if set // It is a close cousin of operations.SuffixName, but we don't want to // use ci.Suffix for this because it might be used for --backup-dir. func SuffixName(ctx context.Context, remote, suffix string) string { if suffix == "" { return remote } ci := fs.GetConfig(ctx) if ci.SuffixKeepExtension { var ( base = remote exts = "" first = true ext = path.Ext(remote) ) for ext != "" { // Look second and subsequent extensions in mime types. // If they aren't found then don't keep it as an extension. if !first && mime.TypeByExtension(ext) == "" { break } base = base[:len(base)-len(ext)] exts = ext + exts first = false ext = path.Ext(base) } return base + suffix + exts } return remote + suffix } // NotEmpty checks whether set is not empty func (r renames) NotEmpty() bool { return len(r) > 0 } func (ri *renamesInfo) getNames(is1to2 bool) (srcOldName, srcNewName, dstOldName, dstNewName string) { if is1to2 { return ri.path1.oldName, ri.path1.newName, ri.path2.oldName, ri.path2.newName } return ri.path2.oldName, ri.path2.newName, ri.path1.oldName, ri.path1.newName } // work out the lowest number that neither side has, return it for suffix func (b *bisyncRun) numerate(ctx context.Context, startnum int, file, alias string) int { for i := startnum; i < math.MaxInt; i++ { iStr := fmt.Sprint(i) if !ls1.has(SuffixName(ctx, file, b.opt.ConflictSuffix1+iStr)) && !ls1.has(SuffixName(ctx, alias, b.opt.ConflictSuffix1+iStr)) && !ls2.has(SuffixName(ctx, file, b.opt.ConflictSuffix2+iStr)) && !ls2.has(SuffixName(ctx, alias, b.opt.ConflictSuffix2+iStr)) { // make sure it still holds true with suffixes switched (it should) if !ls1.has(SuffixName(ctx, file, b.opt.ConflictSuffix2+iStr)) && !ls1.has(SuffixName(ctx, alias, b.opt.ConflictSuffix2+iStr)) && !ls2.has(SuffixName(ctx, file, b.opt.ConflictSuffix1+iStr)) && !ls2.has(SuffixName(ctx, alias, b.opt.ConflictSuffix1+iStr)) { fs.Debugf(file, "The first available suffix is: %s", iStr) return i } } } return 0 // not really possible, as no one has 9223372036854775807 conflicts, and if they do, they have bigger problems } // like numerate, but consider only one side's suffix (for when suffixes are different) func (b *bisyncRun) numerateSingle(ctx context.Context, startnum int, file, alias string, path int) int { lsA, lsB := ls1, ls2 suffix := b.opt.ConflictSuffix1 if path == 2 { lsA, lsB = ls2, ls1 suffix = b.opt.ConflictSuffix2 } for i := startnum; i < math.MaxInt; i++ { iStr := fmt.Sprint(i) if !lsA.has(SuffixName(ctx, file, suffix+iStr)) && !lsA.has(SuffixName(ctx, alias, suffix+iStr)) && !lsB.has(SuffixName(ctx, file, suffix+iStr)) && !lsB.has(SuffixName(ctx, alias, suffix+iStr)) { fs.Debugf(file, "The first available suffix is: %s", iStr) return i } } return 0 // not really possible, as no one has 9223372036854775807 conflicts, and if they do, they have bigger problems } func (b *bisyncRun) rename(ctx context.Context, thisNamePair namePair, thisPath, thatPath string, thisFs fs.Fs, thisPathNum, thatPathNum, winningPath int, q, renameSkipped *bilib.Names) error { if winningPath == thisPathNum { b.indent(fmt.Sprintf("!Path%d", thisPathNum), thisPath+thisNamePair.newName, fmt.Sprintf("Not renaming Path%d copy, as it was determined the winner", thisPathNum)) } else { skip := operations.SkipDestructive(ctx, thisNamePair.oldName, "rename") if !skip { b.indent(fmt.Sprintf("!Path%d", thisPathNum), thisPath+thisNamePair.newName, fmt.Sprintf("Renaming Path%d copy", thisPathNum)) ctx = b.setBackupDir(ctx, thisPathNum) // in case already a file with new name if err = operations.MoveFile(ctx, thisFs, thisFs, thisNamePair.newName, thisNamePair.oldName); err != nil { err = fmt.Errorf("%s rename failed for %s: %w", thisPath, thisPath+thisNamePair.oldName, err) b.critical = true return err } } else { renameSkipped.Add(thisNamePair.oldName) // (due to dry-run, not equality) } } b.indent(fmt.Sprintf("!Path%d", thisPathNum), thatPath+thisNamePair.newName, fmt.Sprintf("Queue copy to Path%d", thatPathNum)) q.Add(thisNamePair.newName) return nil } func (b *bisyncRun) delete(ctx context.Context, thisNamePair namePair, thisPath, thatPath string, thisFs fs.Fs, thisPathNum, thatPathNum int, renameSkipped *bilib.Names) error { skip := operations.SkipDestructive(ctx, thisNamePair.oldName, "delete") if !skip { b.indent(fmt.Sprintf("!Path%d", thisPathNum), thisPath+thisNamePair.oldName, fmt.Sprintf("Deleting Path%d copy", thisPathNum)) ctx = b.setBackupDir(ctx, thisPathNum) ci := fs.GetConfig(ctx) var backupDir fs.Fs if ci.BackupDir != "" { backupDir, err = operations.BackupDir(ctx, thisFs, thisFs, thisNamePair.oldName) if err != nil { b.critical = true return err } } obj, err := thisFs.NewObject(ctx, thisNamePair.oldName) if err != nil { b.critical = true return err } if err = operations.DeleteFileWithBackupDir(ctx, obj, backupDir); err != nil { err = fmt.Errorf("%s delete failed for %s: %w", thisPath, thisPath+thisNamePair.oldName, err) b.critical = true return err } } else { renameSkipped.Add(thisNamePair.oldName) // (due to dry-run, not equality) } return nil } func (b *bisyncRun) conflictWinner(ds1, ds2 *deltaSet, remote1, remote2 string) int { switch b.opt.ConflictResolve { case PreferPath1: return 1 case PreferPath2: return 2 case PreferNewer, PreferOlder: t1, t2 := ds1.time[remote1], ds2.time[remote2] return b.resolveNewerOlder(t1, t2, remote1, remote2, b.opt.ConflictResolve) case PreferLarger, PreferSmaller: s1, s2 := ds1.size[remote1], ds2.size[remote2] return b.resolveLargerSmaller(s1, s2, remote1, remote2, b.opt.ConflictResolve) default: return 0 } } // returns the winning path number, or 0 if winner can't be determined func (b *bisyncRun) resolveNewerOlder(t1, t2 time.Time, remote1, remote2 string, prefer Prefer) int { if fs.GetModifyWindow(b.octx, b.fs1, b.fs2) == fs.ModTimeNotSupported { fs.Infof(remote1, "Winner cannot be determined as at least one path lacks modtime support.") return 0 } if t1.IsZero() || t2.IsZero() { fs.Infof(remote1, "Winner cannot be determined as at least one modtime is missing. Path1: %v, Path2: %v", t1, t2) return 0 } if t1.After(t2) { if prefer == PreferNewer { fs.Infof(remote1, "Path1 is newer. Path1: %v, Path2: %v, Difference: %s", t1.Local(), t2.Local(), t1.Sub(t2)) return 1 } else if prefer == PreferOlder { fs.Infof(remote1, "Path2 is older. Path1: %v, Path2: %v, Difference: %s", t1.Local(), t2.Local(), t1.Sub(t2)) return 2 } } else if t1.Before(t2) { if prefer == PreferNewer { fs.Infof(remote1, "Path2 is newer. Path1: %v, Path2: %v, Difference: %s", t1.Local(), t2.Local(), t2.Sub(t1)) return 2 } else if prefer == PreferOlder { fs.Infof(remote1, "Path1 is older. Path1: %v, Path2: %v, Difference: %s", t1.Local(), t2.Local(), t2.Sub(t1)) return 1 } } if t1.Equal(t2) { fs.Infof(remote1, "Winner cannot be determined as times are equal. Path1: %v, Path2: %v, Difference: %s", t1.Local(), t2.Local(), t2.Sub(t1)) return 0 } fs.Errorf(remote1, "Winner cannot be determined. Path1: %v, Path2: %v", t1.Local(), t2.Local()) // shouldn't happen unless prefer is of wrong type return 0 } // returns the winning path number, or 0 if winner can't be determined func (b *bisyncRun) resolveLargerSmaller(s1, s2 int64, remote1, remote2 string, prefer Prefer) int { if s1 < 0 || s2 < 0 { fs.Infof(remote1, "Winner cannot be determined as at least one size is unknown. Path1: %v, Path2: %v", s1, s2) return 0 } if s1 > s2 { if prefer == PreferLarger { fs.Infof(remote1, "Path1 is larger. Path1: %v, Path2: %v, Difference: %v", s1, s2, s1-s2) return 1 } else if prefer == PreferSmaller { fs.Infof(remote1, "Path2 is smaller. Path1: %v, Path2: %v, Difference: %v", s1, s2, s1-s2) return 2 } } else if s1 < s2 { if prefer == PreferLarger { fs.Infof(remote1, "Path2 is larger. Path1: %v, Path2: %v, Difference: %v", s1, s2, s2-s1) return 2 } else if prefer == PreferSmaller { fs.Infof(remote1, "Path1 is smaller. Path1: %v, Path2: %v, Difference: %v", s1, s2, s2-s1) return 1 } } if s1 == s2 { fs.Infof(remote1, "Winner cannot be determined as sizes are equal. Path1: %v, Path2: %v, Difference: %v", s1, s2, s1-s2) return 0 } fs.Errorf(remote1, "Winner cannot be determined. Path1: %v, Path2: %v", s1, s2) // shouldn't happen unless prefer is of wrong type return 0 }