From 09716b3f3bdc5dbf7e4133ee6ce8652dac0245c7 Mon Sep 17 00:00:00 2001 From: ApolloF <98666317+ApolloF@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:29:48 +0200 Subject: [PATCH 1/2] Protect re-added backups from mass deletion in Drive --- internal/gdrive/sync.go | 9 ++++++ internal/gdrive/sync_test.go | 54 ++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/internal/gdrive/sync.go b/internal/gdrive/sync.go index d12d933..5158841 100644 --- a/internal/gdrive/sync.go +++ b/internal/gdrive/sync.go @@ -191,6 +191,15 @@ func syncWith(ctx context.Context, d drive, local string, st *State) (Report, er if err != nil { return rep, err } + // Local files under a forgotten folder mean it was re-added. A failed + // Drive delete must not exempt its new backup from the mass-loss guard. + readded := map[string]bool{} + for k := range here { + if id := st.forgotten(k); id != "" { + readded[id] = true + } + } + st.Forgotten = slices.DeleteFunc(st.Forgotten, func(id string) bool { return readded[id] }) // One side empty while the last sync saw files: it was lost or not // there yet, not emptied on purpose. Copy instead of deleting. if len(st.Files) > 0 && (len(here) == 0 || len(remote) == 0) { diff --git a/internal/gdrive/sync_test.go b/internal/gdrive/sync_test.go index beee0fd..e2d975d 100644 --- a/internal/gdrive/sync_test.go +++ b/internal/gdrive/sync_test.go @@ -364,6 +364,60 @@ func TestSyncForgottenFolderPartlyDeleted(t *testing.T) { } } +// A failed delete must not keep a re-added game's backup outside the +// mass-loss guard when its local folder later disappears. +func TestSyncReaddedForgottenFolderMassLoss(t *testing.T) { + d := newFake() + a := newPC(t) + t0 := time.Now().Add(-time.Hour) + for i := 0; i < 20; i++ { + a.write(fmt.Sprintf("g/%d.sav", i), "old", t0) + } + a.write(".versions/g/old/slot.sav", "history", t0) + a.write("other/1.sav", "other", t0) + a.sync(d) + failing := d.path(".versions/g/old/slot.sav") + d.failDelete = func(id string) bool { return id == failing } + for _, rel := range []string{"g", ".versions/g"} { + if err := os.RemoveAll(filepath.Join(a.local, filepath.FromSlash(rel))); err != nil { + t.Fatal(err) + } + } + a.forget("g") + if r := a.sync(d); r.DeletedThere != 20 || len(r.Errors) != 1 { + t.Fatalf("partial delete: %+v", r) + } + if st := a.saved(); len(st.Forgotten) != 1 { + t.Fatalf("forgotten after failed delete: %v", st.Forgotten) + } + + for i := 0; i < 20; i++ { + a.write(fmt.Sprintf("g/%d.sav", i), "re-added", t0.Add(time.Minute)) + } + if r := a.sync(d); r.Up != 20 || len(r.Errors) != 1 { + t.Fatalf("re-added game: %+v", r) + } + if st := a.saved(); len(st.Forgotten) != 0 { + t.Errorf("re-added game still forgotten: %v", st.Forgotten) + } + ids := make([]string, 20) + for i := range ids { + ids[i] = d.path(fmt.Sprintf("g/%d.sav", i)) + } + if err := os.RemoveAll(filepath.Join(a.local, "g")); err != nil { + t.Fatal(err) + } + if r := a.sync(d); r.DeletedThere != 0 || r.Down != 20 || len(r.Notes) == 0 || len(r.Errors) != 1 { + t.Errorf("re-added backup lost: %+v", r) + } + for i, id := range ids { + rel := fmt.Sprintf("g/%d.sav", i) + if d.path(rel) != id || string(d.data[id]) != "re-added" || a.read(rel) != "re-added" { + t.Errorf("%s was not preserved in Drive and restored locally", rel) + } + } +} + // Two PCs made the same folder at once: files in either copy count. func TestSyncDuplicateFolders(t *testing.T) { d := newFake() From 141a3f84ad1c13481a6be7a0df8d29f41352e589 Mon Sep 17 00:00:00 2001 From: ApolloF <98666317+ApolloF@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:30:41 +0200 Subject: [PATCH 2/2] Reject short-name aliases with trailing dots or spaces --- internal/paths/real.go | 4 +++- internal/paths/real_test.go | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/paths/real.go b/internal/paths/real.go index 271b8c2..d874ec4 100644 --- a/internal/paths/real.go +++ b/internal/paths/real.go @@ -194,4 +194,6 @@ var shortNameForm = regexp.MustCompile(`~[0-9]+(\.[^.]*)?$`) // short name, which may stand for a folder of another name. Other names // with a "~" are fine: a real short name elsewhere is caught on disk by // CheckSyncable. -func LooksShort(name string) bool { return shortNameForm.MatchString(name) } +func LooksShort(name string) bool { + return shortNameForm.MatchString(strings.TrimRight(name, ". ")) +} diff --git a/internal/paths/real_test.go b/internal/paths/real_test.go index e7f2484..99145bb 100644 --- a/internal/paths/real_test.go +++ b/internal/paths/real_test.go @@ -88,7 +88,7 @@ func TestResolveShortNameForm(t *testing.T) { t.Errorf("Resolve(home, %q) refused", rel) } } - for _, rel := range []string{"PROGRA~1", "MICROS~1.TXT", "Games/SAVEGA~12", "a~1."} { + for _, rel := range []string{"PROGRA~1", "MICROS~1.TXT", "Games/SAVEGA~12", "a~1.", "PROGRA~1 ", "PROGRA~1..", "MICROS~1.TXT."} { if p, ok := Resolve(Home, rel); ok { t.Errorf("Resolve(home, %q) = %q, want refused", rel, p) }