fix(backup): use active SQLite database and validate safe restore (U01)
This commit is contained in:
@@ -7,70 +7,121 @@ namespace Duempelkas.Infrastructure.Services;
|
||||
|
||||
public class BackupService : IBackupService
|
||||
{
|
||||
private static readonly string DbPath = Path.Combine(
|
||||
Path.GetDirectoryName(Environment.ProcessPath ?? AppContext.BaseDirectory) ?? AppContext.BaseDirectory,
|
||||
"duempelkas.db");
|
||||
private readonly DatabaseLocation location;
|
||||
|
||||
public BackupService(DatabaseLocation location) => this.location = location;
|
||||
|
||||
public async Task<string> CreateBackupAsync()
|
||||
{
|
||||
if (!File.Exists(DbPath))
|
||||
{
|
||||
return "Keine Datenbankdatei gefunden.";
|
||||
}
|
||||
|
||||
var suggestedFileName = $"duempelkas-backup-{DateTime.Now:yyMMdd-HHmm}.bak";
|
||||
var backupPath = await ShowSaveDialogAsync(suggestedFileName);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(backupPath))
|
||||
{
|
||||
return "Backup wurde abgebrochen.";
|
||||
}
|
||||
|
||||
backupPath = Path.ChangeExtension(backupPath, ".bak");
|
||||
|
||||
try
|
||||
{
|
||||
File.Copy(DbPath, backupPath, overwrite: true);
|
||||
return $"Backup gespeichert: {backupPath}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"Backup fehlgeschlagen: {ex.Message}";
|
||||
var path = await ShowSaveDialogAsync($"duempelkas-backup-{DateTime.Now:yyyyMMdd-HHmmss}.bak");
|
||||
if (string.IsNullOrWhiteSpace(path)) return "Sicherung wurde abgebrochen.";
|
||||
await CreateBackupToAsync(path);
|
||||
return $"Sicherung gespeichert: {path}";
|
||||
}
|
||||
catch (Exception ex) { return $"Sicherung fehlgeschlagen: {ex.Message}"; }
|
||||
}
|
||||
|
||||
public async Task<string> RestoreBackupAsync()
|
||||
{
|
||||
var backupPath = await ShowOpenDialogAsync();
|
||||
if (string.IsNullOrWhiteSpace(backupPath))
|
||||
{
|
||||
return "Wiederherstellung wurde abgebrochen.";
|
||||
}
|
||||
|
||||
if (!File.Exists(backupPath))
|
||||
{
|
||||
return "Die gewählte Backup-Datei wurde nicht gefunden.";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Ensure no pooled connection keeps stale handles to the previous DB file.
|
||||
SqliteConnection.ClearAllPools();
|
||||
|
||||
var targetDirectory = Path.GetDirectoryName(DbPath);
|
||||
if (!string.IsNullOrWhiteSpace(targetDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(targetDirectory);
|
||||
}
|
||||
|
||||
File.Copy(backupPath, DbPath, overwrite: true);
|
||||
SqliteConnection.ClearAllPools();
|
||||
return "Wiederherstellung erfolgreich. Die Datenbank wurde neu geladen.";
|
||||
var path = await ShowOpenDialogAsync();
|
||||
if (string.IsNullOrWhiteSpace(path)) return "Wiederherstellung wurde abgebrochen.";
|
||||
var fallback = await RestoreFromAsync(path);
|
||||
return $"Wiederherstellung erfolgreich. Rückfallsicherung: {fallback}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception ex) { return $"Wiederherstellung fehlgeschlagen: {ex.Message}"; }
|
||||
}
|
||||
|
||||
public async Task CreateBackupToAsync(string path)
|
||||
{
|
||||
using var lease = await DatabaseLease.AcquireAsync(location);
|
||||
RejectActivePath(path);
|
||||
await Task.Run(() =>
|
||||
{
|
||||
return $"Wiederherstellung fehlgeschlagen: {ex.Message}";
|
||||
}
|
||||
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<string> 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<Duempelkas.Infrastructure.Persistence.FinanceDbContext>();
|
||||
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();
|
||||
}
|
||||
|
||||
private static Task<string?> ShowSaveDialogAsync(string suggestedFileName)
|
||||
@@ -143,4 +194,4 @@ if ($dialog.ShowDialog() -eq 'OK') { $dialog.FileName } else { '' }
|
||||
|
||||
return string.IsNullOrWhiteSpace(result) ? null : result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user