using Duempelkas.App.Services.Models; using System.Diagnostics; using System.Runtime.InteropServices; using Duempelkas.App.Services; using Microsoft.Data.Sqlite; namespace Duempelkas.Infrastructure.Services; public class BackupService : IBackupService { private readonly DatabaseLocation location; private readonly IFileDialogService dialogs; public BackupService(DatabaseLocation location, IFileDialogService? dialogs = null) { this.location = location; this.dialogs = dialogs ?? new FileDialogService(); } public async Task CreateBackupAsync() { try { var path = await dialogs.SaveAsync($"duempelkas-backup-{DateTime.Now:yyyyMMdd-HHmmss}.bak"); if (string.IsNullOrWhiteSpace(path)) return FileOperationResult.Cancelled(); await CreateBackupToAsync(path); return new(FileOperationStatus.Success, "Sicherung gespeichert.", path); } catch (Exception ex) { return FileOperationResult.Error(ex); } } public async Task RestoreBackupAsync() { try { var path = await dialogs.OpenBackupAsync(); if (string.IsNullOrWhiteSpace(path)) return FileOperationResult.Cancelled(); var fallback = await RestoreFromAsync(path); return new(FileOperationStatus.Success, "Sicherung zurückgespielt. Die bisherigen Daten liegen in der Rückfallsicherung:", fallback); } catch (Exception ex) { return FileOperationResult.Error(ex); } } public async Task CreateBackupToAsync(string path) { using var lease = await DatabaseLease.AcquireAsync(location); RejectActivePath(path); await Task.Run(() => { var staged = System.IO.Path.GetFullPath(path) + $".{Guid.NewGuid():N}.tmp"; try { Snapshot(location.Path, staged); File.Move(staged, path, overwrite: true); } finally { if (File.Exists(staged)) File.Delete(staged); } }); } public async Task RestoreFromAsync(string path) { using var lease = await DatabaseLease.AcquireAsync(location); return await Task.Run(() => { RejectActivePath(path); // Stage the selected database first, so a changing/removable source cannot // change between validation and restore. BackupDatabase includes WAL data. var staged = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"duempelkas-{Guid.NewGuid():N}.db"); try { Snapshot(path, staged); Validate(staged); var fallback = location.Path + $".before-restore-{DateTime.Now:yyyyMMdd-HHmmss}-{Guid.NewGuid():N}.bak"; Snapshot(location.Path, fallback); // SQLite's online backup atomically replaces database contents, retaining // the active file/handles and coordinating locks. Never copy over an open DB. Snapshot(staged, location.Path); return fallback; } finally { if (File.Exists(staged)) File.Delete(staged); } }); } private void RejectActivePath(string path) { if (new[] { location.Path, location.Path + "-wal", location.Path + "-shm", location.Path + "-journal" } .Contains(System.IO.Path.GetFullPath(path), StringComparer.OrdinalIgnoreCase)) throw new InvalidOperationException("Bitte eine andere Datei als die aktive Datenbank wählen."); } private static SqliteConnection Open(string path, SqliteOpenMode mode) { var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = System.IO.Path.GetFullPath(path), Mode = mode, Pooling = false }.ToString()); try { connection.Open(); return connection; } catch { connection.Dispose(); throw; } } private static void Snapshot(string source, string destination) { if (string.Equals(System.IO.Path.GetFullPath(source), System.IO.Path.GetFullPath(destination), StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Quelle und Ziel müssen verschiedene Dateien sein."); using var input = Open(source, SqliteOpenMode.ReadOnly); using var output = Open(destination, SqliteOpenMode.ReadWriteCreate); input.BackupDatabase(output); } private static void Validate(string path) { using var connection = Open(path, SqliteOpenMode.ReadOnly); using var check = connection.CreateCommand(); check.CommandText = "PRAGMA integrity_check"; if (!string.Equals(check.ExecuteScalar()?.ToString(), "ok", StringComparison.Ordinal)) throw new InvalidOperationException("Die Sicherung ist beschädigt."); check.CommandText = "PRAGMA foreign_key_check"; using (var reader = check.ExecuteReader()) if (reader.Read()) throw new InvalidOperationException("Die Sicherung enthält ungültige Verknüpfungen."); var options = new Microsoft.EntityFrameworkCore.DbContextOptionsBuilder(); Microsoft.EntityFrameworkCore.SqliteDbContextOptionsBuilderExtensions.UseSqlite(options, connection); using var db = new Duempelkas.Infrastructure.Persistence.FinanceDbContext(options.Options); var expected = Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.GetMigrations(db.Database).ToArray(); var applied = Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.GetAppliedMigrations(db.Database).ToArray(); if (!expected.SequenceEqual(applied)) throw new InvalidOperationException("Diese Sicherung passt nicht zu dieser Programmversion."); // Materialize every mapped column to reject a forged/missing schema before touching active data. _ = db.Accounts.ToArray(); _ = db.Entries.ToArray(); _ = db.TransferLinks.ToArray(); } }