using System.IO.Compression; using System.Security.Cryptography; using System.Text; using System.Text.Json; using ClawdDotNet.Core.Security; using ClawdDotNet.Core.Storage; using Microsoft.Data.Sqlite; namespace ClawdDotNet.Core.Backup; /// /// Sichert eine Instanz vollständig und stellt sie wieder her. /// /// Zwei Dinge sind dabei nicht offensichtlich: /// /// 1. Die Datenbank darf nicht einfach kopiert werden. Mit WAL stehen die jüngsten /// Änderungen in der Begleitdatei, nicht in der Hauptdatei — eine reine Kopie wäre /// veraltet oder in sich widersprüchlich. VACUUM INTO erzeugt dagegen im /// laufenden Betrieb eine geschlossene, konsistente Kopie. /// /// 2. Zugangsdaten sind mit DPAPI geschützt und damit an Benutzer und Rechner /// gebunden. In einer Sicherung wären sie genau dann unbrauchbar, wenn man sie /// braucht. Sie werden deshalb auf eine Passphrase umgeschlüsselt — oder auf /// Wunsch weggelassen. /// public sealed class BackupService { private const string ManifestName = "manifest.json"; private const string DatabaseName = "state.db"; private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; /// Wird nie mitgesichert — entweder erzeugt oder unerwünscht. private static readonly string[] AlwaysExcludedDirectories = ["bin", "obj", ".vs"]; private static readonly string[] AlwaysExcludedExtensions = [".tmp", ".bak"]; // ═══════════════════════════════════════════════════════════ // Sichern // ═══════════════════════════════════════════════════════════ public async Task CreateAsync( string instanceDir, string targetZipPath, BackupOptions options, CancellationToken ct = default) { if (!Directory.Exists(instanceDir)) throw new BackupException($"Instanzverzeichnis nicht gefunden: {instanceDir}"); if (options.Secrets == SecretMode.Passphrase && string.IsNullOrEmpty(options.Passphrase)) throw new BackupException("Für den Schutz der Zugangsdaten wird eine Passphrase benötigt."); var staging = Path.Combine(Path.GetTempPath(), "clawd-backup-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(staging); try { var files = new List(); var secretCount = 0; // ─── Datenbank konsistent kopieren ─── var dbPath = Path.Combine(instanceDir, DatabaseName); if (File.Exists(dbPath)) { var target = Path.Combine(staging, DatabaseName); CopyDatabaseConsistently(dbPath, target); files.Add(await DescribeAsync(staging, target, ct)); } // ─── Übrige Dateien ─── foreach (var source in EnumerateFiles(instanceDir, options)) { ct.ThrowIfCancellationRequested(); var relative = Path.GetRelativePath(instanceDir, source); var target = Path.Combine(staging, relative); Directory.CreateDirectory(Path.GetDirectoryName(target)!); if (IsConfigFile(relative)) { var original = AtomicFile.ReadAllText(source); var (rewritten, count) = RewriteSecretsForBackup(original, options); secretCount += count; await File.WriteAllTextAsync(target, rewritten, ct); } else { File.Copy(source, target, overwrite: true); } files.Add(await DescribeAsync(staging, target, ct)); } // ─── Manifest ─── var (instanceId, instanceName) = ReadInstanceIdentity(instanceDir); var manifest = new BackupManifest { CreatedAt = DateTime.Now, InstanceId = instanceId, InstanceName = instanceName, Secrets = options.Secrets.ToString(), SecretCount = secretCount, Files = files.OrderBy(f => f.Path, StringComparer.OrdinalIgnoreCase).ToList() }; await File.WriteAllTextAsync( Path.Combine(staging, ManifestName), JsonSerializer.Serialize(manifest, JsonOptions), ct); // ─── Archiv ─── Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(targetZipPath))!); if (File.Exists(targetZipPath)) File.Delete(targetZipPath); ZipFile.CreateFromDirectory(staging, targetZipPath, CompressionLevel.Optimal, includeBaseDirectory: false); return new BackupResult(targetZipPath, manifest, new FileInfo(targetZipPath).Length); } finally { TryDeleteDirectory(staging); } } /// /// Erzeugt eine konsistente Kopie der Datenbank, auch während sie in Benutzung ist. /// private static void CopyDatabaseConsistently(string sourcePath, string targetPath) { var connectionString = new SqliteConnectionStringBuilder { DataSource = sourcePath, Mode = SqliteOpenMode.ReadOnly }.ToString(); using var conn = new SqliteConnection(connectionString); conn.Open(); using var cmd = conn.CreateCommand(); // Parameter sind in VACUUM INTO nicht erlaubt, deshalb einfache Anführungszeichen // im Pfad verdoppeln. cmd.CommandText = $"VACUUM INTO '{targetPath.Replace("'", "''")}'"; cmd.ExecuteNonQuery(); } // ═══════════════════════════════════════════════════════════ // Prüfen // ═══════════════════════════════════════════════════════════ /// Liest das Manifest, ohne etwas auszupacken. public async Task InspectAsync(string zipPath, CancellationToken ct = default) { using var archive = ZipFile.OpenRead(zipPath); var entry = archive.GetEntry(ManifestName) ?? throw new BackupException("Kein Manifest im Archiv — das ist keine ClawdDotNet-Sicherung."); await using var stream = entry.Open(); using var reader = new StreamReader(stream, Encoding.UTF8); var json = await reader.ReadToEndAsync(ct); return JsonSerializer.Deserialize(json) ?? throw new BackupException("Das Manifest ist unlesbar."); } // ═══════════════════════════════════════════════════════════ // Wiederherstellen // ═══════════════════════════════════════════════════════════ public async Task RestoreAsync( string zipPath, string targetDir, RestoreOptions options, CancellationToken ct = default) { var manifest = await InspectAsync(zipPath, ct); if (manifest.HasSecrets && string.IsNullOrEmpty(options.Passphrase)) throw new BackupException( "Diese Sicherung enthält geschützte Zugangsdaten. Bitte die Passphrase angeben."); var written = new List(); var skipped = new List(); var wouldOverwrite = new List(); using var archive = ZipFile.OpenRead(zipPath); foreach (var entry in archive.Entries) { ct.ThrowIfCancellationRequested(); if (entry.FullName == ManifestName || string.IsNullOrEmpty(entry.Name)) continue; var relative = entry.FullName.Replace('/', Path.DirectorySeparatorChar); var destination = ResolveInside(targetDir, relative); // Prüfsumme gegen das Manifest — ein beschädigtes Archiv soll auffallen, // bevor etwas überschrieben wird. var expected = manifest.Files.FirstOrDefault( f => string.Equals(f.Path, entry.FullName, StringComparison.OrdinalIgnoreCase)); var content = await ReadEntryAsync(entry, ct); if (expected is not null && ComputeSha256(content) != expected.Sha256) { throw new BackupException( $"Prüfsumme stimmt nicht für '{entry.FullName}'. Das Archiv ist beschädigt."); } if (File.Exists(destination)) { wouldOverwrite.Add(relative); if (!options.Overwrite) { skipped.Add(relative); continue; } } if (options.DryRun) continue; var restored = manifest.HasSecrets && IsConfigFile(relative) ? Encoding.UTF8.GetBytes( RewriteSecretsForRestore(Encoding.UTF8.GetString(content), options.Passphrase!)) : content; AtomicFile.WriteAllBytes(destination, restored); written.Add(relative); } return new RestoreResult(written, skipped, wouldOverwrite); } // ═══════════════════════════════════════════════════════════ // Zugangsdaten // ═══════════════════════════════════════════════════════════ private static (string Json, int SecretCount) RewriteSecretsForBackup( string json, BackupOptions options) { var count = 0; var rewritten = JsonSecretRewriter.Rewrite(json, value => { count++; // In der Datei liegt der Wert DPAPI-geschützt; für die Sicherung wird er // zunächst gelesen und dann anders geschützt. string? plain; try { plain = SecretProtector.Unprotect(value); } catch (SecretProtectionException) { // Nicht lesbar — etwa weil die Datei von einem anderen Benutzer stammt. // Der Wert darf dann nicht als vermeintlicher Klartext weitergereicht // werden. return null; } return options.Secrets == SecretMode.Passphrase ? PassphraseProtector.Protect(plain, options.Passphrase!) : null; }); return (rewritten, count); } private static string RewriteSecretsForRestore(string json, string passphrase) => JsonSecretRewriter.Rewrite(json, value => { if (!PassphraseProtector.IsProtected(value)) return value; var plain = PassphraseProtector.Unprotect(value, passphrase); // Zurück auf DPAPI des Zielrechners. return SecretProtector.Protect(plain); }); // ═══════════════════════════════════════════════════════════ // Hilfsfunktionen // ═══════════════════════════════════════════════════════════ private static IEnumerable EnumerateFiles(string instanceDir, BackupOptions options) { foreach (var path in Directory.EnumerateFiles(instanceDir, "*", SearchOption.AllDirectories)) { var relative = Path.GetRelativePath(instanceDir, path); var segments = relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); if (segments.Any(s => AlwaysExcludedDirectories.Contains(s, StringComparer.OrdinalIgnoreCase))) continue; var name = Path.GetFileName(path); // Die Datenbank wird gesondert behandelt; die WAL-Begleitdateien gehören // nicht ins Archiv, weil VACUUM INTO sie bereits einarbeitet. if (name is DatabaseName or DatabaseName + "-wal" or DatabaseName + "-shm") continue; if (AlwaysExcludedExtensions.Contains(Path.GetExtension(name), StringComparer.OrdinalIgnoreCase)) continue; if (name.Contains(".tmp_", StringComparison.OrdinalIgnoreCase)) continue; if (!options.IncludeLogs && segments.Any(s => s.Equals("Logs", StringComparison.OrdinalIgnoreCase))) continue; if (!options.IncludeChatHistory && name is "ChatHistory.json" or "ChatContext.json") continue; yield return path; } } private static bool IsConfigFile(string relativePath) { var name = Path.GetFileName(relativePath); return name.Equals("InstanceSettings.json", StringComparison.OrdinalIgnoreCase) || name.Equals("AgentSettings.json", StringComparison.OrdinalIgnoreCase); } private static (string Id, string Name) ReadInstanceIdentity(string instanceDir) { var path = Path.Combine(instanceDir, "InstanceSettings.json"); if (!File.Exists(path)) return ("", Path.GetFileName(instanceDir.TrimEnd(Path.DirectorySeparatorChar))); try { using var doc = JsonDocument.Parse(AtomicFile.ReadAllText(path)); var root = doc.RootElement; return ( root.TryGetProperty("instanceId", out var id) ? id.GetString() ?? "" : "", root.TryGetProperty("instanceName", out var n) ? n.GetString() ?? "" : ""); } catch (JsonException) { return ("", ""); } } private static async Task DescribeAsync(string root, string file, CancellationToken ct) { var bytes = await File.ReadAllBytesAsync(file, ct); return new BackupEntry( Path.GetRelativePath(root, file).Replace(Path.DirectorySeparatorChar, '/'), bytes.LongLength, ComputeSha256(bytes)); } private static async Task ReadEntryAsync(ZipArchiveEntry entry, CancellationToken ct) { await using var stream = entry.Open(); using var buffer = new MemoryStream(); await stream.CopyToAsync(buffer, ct); return buffer.ToArray(); } private static string ComputeSha256(byte[] content) => Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant(); /// /// Verhindert, dass ein präpariertes Archiv über Einträge wie ..\..\evil /// außerhalb des Zielverzeichnisses schreibt. /// private static string ResolveInside(string targetDir, string relative) { var root = Path.GetFullPath(targetDir); var full = Path.GetFullPath(Path.Combine(root, relative)); var rootWithSeparator = root.EndsWith(Path.DirectorySeparatorChar) ? root : root + Path.DirectorySeparatorChar; if (!full.StartsWith(rootWithSeparator, StringComparison.OrdinalIgnoreCase)) throw new BackupException($"Eintrag '{relative}' zeigt aus dem Zielverzeichnis heraus."); return full; } private static void TryDeleteDirectory(string path) { try { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); } catch { /* Aufräumen darf den eigentlichen Vorgang nicht stören */ } } }