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")
}