Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions internal/backup/backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions internal/backup/backup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
Expand Down Expand Up @@ -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")
}
}
44 changes: 36 additions & 8 deletions internal/backup/restore.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand All @@ -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+$`)
Expand All @@ -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})
Expand Down
16 changes: 16 additions & 0 deletions internal/logx/logx_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ package logx

import (
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/ApolloF/syncer/internal/paths"
)

func TestRunRestartsAfterPanic(t *testing.T) {
Expand Down Expand Up @@ -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")
}
}