diff --git a/backend/internal/services/backup_service.go b/backend/internal/services/backup_service.go index bf72e832..e25e73f5 100644 --- a/backend/internal/services/backup_service.go +++ b/backend/internal/services/backup_service.go @@ -290,8 +290,8 @@ func (s *BackupService) CreateBackup() (string, error) { return "", err } defer func() { - if err := outFile.Close(); err != nil { - logger.Log().WithError(err).Warn("failed to close backup file") + if closeErr := outFile.Close(); closeErr != nil { + logger.Log().WithError(closeErr).Warn("failed to close backup file") } }() @@ -301,20 +301,12 @@ func (s *BackupService) CreateBackup() (string, error) { // 1. Database dbPath := filepath.Join(s.DataDir, s.DatabaseName) // Ensure DB exists before backing up - if _, err := os.Stat(dbPath); os.IsNotExist(err) { + if _, statErr := os.Stat(dbPath); os.IsNotExist(statErr) { return "", fmt.Errorf("database file not found: %s", dbPath) } - backupSourcePath := dbPath - cleanupBackupSource := func() {} - - if snapshotPath, cleanup, err := createSQLiteSnapshot(dbPath); err == nil { - backupSourcePath = snapshotPath - cleanupBackupSource = cleanup - } else { - logger.Log().WithError(err).Warn("failed to create sqlite snapshot before backup; falling back to direct file copy") - if checkpointErr := checkpointSQLiteDatabase(dbPath); checkpointErr != nil { - logger.Log().WithError(checkpointErr).Warn("failed to checkpoint sqlite wal before backup; proceeding with file snapshot") - } + backupSourcePath, cleanupBackupSource, err := createSQLiteSnapshot(dbPath) + if err != nil { + return "", fmt.Errorf("create sqlite snapshot before backup: %w", err) } defer cleanupBackupSource() @@ -322,22 +314,6 @@ func (s *BackupService) CreateBackup() (string, error) { return "", fmt.Errorf("backup db: %w", err) } - if backupSourcePath == dbPath { - walPath := dbPath + "-wal" - if _, err := os.Stat(walPath); err == nil { - if err := s.addToZip(w, walPath, s.DatabaseName+"-wal"); err != nil { - return "", fmt.Errorf("backup db wal: %w", err) - } - } - - shmPath := dbPath + "-shm" - if _, err := os.Stat(shmPath); err == nil { - if err := s.addToZip(w, shmPath, s.DatabaseName+"-shm"); err != nil { - return "", fmt.Errorf("backup db shm: %w", err) - } - } - } - // 2. Caddy Data (Certificates, etc) // We walk the 'caddy' subdirectory caddyDir := filepath.Join(s.DataDir, "caddy") @@ -446,8 +422,14 @@ func (s *BackupService) RestoreBackup(filename string) error { s.restoreDBPath = restoreDBPath } - // 2. Unzip to DataDir (overwriting) - return s.unzip(srcPath, s.DataDir) + // 2. Unzip to DataDir while skipping database files. + // Database data is applied through controlled live rehydrate to avoid corrupting the active SQLite file. + skipEntries := map[string]struct{}{ + s.DatabaseName: {}, + s.DatabaseName + "-wal": {}, + s.DatabaseName + "-shm": {}, + } + return s.unzipWithSkip(srcPath, s.DataDir, skipEntries) } // RehydrateLiveDatabase reloads the currently-open SQLite database from the restored DB file @@ -705,7 +687,7 @@ func (s *BackupService) extractDatabaseFromBackup(zipPath string) (string, error return tmpPath, nil } -func (s *BackupService) unzip(src, dest string) error { +func (s *BackupService) unzipWithSkip(src, dest string, skipEntries map[string]struct{}) error { r, err := zip.OpenReader(src) if err != nil { return err @@ -717,6 +699,12 @@ func (s *BackupService) unzip(src, dest string) error { }() for _, f := range r.File { + if skipEntries != nil { + if _, skip := skipEntries[filepath.Clean(f.Name)]; skip { + continue + } + } + // Use SafeJoinPath to prevent directory traversal attacks fpath, err := SafeJoinPath(dest, f.Name) if err != nil { diff --git a/backend/internal/services/backup_service_test.go b/backend/internal/services/backup_service_test.go index 9ec62d7b..8434bde6 100644 --- a/backend/internal/services/backup_service_test.go +++ b/backend/internal/services/backup_service_test.go @@ -11,8 +11,19 @@ import ( "github.com/Wikid82/charon/backend/internal/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" ) +func createSQLiteTestDB(t *testing.T, dbPath string) { + t.Helper() + + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.Exec("CREATE TABLE IF NOT EXISTS healthcheck (id INTEGER PRIMARY KEY, value TEXT)").Error) + require.NoError(t, db.Exec("INSERT INTO healthcheck (value) VALUES (?)", "ok").Error) +} + func TestBackupService_CreateAndList(t *testing.T) { // Setup temp dirs tmpDir, err := os.MkdirTemp("", "cpm-backup-service-test") @@ -23,10 +34,9 @@ func TestBackupService_CreateAndList(t *testing.T) { err = os.MkdirAll(dataDir, 0o700) require.NoError(t, err) - // Create dummy DB + // Create valid sqlite DB dbPath := filepath.Join(dataDir, "charon.db") - err = os.WriteFile(dbPath, []byte("dummy db"), 0o600) - require.NoError(t, err) + createSQLiteTestDB(t, dbPath) // Create dummy caddy dir caddyDir := filepath.Join(dataDir, "caddy") @@ -58,18 +68,13 @@ func TestBackupService_CreateAndList(t *testing.T) { assert.Equal(t, filepath.Join(service.BackupDir, filename), path) // Test Restore - // Modify DB to verify restore - err = os.WriteFile(dbPath, []byte("modified db"), 0o600) - require.NoError(t, err) err = service.RestoreBackup(filename) require.NoError(t, err) - // Verify DB content restored - // #nosec G304 -- Test reads from known database path in test directory - content, err := os.ReadFile(dbPath) - require.NoError(t, err) - assert.Equal(t, "dummy db", string(content)) + // DB file is staged for live rehydrate (not directly overwritten during unzip) + assert.NotEmpty(t, service.restoreDBPath) + assert.FileExists(t, service.restoreDBPath) // Test Delete err = service.DeleteBackup(filename) @@ -1173,7 +1178,7 @@ func TestBackupService_FullCycle(t *testing.T) { // Create database and caddy config dbPath := filepath.Join(dataDir, "charon.db") - _ = os.WriteFile(dbPath, []byte("original db"), 0o600) // #nosec G306 -- test fixture + createSQLiteTestDB(t, dbPath) caddyDir := filepath.Join(dataDir, "caddy") _ = os.MkdirAll(caddyDir, 0o750) // #nosec G301 -- test directory @@ -1188,20 +1193,15 @@ func TestBackupService_FullCycle(t *testing.T) { require.NoError(t, err) // Modify files - _ = os.WriteFile(dbPath, []byte("modified db"), 0o600) // #nosec G306 -- test fixture _ = os.WriteFile(filepath.Join(caddyDir, "config.json"), []byte(`{"modified": true}`), 0o600) // #nosec G306 -- test fixture - // Verify modification - content, _ := os.ReadFile(dbPath) // #nosec G304 -- test fixture path - assert.Equal(t, "modified db", string(content)) - // Restore backup err = service.RestoreBackup(filename) require.NoError(t, err) - // Verify restoration - content, _ = os.ReadFile(dbPath) // #nosec G304 -- test fixture path - assert.Equal(t, "original db", string(content)) + // DB file is staged for live rehydrate (not directly overwritten during unzip) + assert.NotEmpty(t, service.restoreDBPath) + assert.FileExists(t, service.restoreDBPath) caddyContent, _ := os.ReadFile(filepath.Join(caddyDir, "config.json")) // #nosec G304 -- test fixture path assert.Equal(t, `{"original": true}`, string(caddyContent))