diff --git a/modules/restic/cmd/restic-backups/backups.go b/modules/restic/cmd/restic-backups/backups.go index 08a8339..bd3bc51 100644 --- a/modules/restic/cmd/restic-backups/backups.go +++ b/modules/restic/cmd/restic-backups/backups.go @@ -440,7 +440,7 @@ func (b *Backups) Restore(ctx context.Context, module, snapshot, path string) (* if b.exists(ctx, target) { return nil, fmt.Errorf("%s already exists; nothing is restored over anything", target) } - if _, err := b.restic(ctx, "restore", chosen.ID+":"+p, "--target", target); err != nil { + if err := b.restoreOne(ctx, chosen.ID, p, target); err != nil { return nil, err } r.Restored = append(r.Restored, target) @@ -449,6 +449,51 @@ func (b *Backups) Restore(ctx context.Context, module, snapshot, path string) (* return r, nil } +// restoreOne puts one kept path at target. A directory is restored as the snapshot's subfolder, so +// its contents land directly in target; a single file cannot be — restic restores a subfolder, not a +// file (a settings file refused with "not a directory" on the first restore of an app, 2026-10-05) — +// so it is restored with its full path into a scratch directory beside target, moved into place, and +// the scratch directory removed. +func (b *Backups) restoreOne(ctx context.Context, id, path, target string) error { + file, err := b.isFile(ctx, id, path) + if err != nil { + return err + } + if !file { + _, err := b.restic(ctx, "restore", id+":"+path, "--target", target) + return err + } + scratch := target + ".partial" + if _, err := b.restic(ctx, "restore", id, "--target", scratch, "--include", path); err != nil { + return err + } + if _, err := b.Run(ctx, "mv", scratch+path, target); err != nil { + return err + } + _, err = b.Run(ctx, "rm", "-rf", scratch) + return err +} + +// isFile is whether a kept path is a single file in the snapshot, as restic lists it. +func (b *Backups) isFile(ctx context.Context, id, path string) (bool, error) { + out, err := b.restic(ctx, "ls", "--json", id, path) + if err != nil { + return false, err + } + scanner := bufio.NewScanner(strings.NewReader(out)) + scanner.Buffer(make([]byte, 1024*1024), 16*1024*1024) + for scanner.Scan() { + var node struct { + Type string `json:"type"` + Path string `json:"path"` + } + if json.Unmarshal(scanner.Bytes(), &node) == nil && node.Path == path { + return node.Type == "file", nil + } + } + return false, fmt.Errorf("restore point %s does not list %s", id, path) +} + // Check is the weekly look at the repository's own integrity, with a sample of the data read back. func (b *Backups) Check(ctx context.Context) error { b.mu.Lock() diff --git a/modules/restic/cmd/restic-backups/backups_test.go b/modules/restic/cmd/restic-backups/backups_test.go index 684ba3f..34b75b2 100644 --- a/modules/restic/cmd/restic-backups/backups_test.go +++ b/modules/restic/cmd/restic-backups/backups_test.go @@ -187,7 +187,9 @@ func TestWithTheRealResticAMistakeIsUndoneBesideTheLiveData(t *testing.T) { must(t, os.Mkdir(store, 0o700)) must(t, os.Mkdir(dumps, 0o700)) must(t, os.WriteFile(filepath.Join(store, "mailbox"), []byte("the only copy of a letter\n"), 0o600)) - where := placed(t, "# mail\npath "+store+"\n# pg\nrun echo 'every row' > "+dumps+"/all.dump\npath "+dumps+"\n") + settings := filepath.Join(root, "settings.xml") + must(t, os.WriteFile(settings, []byte("kept\n"), 0o600)) + where := placed(t, "# mail\npath "+store+"\npath "+settings+"\n# pg\nrun echo 'every row' > "+dumps+"/all.dump\npath "+dumps+"\n") // As whoever runs the test, against its own repository: no sudo. run := func(ctx context.Context, name string, args ...string) (string, error) { out, err := exec.CommandContext(ctx, name, args...).Output() @@ -217,14 +219,36 @@ func TestWithTheRealResticAMistakeIsUndoneBesideTheLiveData(t *testing.T) { t.Fatalf("listed %+v", listed) } + // The mistake to a single file, too. + must(t, os.WriteFile(settings, []byte("overwritten\n"), 0o600)) + restored, err := b.Restore(ctx, "mail", "", "") must(t, err) - if len(restored.Restored) != 1 || !strings.HasSuffix(restored.Restored[0], "store.restored-20261006-030000") { + if len(restored.Restored) != 2 { t.Fatalf("restored %+v", restored) } - if raw, _ := os.ReadFile(filepath.Join(restored.Restored[0], "mailbox")); string(raw) != "the only copy of a letter\n" { + var dir, file string + for _, r := range restored.Restored { + switch { + case strings.HasSuffix(r, "store.restored-20261006-030000"): + dir = r + case strings.HasSuffix(r, "settings.xml.restored-20261006-030000"): + file = r + } + } + if raw, _ := os.ReadFile(filepath.Join(dir, "mailbox")); string(raw) != "the only copy of a letter\n" { t.Fatalf("the restored letter: %q", raw) } + // A single file comes back as a file beside the live one, and nothing of the scratch remains. + if raw, _ := os.ReadFile(file); string(raw) != "kept\n" { + t.Fatalf("the restored settings: %q", raw) + } + if raw, _ := os.ReadFile(settings); string(raw) != "overwritten\n" { + t.Fatalf("the restore wrote over the live settings: %q", raw) + } + if _, err := os.Stat(file + ".partial"); err == nil { + t.Fatal("the scratch directory was left behind") + } if _, err := os.Stat(filepath.Join(store, "mailbox")); err == nil { t.Fatal("the restore wrote into the live directory") }