package backup import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" ) type restoreFile struct { path string before []byte after []byte mode os.FileMode existed bool } type restoreJournal struct { DebtikDir string `json:"debtik_dir"` NetworkDir string `json:"network_dir"` NewDir string `json:"new_dir,omitempty"` OldDir string `json:"old_dir,omitempty"` OldExists bool `json:"old_exists"` HasDebtik bool `json:"has_debtik"` Committed bool `json:"committed"` Files []journalFile `json:"files"` } type journalFile struct { Path string `json:"path"` Data []byte `json:"data,omitempty"` Mode os.FileMode `json:"mode"` Existed bool `json:"existed"` } func (m *Manager) journalPath() string { return filepath.Join(m.backupStore, "restore-transaction.json") } func (m *Manager) writeJournal(j *restoreJournal) error { if err := os.MkdirAll(m.backupStore, 0700); err != nil { return err } data, err := json.Marshal(j) if err != nil { return err } return restoreWrite(m.journalPath(), data, 0600) } func syncRestoreDir(path string) error { d, err := os.Open(path) if err != nil { return err } defer d.Close() return d.Sync() } // RecoverInterruptedRestore must be called before managers load configuration. func (m *Manager) RecoverInterruptedRestore() (bool, error) { m.mu.Lock() defer m.mu.Unlock() return m.recoverInterruptedRestoreLocked() } func (m *Manager) recoverInterruptedRestoreLocked() (bool, error) { data, err := os.ReadFile(m.journalPath()) if os.IsNotExist(err) { return false, nil } if err != nil { return false, err } var j restoreJournal if err = json.Unmarshal(data, &j); err != nil { return false, err } if j.DebtikDir != m.debtikDir || j.NetworkDir != m.networkDir { return false, errors.New("restore journal paths do not match configured paths") } parent := filepath.Dir(m.debtikDir) if j.HasDebtik && (j.NewDir == "" || (j.OldExists && j.OldDir == "") || (!j.OldExists && j.OldDir != "")) { return false, errors.New("invalid restore journal directory state") } if !j.HasDebtik && (j.NewDir != "" || j.OldDir != "" || j.OldExists) { return false, errors.New("invalid restore journal directory state") } for _, p := range []string{j.NewDir, j.OldDir} { if p != "" && (filepath.Dir(p) != parent || !strings.HasPrefix(filepath.Base(p), ".debtik-restore-")) { return false, errors.New("invalid restore journal staging path") } } for _, f := range j.Files { if filepath.Dir(f.Path) != m.networkDir || !strings.HasSuffix(f.Path, ".cfg") { return false, errors.New("invalid restore journal network path") } } if !j.Committed { for _, f := range j.Files { if f.Existed { if err = os.MkdirAll(m.networkDir, 0755); err == nil { err = restoreWrite(f.Path, f.Data, f.Mode) } } else { err = os.Remove(f.Path) if os.IsNotExist(err) { err = nil } } if err != nil { return false, fmt.Errorf("recovering %s: %w", f.Path, err) } } if j.HasDebtik { if j.OldExists { if _, e := os.Lstat(j.OldDir); e == nil { if err = os.RemoveAll(m.debtikDir); err != nil { return false, err } if err = os.Rename(j.OldDir, m.debtikDir); err != nil { return false, err } if err = syncRestoreDir(parent); err != nil { return false, err } } else if !os.IsNotExist(e) { return false, e } else if _, e = os.Lstat(m.debtikDir); e != nil { return false, fmt.Errorf("original configuration unavailable: %w", e) } } else if _, e := os.Lstat(j.NewDir); os.IsNotExist(e) { if err = os.RemoveAll(m.debtikDir); err != nil { return false, err } if err = syncRestoreDir(parent); err != nil { return false, err } } else if e != nil { return false, e } } } if j.NewDir != "" { if err = os.RemoveAll(j.NewDir); err != nil { return false, err } } if j.OldDir != "" { if err = os.RemoveAll(j.OldDir); err != nil { return false, err } } if j.HasDebtik { if err = syncRestoreDir(parent); err != nil { return false, err } } if err = os.Remove(m.journalPath()); err != nil { return false, err } if err = syncRestoreDir(m.backupStore); err != nil { return false, err } return true, nil } // applyStagedRestore restores the original on-disk files if an apply step fails. // The optional step hook enables deterministic failure tests. func (m *Manager) applyStagedRestore(stage string, step func(string) error) (err error) { runStep := func(name string) error { if step != nil { return step(name) } return nil } stagedDebtik := filepath.Join(stage, "debtik") stagedNetwork := filepath.Join(stage, "network") var files []restoreFile entries, readErr := os.ReadDir(stagedNetwork) if readErr != nil && !os.IsNotExist(readErr) { return fmt.Errorf("failed listing staged network files: %w", readErr) } for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".cfg") { continue } target := filepath.Join(m.networkDir, entry.Name()) f := restoreFile{path: target, mode: 0644} f.after, err = os.ReadFile(filepath.Join(stagedNetwork, entry.Name())) if err != nil { return fmt.Errorf("failed reading staged network file %s: %w", entry.Name(), err) } info, statErr := os.Lstat(target) if statErr == nil { if !info.Mode().IsRegular() { return fmt.Errorf("failed restoring network file %s: destination is not a regular file", entry.Name()) } f.existed = true f.mode = info.Mode().Perm() f.before, err = os.ReadFile(target) if err != nil { return fmt.Errorf("failed reading current network file %s: %w", entry.Name(), err) } } else if !os.IsNotExist(statErr) { return statErr } files = append(files, f) } hasDebtik := false if info, e := os.Stat(stagedDebtik); e == nil { if !info.IsDir() { return errors.New("invalid staged configuration") } hasDebtik = true } else if !os.IsNotExist(e) { return e } var newDir, oldDir string oldExists := false if hasDebtik { parent := filepath.Dir(m.debtikDir) if err = os.MkdirAll(parent, 0755); err != nil { return err } newDir, err = os.MkdirTemp(parent, ".debtik-restore-new-*") if err != nil { return err } defer func() { if _, e := os.Stat(m.journalPath()); os.IsNotExist(e) { _ = os.RemoveAll(newDir) } }() if err = m.syncDirectory(stagedDebtik, newDir); err != nil { return fmt.Errorf("failed staging debtik configuration: %w", err) } info, e := os.Lstat(m.debtikDir) if e == nil { if !info.IsDir() { return errors.New("debtik configuration path is not a directory") } oldExists = true } else if !os.IsNotExist(e) { return e } if oldExists { oldDir, err = os.MkdirTemp(parent, ".debtik-restore-old-*") if err != nil { return err } if err = os.Remove(oldDir); err != nil { return err } } } if _, err := os.Stat(m.journalPath()); err == nil { return errors.New("an interrupted restore must be recovered first") } else if !os.IsNotExist(err) { return err } journal := &restoreJournal{DebtikDir: m.debtikDir, NetworkDir: m.networkDir, NewDir: newDir, OldDir: oldDir, OldExists: oldExists, HasDebtik: hasDebtik} for _, f := range files { journal.Files = append(journal.Files, journalFile{Path: f.path, Data: f.before, Mode: f.mode, Existed: f.existed}) } if err = m.writeJournal(journal); err != nil { return fmt.Errorf("failed recording restore transaction: %w", err) } rollback := func(cause error) error { if _, err := m.recoverInterruptedRestoreLocked(); err != nil { return fmt.Errorf("restore failed and rollback incomplete: %w", errors.Join(cause, err)) } return fmt.Errorf("restore failed; previous files restored: %w", cause) } if hasDebtik { if oldExists { if err = os.Rename(m.debtikDir, oldDir); err != nil { return rollback(err) } if err = syncRestoreDir(filepath.Dir(m.debtikDir)); err != nil { return rollback(err) } if err = runStep("old-renamed"); err != nil { return rollback(err) } } if err = os.Rename(newDir, m.debtikDir); err != nil { return rollback(err) } if err = syncRestoreDir(filepath.Dir(m.debtikDir)); err != nil { return rollback(err) } if err = runStep("debtik"); err != nil { return rollback(err) } } if len(files) > 0 { if err = os.MkdirAll(m.networkDir, 0755); err != nil { return rollback(err) } } for _, f := range files { if err = restoreWrite(f.path, f.after, 0644); err != nil { return rollback(fmt.Errorf("failed restoring network file %s: %w", filepath.Base(f.path), err)) } if err = runStep("network"); err != nil { return rollback(err) } } journal.Committed = true if err = m.writeJournal(journal); err != nil { return fmt.Errorf("could not commit restore journal; recovery required: %w", err) } if err = runStep("committed"); err != nil { return fmt.Errorf("restore committed; cleanup pending: %w", err) } if _, err = m.recoverInterruptedRestoreLocked(); err != nil { return fmt.Errorf("configuration restored but transaction cleanup failed: %w", err) } return nil } func restoreWrite(path string, data []byte, mode os.FileMode) error { f, err := os.CreateTemp(filepath.Dir(path), ".debtik-restore-*") if err != nil { return err } name := f.Name() defer os.Remove(name) if err = f.Chmod(mode); err == nil { _, err = f.Write(data) } if err == nil { err = f.Sync() } if closeErr := f.Close(); err == nil { err = closeErr } if err != nil { return err } if err = os.Rename(name, path); err != nil { return err } return syncRestoreDir(filepath.Dir(path)) }