From 12f35afca8ec66cd4cdfdf6375686027c22b59c4 Mon Sep 17 00:00:00 2001 From: ApolloF Date: Wed, 21 Oct 2026 12:38:37 +0200 Subject: [PATCH] Restore versions left with the .syncer-tmp name --- internal/backup/backup.go | 8 +++-- internal/backup/backup_test.go | 53 ++++++++++++++++++++++++++++++++++ internal/backup/restore.go | 44 +++++++++++++++++++++++----- internal/logx/logx_test.go | 16 ++++++++++ 4 files changed, 111 insertions(+), 10 deletions(-) diff --git a/internal/backup/backup.go b/internal/backup/backup.go index 9eab009..5166629 100644 --- a/internal/backup/backup.go +++ b/internal/backup/backup.go @@ -666,10 +666,14 @@ func copyHistory(ctx context.Context, target, from, to string, mirror bool) erro } return err } - if !e.Type().IsRegular() || strings.HasSuffix(p, tmpSuffix) { + if !e.Type().IsRegular() { return nil } - rel, _ := filepath.Rel(src, p) + name, ok := copyName(p, strings.HasPrefix(d[0], VersionsDir)) + if !ok { + return nil + } + rel, _ := filepath.Rel(src, name) out := filepath.Join(dst, rel) if _, err := os.Lstat(out); err == nil { return nil diff --git a/internal/backup/backup_test.go b/internal/backup/backup_test.go index 8859c96..cc9ae82 100644 --- a/internal/backup/backup_test.go +++ b/internal/backup/backup_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "time" ) @@ -495,3 +496,55 @@ func TestKeepNeverOverwrites(t *testing.T) { t.Errorf("kept %v, want all three", got) } } + +// Google Drive can leave a copy in a restore point under the temporary name +// it was written with. It is still the version, unless the real name is there. +func TestLeftoverTempVersionsCanBeRestored(t *testing.T) { + src, target := t.TempDir(), t.TempDir() + id := "leftover-" + time.Now().Format("150405.000000") + defer os.Remove(indexPath(id)) + f := Folder{ID: id, Label: "L", Path: src} + stamp := "2026-10-20_193123" + pt, _ := time.ParseInLocation(stampFmt, stamp, time.Local) + ver := filepath.Join(target, VersionsDir, id, stamp) + write(t, filepath.Join(target, id, "now.json"), "latest") + write(t, filepath.Join(ver, "sub", "a.json"+tmpSuffix), "stuck") + write(t, filepath.Join(ver, "sub", "b.json"), "real") + write(t, filepath.Join(ver, "sub", "b.json"+tmpSuffix), "partial") + write(t, filepath.Join(target, id, "live.json"+tmpSuffix), "being written") + + cs := Copies(target, id) + a := cs[strings.ToLower(filepath.Join("sub", "a.json"))] + if len(a) != 1 || a[0].Point.IsZero() || a[0].Rel != filepath.Join("sub", "a.json") { + t.Fatalf("stuck version not listed under its real name: %+v", a) + } + if b := cs[strings.ToLower(filepath.Join("sub", "b.json"))]; len(b) != 1 || read(t, b[0].Path) != "real" { + t.Errorf("temp file next to the real one counted: %+v", b) + } + if _, ok := cs["live.json"]; ok { + t.Error("temp file in the live backup counted as data") + } + + n, err := Restore(target, f, pt) + if err != nil || n != 3 { + t.Fatalf("restore: n=%d err=%v", n, err) + } + if read(t, filepath.Join(src, "sub", "a.json")) != "stuck" || read(t, filepath.Join(src, "sub", "b.json")) != "real" { + t.Error("versions not restored") + } + if _, err := os.Stat(filepath.Join(src, "live.json")); err == nil { + t.Error("in-progress temp file restored") + } +} + +func TestCopyVersionsKeepsLeftoverTempVersions(t *testing.T) { + target := t.TempDir() + stamp := "2026-10-20_193123" + write(t, filepath.Join(target, VersionsDir, "old", stamp, "a.json"+tmpSuffix), "stuck") + if err := CopyVersions(context.Background(), target, "old", "new"); err != nil { + t.Fatal(err) + } + if read(t, filepath.Join(target, VersionsDir, "new", stamp, "a.json")) != "stuck" { + t.Error("stuck version not copied under its real name") + } +} diff --git a/internal/backup/restore.go b/internal/backup/restore.go index 65795fa..ff11714 100644 --- a/internal/backup/restore.go +++ b/internal/backup/restore.go @@ -97,27 +97,30 @@ func Restore(target string, f Folder, point time.Time) (int, error) { // point at T itself counts. func sources(target, id string, point time.Time) (src, rels map[string]string) { src, rels = map[string]string{}, map[string]string{} - add := func(root string) { + add := func(root string, version bool) { _ = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { // steam_autocloud.vdf names the account of the PC it was backed // up on; this PC's Steam writes its own. A second copy of a file // in one point (see moveTo) isn't a file of the game. - if err != nil || d.IsDir() || strings.HasSuffix(p, tmpSuffix) || strings.EqualFold(d.Name(), SteamMarker) || - keptSuffix.MatchString(d.Name()) { + if err != nil || d.IsDir() || strings.EqualFold(d.Name(), SteamMarker) || keptSuffix.MatchString(d.Name()) { return nil } - rel, _ := filepath.Rel(root, p) + name, ok := copyName(p, version) + if !ok { + return nil + } + rel, _ := filepath.Rel(root, name) k := strings.ToLower(rel) src[k], rels[k] = p, rel return nil }) } - add(filepath.Join(target, id)) + add(filepath.Join(target, id), false) if !point.IsZero() { aside := marks(filepath.Join(target, VersionsDir, id), AsideDir) for _, t := range Points(target, id) { // newest first if !t.Before(point) && (t.Equal(point) || !aside[t.Format(stampFmt)]) { - add(filepath.Join(target, VersionsDir, id, t.Format(stampFmt))) + add(filepath.Join(target, VersionsDir, id, t.Format(stampFmt)), true) } } } @@ -133,6 +136,27 @@ type Copy struct { Point time.Time // the restore point that keeps it; zero for the latest backup } +// copyName is the name the file at p goes by in a backup: p itself, or, for +// a file in a restore point (version) left with the temporary name its copy +// was made under, the name without it. That happens when a copy into a +// restore point is cut off or Google Drive keeps the temporary name after the +// rename; the file is the version all the same. ok is false for what isn't a +// copy: a temporary file in the live backup (still being written), or in a +// restore point next to the file it became. +func copyName(p string, version bool) (name string, ok bool) { + name, tmp := strings.CutSuffix(p, tmpSuffix) + if !tmp { + return p, true + } + if !version { + return "", false + } + if _, err := os.Lstat(name); err == nil { + return "", false + } + return name, true +} + // keptSuffix ends the name of a second copy of a file in one restore point // (see moveTo). var keptSuffix = regexp.MustCompile(`\.syncer-kept-\d+$`) @@ -146,14 +170,18 @@ func Copies(target, id string) map[string][]Copy { } add := func(root string, point time.Time) { _ = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { - if err != nil || d.IsDir() || strings.HasSuffix(p, tmpSuffix) || strings.EqualFold(d.Name(), SteamMarker) { + if err != nil || d.IsDir() || strings.EqualFold(d.Name(), SteamMarker) { + return nil + } + name, ok := copyName(p, !point.IsZero()) + if !ok { return nil } fi, err := d.Info() if err != nil { return nil } - rel, _ := filepath.Rel(root, p) + rel, _ := filepath.Rel(root, name) rel = keptSuffix.ReplaceAllString(rel, "") k := strings.ToLower(rel) out[k] = append(out[k], Copy{Rel: rel, Path: p, Modified: fi.ModTime(), Point: point}) diff --git a/internal/logx/logx_test.go b/internal/logx/logx_test.go index 41cca8c..5d49f1e 100644 --- a/internal/logx/logx_test.go +++ b/internal/logx/logx_test.go @@ -2,9 +2,12 @@ package logx import ( "os" + "path/filepath" "strings" "testing" "time" + + "github.com/ApolloF/syncer/internal/paths" ) func TestRunRestartsAfterPanic(t *testing.T) { @@ -46,3 +49,16 @@ func TestRecoverLogsPanic(t *testing.T) { t.Error("the panic was not logged") } } + +func TestTestsNeverWriteToTheRealLog(t *testing.T) { + real := filepath.Join(paths.AppDir(), "syncer.log") + before, beforeErr := os.Stat(real) + if Path() == real || CrashPath() == filepath.Join(paths.AppDir(), "crash.log") { + t.Fatalf("tests log to the real files: %s, %s", Path(), CrashPath()) + } + Printf("logx test line") + after, afterErr := os.Stat(real) + if (beforeErr == nil) != (afterErr == nil) || (beforeErr == nil && (after.Size() != before.Size() || !after.ModTime().Equal(before.ModTime()))) { + t.Error("the real syncer.log changed") + } +}