diff --git a/src/ClawdDotNet.Core/Backup/BackupModels.cs b/src/ClawdDotNet.Core/Backup/BackupModels.cs new file mode 100644 index 0000000..7bc5479 --- /dev/null +++ b/src/ClawdDotNet.Core/Backup/BackupModels.cs @@ -0,0 +1,92 @@ +using System.Text.Json.Serialization; + +namespace ClawdDotNet.Core.Backup; + +/// Wie mit Zugangsdaten in der Sicherung verfahren wird. +public enum SecretMode +{ + /// + /// Zugangsdaten werden entfernt. Die Sicherung ist gefahrlos ablegbar, die + /// Wiederherstellung aber unvollständig — Schlüssel und Passwörter müssen + /// danach neu eingetragen werden. + /// + Exclude, + + /// + /// Zugangsdaten werden mit einer Passphrase geschützt. Nur so überstehen sie + /// einen Rechner- oder Benutzerwechsel. + /// + Passphrase +} + +public sealed record BackupOptions +{ + public SecretMode Secrets { get; init; } = SecretMode.Exclude; + + /// Erforderlich bei . + public string? Passphrase { get; init; } + + /// Protokolle sind groß und selten nötig. + public bool IncludeLogs { get; init; } + + /// Chatverläufe gehören zum Arbeitsstand, können aber umfangreich sein. + public bool IncludeChatHistory { get; init; } = true; +} + +public sealed record RestoreOptions +{ + public string? Passphrase { get; init; } + + /// Nur prüfen und berichten, nichts schreiben. + public bool DryRun { get; init; } + + /// Vorhandene Dateien im Ziel überschreiben. + public bool Overwrite { get; init; } +} + +public sealed record BackupEntry( + [property: JsonPropertyName("path")] string Path, + [property: JsonPropertyName("size")] long Size, + [property: JsonPropertyName("sha256")] string Sha256); + +public sealed record BackupManifest +{ + /// Erlaubt es späteren Fassungen, ältere Sicherungen zu erkennen. + [JsonPropertyName("formatVersion")] + public int FormatVersion { get; init; } = 1; + + [JsonPropertyName("createdAt")] + public DateTime CreatedAt { get; init; } + + [JsonPropertyName("instanceId")] + public string InstanceId { get; init; } = ""; + + [JsonPropertyName("instanceName")] + public string InstanceName { get; init; } = ""; + + [JsonPropertyName("secrets")] + public string Secrets { get; init; } = nameof(SecretMode.Exclude); + + /// Wie viele Zugangsdaten enthalten bzw. entfernt wurden. + [JsonPropertyName("secretCount")] + public int SecretCount { get; init; } + + [JsonPropertyName("files")] + public List Files { get; init; } = new(); + + [JsonIgnore] + public bool HasSecrets => Secrets == nameof(SecretMode.Passphrase); +} + +public sealed record BackupResult(string ZipPath, BackupManifest Manifest, long SizeBytes); + +public sealed record RestoreResult( + IReadOnlyList Written, + IReadOnlyList Skipped, + IReadOnlyList WouldOverwrite) +{ + public bool HasConflicts => WouldOverwrite.Count > 0; +} + +public sealed class BackupException(string message, Exception? inner = null) + : Exception(message, inner); diff --git a/src/ClawdDotNet.Core/Backup/BackupService.cs b/src/ClawdDotNet.Core/Backup/BackupService.cs new file mode 100644 index 0000000..9e3f774 --- /dev/null +++ b/src/ClawdDotNet.Core/Backup/BackupService.cs @@ -0,0 +1,395 @@ +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 */ } + } +} diff --git a/src/ClawdDotNet.Core/Security/JsonSecretRewriter.cs b/src/ClawdDotNet.Core/Security/JsonSecretRewriter.cs new file mode 100644 index 0000000..bb49c70 --- /dev/null +++ b/src/ClawdDotNet.Core/Security/JsonSecretRewriter.cs @@ -0,0 +1,85 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ClawdDotNet.Core.Security; + +/// +/// Schreibt Zugangsdaten in einer JSON-Datei um, ohne sonst etwas zu verändern. +/// +/// Bewusst auf dem JSON-Baum statt über die typisierten Konfigurationsklassen: +/// Beim Deserialisieren und erneuten Serialisieren gingen unbekannte Felder verloren. +/// Eine Sicherung darf aber nichts wegwerfen, nur weil eine ältere Programmfassung +/// ein Feld nicht kennt. +/// +/// Welche Felder betroffen sind, entscheidet — +/// dieselbe Liste wie im laufenden Betrieb. +/// +public static class JsonSecretRewriter +{ + private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true }; + + private static readonly JsonNodeOptions NodeOptions = new() { PropertyNameCaseInsensitive = false }; + + private static readonly JsonDocumentOptions DocumentOptions = new() + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }; + + /// + /// Wendet auf alle Werte an, deren Feldname als + /// Zugangsdatum gilt. Gibt das neue JSON zurück. + /// + public static string Rewrite(string json, Func transform) + { + var root = JsonNode.Parse(json, NodeOptions, DocumentOptions); + if (root is null) + return json; + + Walk(root, transform); + + return root.ToJsonString(WriteOptions); + } + + /// Zählt, wie viele Zugangsdaten in der Datei stecken — für das Manifest. + public static int CountSecrets(string json) + { + var count = 0; + Rewrite(json, value => { count++; return value; }); + return count; + } + + private static void Walk(JsonNode node, Func transform) + { + switch (node) + { + case JsonObject obj: + // Über eine Kopie laufen, weil Werte im Zuge ersetzt werden. + foreach (var (name, child) in obj.ToList()) + { + if (child is null) + continue; + + if (ConfigSecrets.IsSecretKey(name) && + child is JsonValue value && + value.TryGetValue(out var text) && + !string.IsNullOrEmpty(text)) + { + obj[name] = transform(text); + continue; + } + + Walk(child, transform); + } + break; + + case JsonArray array: + foreach (var child in array) + { + if (child is not null) + Walk(child, transform); + } + break; + } + } +} diff --git a/src/ClawdDotNet.Core/Security/PassphraseProtector.cs b/src/ClawdDotNet.Core/Security/PassphraseProtector.cs new file mode 100644 index 0000000..0614b96 --- /dev/null +++ b/src/ClawdDotNet.Core/Security/PassphraseProtector.cs @@ -0,0 +1,120 @@ +using System.Security.Cryptography; +using System.Text; + +namespace ClawdDotNet.Core.Security; + +/// +/// Verschlüsselt Werte mit einer Passphrase statt mit DPAPI. +/// +/// Hintergrund: nutzt DPAPI im Benutzerkontext — +/// entschlüsseln kann nur derselbe Windows-Benutzer auf demselben Rechner. Für den +/// laufenden Betrieb ist das richtig, für ein Backup jedoch untauglich: Ein Backup +/// wird gerade dann gebraucht, wenn der Rechner defekt ist. Die Zugangsdaten darin +/// wären auf dem Ersatzrechner nicht lesbar. +/// +/// Deshalb werden Zugangsdaten beim Sichern auf eine Passphrase umgeschlüsselt und +/// beim Wiederherstellen zurück auf DPAPI. +/// +/// Aufbau eines geschützten Werts: +/// pbe:v1:<salt>:<nonce>:<tag>:<ciphertext> +/// Alle Teile Base64. Jeder Wert bekommt ein eigenes Salz und einen eigenen Nonce — +/// gleiche Klartexte ergeben dadurch unterschiedliche Chiffrate. +/// +public static class PassphraseProtector +{ + private const string Prefix = "pbe:v1:"; + + private const int SaltBytes = 16; + private const int NonceBytes = 12; // AES-GCM Standard + private const int TagBytes = 16; + private const int KeyBytes = 32; // AES-256 + + /// + /// Rundenzahl der Schlüsselableitung. Hoch genug, um Rateversuche teuer zu machen, + /// niedrig genug für ein Backup mit vielen Einzelwerten. + /// + private const int Iterations = 210_000; + + public static bool IsProtected(string? value) + => value?.StartsWith(Prefix, StringComparison.Ordinal) == true; + + public static string? Protect(string? plainText, string passphrase) + { + if (string.IsNullOrEmpty(plainText)) + return plainText; + + if (string.IsNullOrEmpty(passphrase)) + throw new ArgumentException("Passphrase darf nicht leer sein.", nameof(passphrase)); + + var salt = RandomNumberGenerator.GetBytes(SaltBytes); + var nonce = RandomNumberGenerator.GetBytes(NonceBytes); + var key = DeriveKey(passphrase, salt); + + var plain = Encoding.UTF8.GetBytes(plainText); + var cipher = new byte[plain.Length]; + var tag = new byte[TagBytes]; + + using (var aes = new AesGcm(key, TagBytes)) + aes.Encrypt(nonce, plain, cipher, tag); + + CryptographicOperations.ZeroMemory(key); + + return Prefix + + Convert.ToBase64String(salt) + ":" + + Convert.ToBase64String(nonce) + ":" + + Convert.ToBase64String(tag) + ":" + + Convert.ToBase64String(cipher); + } + + public static string? Unprotect(string? value, string passphrase) + { + if (string.IsNullOrEmpty(value) || !IsProtected(value)) + return value; + + var parts = value[Prefix.Length..].Split(':'); + if (parts.Length != 4) + throw new SecretProtectionException( + "Der geschützte Wert ist unvollständig oder beschädigt.", + new FormatException("Erwartet werden vier Abschnitte.")); + + byte[] salt, nonce, tag, cipher; + try + { + salt = Convert.FromBase64String(parts[0]); + nonce = Convert.FromBase64String(parts[1]); + tag = Convert.FromBase64String(parts[2]); + cipher = Convert.FromBase64String(parts[3]); + } + catch (FormatException ex) + { + throw new SecretProtectionException("Der geschützte Wert ist beschädigt.", ex); + } + + var key = DeriveKey(passphrase, salt); + var plain = new byte[cipher.Length]; + + try + { + using var aes = new AesGcm(key, TagBytes); + aes.Decrypt(nonce, cipher, tag, plain); + } + catch (CryptographicException ex) + { + // AES-GCM erkennt sowohl eine falsche Passphrase als auch nachträgliche + // Veränderung — beides landet hier. + throw new SecretProtectionException( + "Entschlüsselung fehlgeschlagen. Entweder ist die Passphrase falsch " + + "oder die Sicherung wurde verändert.", ex); + } + finally + { + CryptographicOperations.ZeroMemory(key); + } + + return Encoding.UTF8.GetString(plain); + } + + private static byte[] DeriveKey(string passphrase, byte[] salt) + => Rfc2898DeriveBytes.Pbkdf2( + Encoding.UTF8.GetBytes(passphrase), salt, Iterations, HashAlgorithmName.SHA256, KeyBytes); +} diff --git a/tests/ClawdDotNet.Core.Tests/Backup/BackupServiceTests.cs b/tests/ClawdDotNet.Core.Tests/Backup/BackupServiceTests.cs new file mode 100644 index 0000000..7d70689 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Backup/BackupServiceTests.cs @@ -0,0 +1,429 @@ +using System.IO.Compression; +using System.Text.Json; +using ClawdDotNet.Core.Backup; +using ClawdDotNet.Core.Memory; +using ClawdDotNet.Core.Security; +using ClawdDotNet.Core.Storage; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Backup; + +/// +/// Ein ungeprüftes Wiederherstellen ist kein Backup, sondern eine Vermutung. +/// Deshalb liegt der Schwerpunkt hier auf dem vollständigen Rundlauf. +/// +public sealed class BackupServiceTests : IDisposable +{ + private readonly string _root; + private readonly string _instanceDir; + private readonly BackupService _service = new(); + + private const string ApiKeyPlain = "sk-or-v1-streng-geheim-12345"; + private const string MailPasswordPlain = "mail-passwort-geheim"; + + public BackupServiceTests() + { + _root = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); + _instanceDir = Path.Combine(_root, "Instance-Test"); + Directory.CreateDirectory(_instanceDir); + } + + public void Dispose() + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + try { Directory.Delete(_root, recursive: true); } + catch { /* Aufräumen ist Nebensache */ } + } + + // ─── Aufbau einer realistischen Instanz ─── + + private async Task BuildInstanceAsync() + { + // Instanzkonfiguration — Zugangsdaten liegen DPAPI-geschützt wie im Betrieb. + var instanceSettings = $$""" + { + "instanceId": "test-01", + "instanceName": "Testinstanz", + "openRouterApiKey": "{{SecretProtector.Protect(ApiKeyPlain)}}", + "webServerPort": 8080, + "einUnbekanntesFeld": "muss erhalten bleiben" + } + """; + AtomicFile.WriteAllText(Path.Combine(_instanceDir, "InstanceSettings.json"), instanceSettings); + + // Agent + var agentDir = Path.Combine(_instanceDir, "Agents", "Agent-Analyst"); + Directory.CreateDirectory(Path.Combine(agentDir, "Workspace")); + Directory.CreateDirectory(Path.Combine(agentDir, "Logs")); + + var agentSettings = $$""" + { + "agentId": "analyst", + "displayName": "Analyst", + "model": "anthropic/claude-sonnet-4-5", + "tools": { + "Mail": { + "smtpHost": "smtp.example.com", + "password": "{{SecretProtector.Protect(MailPasswordPlain)}}" + } + } + } + """; + AtomicFile.WriteAllText(Path.Combine(agentDir, "AgentSettings.json"), agentSettings); + AtomicFile.WriteAllText(Path.Combine(agentDir, "Identity.md"), "# Identity\nDer Analyst."); + AtomicFile.WriteAllText(Path.Combine(agentDir, "Soul.md"), "# Soul\nGründlich und knapp."); + AtomicFile.WriteAllText(Path.Combine(agentDir, "ChatHistory.json"), """[{"role":"user"}]"""); + AtomicFile.WriteAllText(Path.Combine(agentDir, "Workspace", "bericht.md"), "# Bericht\nInhalt."); + AtomicFile.WriteAllText(Path.Combine(agentDir, "Logs", "lauf.log"), "viele Zeilen Protokoll"); + + // Datenbank mit einer Erinnerung — der wertvollste Teil. + var storage = new SqliteStorage(Path.Combine(_instanceDir, "state.db")); + var memory = new SqliteMemoryRepository(storage); + await memory.RememberAsync(new MemoryEntry + { + Scope = MemoryScope.Agent, + OwnerId = "analyst", + Subject = "NVDA", + Content = "Muss die Sicherung überstehen", + Key = "kernaussage", + CreatedBy = "analyst" + }, default); + + // Verbindungen schließen, damit VACUUM INTO auf eine ruhige Datei trifft. + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + } + + private string ZipPath => Path.Combine(_root, "sicherung.zip"); + private string RestoreDir => Path.Combine(_root, "wiederhergestellt"); + + // ═══════════════════════════════════════════════════════════ + // Der Rundlauf + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Der_vollstaendige_Rundlauf_stellt_alles_wieder_her() + { + await BuildInstanceAsync(); + + await _service.CreateAsync(_instanceDir, ZipPath, + new BackupOptions { Secrets = SecretMode.Passphrase, Passphrase = "geheim" }); + + var result = await _service.RestoreAsync(ZipPath, RestoreDir, + new RestoreOptions { Passphrase = "geheim" }); + + result.Written.ShouldNotBeEmpty(); + + // Persönlichkeit + File.ReadAllText(Path.Combine(RestoreDir, "Agents", "Agent-Analyst", "Identity.md")) + .ShouldContain("Der Analyst"); + File.ReadAllText(Path.Combine(RestoreDir, "Agents", "Agent-Analyst", "Soul.md")) + .ShouldContain("Gründlich"); + + // Arbeitsstand + File.ReadAllText(Path.Combine(RestoreDir, "Agents", "Agent-Analyst", "Workspace", "bericht.md")) + .ShouldContain("Inhalt"); + + // Datenbank samt Gedächtnis + File.Exists(Path.Combine(RestoreDir, "state.db")).ShouldBeTrue(); + + var memory = new SqliteMemoryRepository(new SqliteStorage(Path.Combine(RestoreDir, "state.db"))); + var recalled = await memory.RecallAsync( + new MemoryQuery { Scope = MemoryScope.Agent, OwnerId = "analyst" }, default); + + recalled.ShouldHaveSingleItem(); + recalled[0].Content.ShouldBe("Muss die Sicherung überstehen"); + } + + [Fact] + public async Task Zugangsdaten_sind_nach_dem_Wiederherstellen_wieder_nutzbar() + { + // Der eigentliche Zweck der Passphrase: Ein Backup wird gebraucht, wenn der + // Rechner defekt ist — DPAPI-geschützte Werte wären dann unlesbar. + await BuildInstanceAsync(); + + await _service.CreateAsync(_instanceDir, ZipPath, + new BackupOptions { Secrets = SecretMode.Passphrase, Passphrase = "geheim" }); + await _service.RestoreAsync(ZipPath, RestoreDir, new RestoreOptions { Passphrase = "geheim" }); + + var settings = JsonDocument.Parse( + File.ReadAllText(Path.Combine(RestoreDir, "InstanceSettings.json"))); + + var stored = settings.RootElement.GetProperty("openRouterApiKey").GetString(); + + SecretProtector.IsProtected(stored).ShouldBeTrue("wieder mit DPAPI geschützt"); + SecretProtector.Unprotect(stored).ShouldBe(ApiKeyPlain); + } + + [Fact] + public async Task Auch_verschachtelte_Zugangsdaten_in_Tool_Konfigurationen_kommen_zurueck() + { + await BuildInstanceAsync(); + + await _service.CreateAsync(_instanceDir, ZipPath, + new BackupOptions { Secrets = SecretMode.Passphrase, Passphrase = "geheim" }); + await _service.RestoreAsync(ZipPath, RestoreDir, new RestoreOptions { Passphrase = "geheim" }); + + var agent = JsonDocument.Parse(File.ReadAllText( + Path.Combine(RestoreDir, "Agents", "Agent-Analyst", "AgentSettings.json"))); + + var password = agent.RootElement + .GetProperty("tools").GetProperty("Mail").GetProperty("password").GetString(); + + SecretProtector.Unprotect(password).ShouldBe(MailPasswordPlain); + } + + [Fact] + public async Task Unbekannte_Felder_ueberstehen_den_Rundlauf() + { + // Eine Sicherung darf nichts wegwerfen, nur weil eine Programmfassung ein + // Feld nicht kennt. + await BuildInstanceAsync(); + + await _service.CreateAsync(_instanceDir, ZipPath, + new BackupOptions { Secrets = SecretMode.Passphrase, Passphrase = "geheim" }); + await _service.RestoreAsync(ZipPath, RestoreDir, new RestoreOptions { Passphrase = "geheim" }); + + File.ReadAllText(Path.Combine(RestoreDir, "InstanceSettings.json")) + .ShouldContain("einUnbekanntesFeld"); + } + + // ═══════════════════════════════════════════════════════════ + // Zugangsdaten weglassen + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Ohne_Zugangsdaten_enthaelt_das_Archiv_keine_Geheimnisse() + { + await BuildInstanceAsync(); + + await _service.CreateAsync(_instanceDir, ZipPath, + new BackupOptions { Secrets = SecretMode.Exclude }); + + // Das gesamte Archiv im Klartext durchsuchen. + var inhalt = ReadWholeArchive(ZipPath); + + inhalt.ShouldNotContain(ApiKeyPlain); + inhalt.ShouldNotContain(MailPasswordPlain); + } + + [Fact] + public async Task Eine_Sicherung_mit_Passphrase_zeigt_die_Geheimnisse_nicht_im_Klartext() + { + await BuildInstanceAsync(); + + await _service.CreateAsync(_instanceDir, ZipPath, + new BackupOptions { Secrets = SecretMode.Passphrase, Passphrase = "geheim" }); + + var inhalt = ReadWholeArchive(ZipPath); + + inhalt.ShouldNotContain(ApiKeyPlain); + inhalt.ShouldNotContain(MailPasswordPlain); + inhalt.ShouldContain("pbe:v1:"); + } + + // ═══════════════════════════════════════════════════════════ + // Manifest und Fehlerfälle + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Das_Manifest_beschreibt_die_Sicherung() + { + await BuildInstanceAsync(); + + var result = await _service.CreateAsync(_instanceDir, ZipPath, + new BackupOptions { Secrets = SecretMode.Passphrase, Passphrase = "geheim" }); + + var manifest = await _service.InspectAsync(ZipPath); + + manifest.InstanceId.ShouldBe("test-01"); + manifest.InstanceName.ShouldBe("Testinstanz"); + manifest.HasSecrets.ShouldBeTrue(); + manifest.SecretCount.ShouldBe(2, "API-Schlüssel und Mail-Passwort"); + manifest.Files.ShouldNotBeEmpty(); + result.SizeBytes.ShouldBeGreaterThan(0); + } + + [Fact] + public async Task Ohne_Passphrase_wird_nicht_wiederhergestellt() + { + await BuildInstanceAsync(); + await _service.CreateAsync(_instanceDir, ZipPath, + new BackupOptions { Secrets = SecretMode.Passphrase, Passphrase = "geheim" }); + + var ex = await Should.ThrowAsync( + () => _service.RestoreAsync(ZipPath, RestoreDir, new RestoreOptions())); + + ex.Message.ShouldContain("Passphrase"); + } + + [Fact] + public async Task Eine_falsche_Passphrase_wird_erkannt() + { + await BuildInstanceAsync(); + await _service.CreateAsync(_instanceDir, ZipPath, + new BackupOptions { Secrets = SecretMode.Passphrase, Passphrase = "richtig" }); + + await Should.ThrowAsync( + () => _service.RestoreAsync(ZipPath, RestoreDir, new RestoreOptions { Passphrase = "falsch" })); + } + + [Fact] + public async Task Ein_veraendertes_Archiv_faellt_auf() + { + await BuildInstanceAsync(); + await _service.CreateAsync(_instanceDir, ZipPath, new BackupOptions()); + + // Eine Datei im Archiv nachträglich verändern. + using (var archive = ZipFile.Open(ZipPath, ZipArchiveMode.Update)) + { + var entry = archive.Entries.First(e => e.FullName.EndsWith("Identity.md")); + using var stream = entry.Open(); + stream.SetLength(0); + using var writer = new StreamWriter(stream); + writer.Write("manipuliert"); + } + + var ex = await Should.ThrowAsync( + () => _service.RestoreAsync(ZipPath, RestoreDir, new RestoreOptions())); + + ex.Message.ShouldContain("Prüfsumme"); + } + + [Fact] + public async Task Ein_Archiv_ohne_Manifest_wird_abgelehnt() + { + var fremd = Path.Combine(_root, "fremd.zip"); + var quelle = Path.Combine(_root, "quelle"); + Directory.CreateDirectory(quelle); + File.WriteAllText(Path.Combine(quelle, "irgendwas.txt"), "Inhalt"); + ZipFile.CreateFromDirectory(quelle, fremd); + + var ex = await Should.ThrowAsync(() => _service.InspectAsync(fremd)); + + ex.Message.ShouldContain("Manifest"); + } + + // ═══════════════════════════════════════════════════════════ + // Vorschau und Überschreiben + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Die_Vorschau_schreibt_nichts() + { + await BuildInstanceAsync(); + await _service.CreateAsync(_instanceDir, ZipPath, new BackupOptions()); + + var result = await _service.RestoreAsync(ZipPath, RestoreDir, + new RestoreOptions { DryRun = true }); + + result.Written.ShouldBeEmpty(); + Directory.Exists(RestoreDir).ShouldBeFalse(); + } + + [Fact] + public async Task Vorhandene_Dateien_werden_ohne_Zustimmung_nicht_ueberschrieben() + { + await BuildInstanceAsync(); + await _service.CreateAsync(_instanceDir, ZipPath, new BackupOptions()); + + Directory.CreateDirectory(RestoreDir); + var vorhanden = Path.Combine(RestoreDir, "InstanceSettings.json"); + AtomicFile.WriteAllText(vorhanden, """{"wichtig":"nicht verlieren"}"""); + + var result = await _service.RestoreAsync(ZipPath, RestoreDir, new RestoreOptions()); + + result.HasConflicts.ShouldBeTrue(); + result.Skipped.ShouldContain("InstanceSettings.json"); + File.ReadAllText(vorhanden).ShouldContain("nicht verlieren"); + } + + [Fact] + public async Task Mit_Zustimmung_wird_ueberschrieben() + { + await BuildInstanceAsync(); + await _service.CreateAsync(_instanceDir, ZipPath, new BackupOptions()); + + Directory.CreateDirectory(RestoreDir); + var vorhanden = Path.Combine(RestoreDir, "InstanceSettings.json"); + AtomicFile.WriteAllText(vorhanden, """{"alt":true}"""); + + await _service.RestoreAsync(ZipPath, RestoreDir, new RestoreOptions { Overwrite = true }); + + File.ReadAllText(vorhanden).ShouldContain("Testinstanz"); + } + + // ═══════════════════════════════════════════════════════════ + // Umfang + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Protokolle_bleiben_standardmaessig_aussen_vor() + { + await BuildInstanceAsync(); + await _service.CreateAsync(_instanceDir, ZipPath, new BackupOptions()); + + var manifest = await _service.InspectAsync(ZipPath); + + manifest.Files.ShouldNotContain(f => f.Path.Contains("Logs/")); + } + + [Fact] + public async Task Protokolle_lassen_sich_einschliessen() + { + await BuildInstanceAsync(); + await _service.CreateAsync(_instanceDir, ZipPath, new BackupOptions { IncludeLogs = true }); + + var manifest = await _service.InspectAsync(ZipPath); + + manifest.Files.ShouldContain(f => f.Path.Contains("Logs/")); + } + + [Fact] + public async Task Die_WAL_Begleitdateien_landen_nicht_im_Archiv() + { + // VACUUM INTO arbeitet sie bereits ein — mitzusichern wäre irreführend. + await BuildInstanceAsync(); + await _service.CreateAsync(_instanceDir, ZipPath, new BackupOptions()); + + var manifest = await _service.InspectAsync(ZipPath); + + manifest.Files.ShouldNotContain(f => f.Path.EndsWith("-wal") || f.Path.EndsWith("-shm")); + manifest.Files.ShouldContain(f => f.Path == "state.db"); + } + + [Fact] + public async Task Ein_fehlendes_Instanzverzeichnis_wird_gemeldet() + { + var ex = await Should.ThrowAsync( + () => _service.CreateAsync(Path.Combine(_root, "gibtsnicht"), ZipPath, new BackupOptions())); + + ex.Message.ShouldContain("nicht gefunden"); + } + + [Fact] + public async Task Passphrase_Modus_ohne_Passphrase_wird_abgelehnt() + { + await BuildInstanceAsync(); + + await Should.ThrowAsync(() => _service.CreateAsync( + _instanceDir, ZipPath, new BackupOptions { Secrets = SecretMode.Passphrase })); + } + + // ─── Helfer ─── + + private static string ReadWholeArchive(string zipPath) + { + using var archive = ZipFile.OpenRead(zipPath); + var sb = new System.Text.StringBuilder(); + + foreach (var entry in archive.Entries) + { + using var stream = entry.Open(); + using var buffer = new MemoryStream(); + stream.CopyTo(buffer); + sb.Append(System.Text.Encoding.UTF8.GetString(buffer.ToArray())); + } + + return sb.ToString(); + } +}