diff --git a/internal/backups/service.go b/internal/backups/service.go index 60c3abf..d2f66ff 100644 --- a/internal/backups/service.go +++ b/internal/backups/service.go @@ -216,37 +216,51 @@ func (s *Service) RestoreDrill(ctx context.Context) (RestoreDrillResult, error) return RestoreDrillResult{}, fmt.Errorf("extract backup: %w", err) } result := RestoreDrillResult{BackupName: files[0].Name} - err = filepath.WalkDir(destination, func(path string, entry os.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } + filesChecked, err := validateRestoredDatabases(ctx, destination) + if err != nil { + return RestoreDrillResult{}, err + } + result.DatabaseFiles = filesChecked + result.IntegrityChecked = len(filesChecked) + if result.IntegrityChecked == 0 { + return RestoreDrillResult{}, errors.New("restored archive contains no SQLite database") + } + return result, nil +} + +func validateRestoredDatabases(ctx context.Context, destination string) ([]string, error) { + entries, err := os.ReadDir(destination) + if err != nil { + return nil, err + } + files := make([]string, 0, 2) + for _, entry := range entries { + // A PocketBase restore boots only the root database files. Nested .db files + // are retained forensic/safety snapshots (for example quarantine/) and + // must not make an otherwise restorable backup fail its drill. if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".db") { - return nil + continue } - relative, _ := filepath.Rel(destination, path) - result.DatabaseFiles = append(result.DatabaseFiles, relative) + path := filepath.Join(destination, entry.Name()) database, err := sql.Open("sqlite", "file:"+path+"?mode=ro") if err != nil { - return fmt.Errorf("open restored database %s: %w", relative, err) + return nil, fmt.Errorf("open restored database %s: %w", entry.Name(), err) } - defer database.Close() var check string - if err := database.QueryRowContext(ctx, "PRAGMA integrity_check").Scan(&check); err != nil { - return fmt.Errorf("integrity check %s: %w", relative, err) + queryErr := database.QueryRowContext(ctx, "PRAGMA integrity_check").Scan(&check) + closeErr := database.Close() + if queryErr != nil { + return nil, fmt.Errorf("integrity check %s: %w", entry.Name(), queryErr) + } + if closeErr != nil { + return nil, closeErr } if !strings.EqualFold(strings.TrimSpace(check), "ok") { - return fmt.Errorf("integrity check %s returned %q", relative, check) + return nil, fmt.Errorf("integrity check %s returned %q", entry.Name(), check) } - result.IntegrityChecked++ - return nil - }) - if err != nil { - return RestoreDrillResult{}, err - } - if result.IntegrityChecked == 0 { - return RestoreDrillResult{}, errors.New("restored archive contains no SQLite database") + files = append(files, entry.Name()) } - return result, nil + return files, nil } func (s *Service) download(ctx context.Context, name string) (string, func(), error) { diff --git a/internal/backups/service_test.go b/internal/backups/service_test.go index b7b7182..2f1cb5e 100644 --- a/internal/backups/service_test.go +++ b/internal/backups/service_test.go @@ -2,12 +2,16 @@ package backups import ( "context" + "database/sql" + "os" + "path/filepath" "testing" "time" "github.com/Phloraxx/payment-api/internal/config" _ "github.com/Phloraxx/payment-api/migrations" "github.com/pocketbase/pocketbase/tests" + _ "modernc.org/sqlite" ) func TestConfigureCreateAndVerifyLocalBackup(t *testing.T) { @@ -47,3 +51,34 @@ func TestConfigureCreateAndVerifyLocalBackup(t *testing.T) { t.Fatalf("drill=%+v", drill) } } + +func TestValidateRestoredDatabasesIgnoresNestedForensicSnapshots(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{"data.db", "auxiliary.db"} { + db, err := sql.Open("sqlite", filepath.Join(dir, name)) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec("CREATE TABLE sample (id INTEGER PRIMARY KEY, value TEXT)"); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + } + quarantine := filepath.Join(dir, "quarantine", "20260811") + if err := os.MkdirAll(quarantine, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(quarantine, "auxiliary.db"), []byte("not sqlite"), 0o600); err != nil { + t.Fatal(err) + } + + files, err := validateRestoredDatabases(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + if len(files) != 2 { + t.Fatalf("validated files=%v", files) + } +}