Files
rclone/cmd/bisync/delta_list.go
T
f-denkena d44d85dc05 bisync: add Dropbox list_folder cursor listings via --delta-list
Skip the per-directory Dropbox walk on successive bisync runs by applying
list_folder/continue deltas to the stored listing. Cursor reset, --max-delete,
and atomic listing+cursor writes abort without a half-applied snapshot. The
local side still walks; there is no NTFS USN dependency.
2026-09-09 23:36:25 +02:00

153 lines
3.6 KiB
Go

package bisync
import (
"bufio"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// errDeltaTooManyDeletes is returned when applying a delta would exceed --max-delete.
var errDeltaTooManyDeletes = errors.New("delta list: too many deletes")
// errDeltaCursorReset is returned when the remote invalidated the listing cursor.
// The in-memory listing must be left unchanged so the caller can full-relist.
var errDeltaCursorReset = errors.New("delta list: cursor reset")
type listingDelta struct {
Remote string
Deleted bool
Size int64
ModTime time.Time
Hash string
ID string
Flags string
}
type deltaApplyOpts struct {
MaxDelete int
Force bool
Reset bool
}
func cursorPath(listing string) string {
return listing + ".cursor"
}
// applyListingDeltas mutates ls in place. On error, ls is restored to its
// previous contents (strong safety: never persist a half-applied delta).
func applyListingDeltas(ls *fileList, deltas []listingDelta, opt deltaApplyOpts) error {
if opt.Reset {
return errDeltaCursorReset
}
backupList := append([]string(nil), ls.list...)
backupInfo := make(map[string]*fileInfo, len(ls.info))
for k, v := range ls.info {
cp := *v
backupInfo[k] = &cp
}
restore := func() {
ls.list = backupList
ls.info = backupInfo
}
oldCount := 0
for _, name := range ls.list {
if !ls.isDir(name) {
oldCount++
}
}
deletedFiles := 0
for _, d := range deltas {
if d.Deleted {
// Dropbox DeletedMetadata: remove the path and all children.
prefix := d.Remote
if prefix != "" && !strings.HasSuffix(prefix, "/") {
prefix += "/"
}
var toRemove []string
for _, name := range ls.list {
if name == d.Remote || (prefix != "" && strings.HasPrefix(name, prefix)) {
toRemove = append(toRemove, name)
}
}
for _, name := range toRemove {
if !ls.isDir(name) {
deletedFiles++
}
ls.remove(name)
}
continue
}
flags := d.Flags
if flags == "" {
flags = "-"
}
ls.put(d.Remote, d.Size, d.ModTime, d.Hash, d.ID, flags)
}
if !opt.Force && opt.MaxDelete >= 0 && oldCount > 0 {
maxRatio := float64(opt.MaxDelete) / 100.0
if float64(deletedFiles)/float64(oldCount) > maxRatio {
restore()
return errDeltaTooManyDeletes
}
}
return nil
}
func saveListingAndCursor(listing, cursor, root string, ls *fileList) error {
if err := os.MkdirAll(filepath.Dir(listing), 0700); err != nil {
return err
}
tmpListing := listing + ".tmp"
tmpCursor := cursorPath(listing) + ".tmp"
if err := ls.save(tmpListing); err != nil {
return err
}
cur := fmt.Sprintf("# rclone-bisync-cursor v1\nroot=%s\ncursor=%s\n", root, cursor)
if err := os.WriteFile(tmpCursor, []byte(cur), 0600); err != nil {
_ = os.Remove(tmpListing)
return err
}
if err := os.Rename(tmpListing, listing); err != nil {
_ = os.Remove(tmpListing)
_ = os.Remove(tmpCursor)
return err
}
if err := os.Rename(tmpCursor, cursorPath(listing)); err != nil {
return fmt.Errorf("listing saved but cursor rename failed (next run will full-list): %w", err)
}
return nil
}
func loadCursor(listing string) (cursor, root string, err error) {
b, err := os.ReadFile(cursorPath(listing))
if err != nil {
if os.IsNotExist(err) {
return "", "", nil
}
return "", "", err
}
sc := bufio.NewScanner(strings.NewReader(string(b)))
for sc.Scan() {
line := sc.Text()
if strings.HasPrefix(line, "root=") {
root = strings.TrimPrefix(line, "root=")
}
if strings.HasPrefix(line, "cursor=") {
cursor = strings.TrimPrefix(line, "cursor=")
}
}
return cursor, root, sc.Err()
}
func loadListingFile(listing string) (*fileList, error) {
b := &bisyncRun{}
return b.loadListing(listing)
}