restic: restore a single kept file, not only a directory

The first restore of an arr app refused its settings file with "not a directory": restic restores
a snapshot's subfolder, not a file. A file is restored with its full path into a scratch directory
beside the target, moved into place, and the scratch removed.
This commit is contained in:
2026-10-05 14:05:52 +02:00
parent d67e786bf9
commit 46e3a5a6b7
2 changed files with 73 additions and 4 deletions
+46 -1
View File
@@ -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()
@@ -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("<Config>kept</Config>\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("<Config>overwritten</Config>\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) != "<Config>kept</Config>\n" {
t.Fatalf("the restored settings: %q", raw)
}
if raw, _ := os.ReadFile(settings); string(raw) != "<Config>overwritten</Config>\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")
}