Backup und Wiederherstellung einer Instanz

Gesichert wird alles, was sich nicht wiederherstellen laesst: Identity und Soul,
Konfigurationen, Arbeitsverzeichnisse, Chatverlaeufe und vor allem die Datenbank
mit dem Langzeitgedaechtnis. Protokolle bleiben standardmaessig aussen vor.

Zwei Punkte waren dabei nicht offensichtlich.

Die Datenbank darf nicht einfach kopiert werden. Mit WAL stehen die juengsten
Aenderungen in der Begleitdatei, nicht in der Hauptdatei — eine reine Kopie waere
veraltet oder in sich widersprueckhlich. VACUUM INTO erzeugt dagegen im laufenden
Betrieb eine geschlossene, konsistente Kopie; die WAL-Begleitdateien gehoeren dann
nicht mehr ins Archiv.

Zugangsdaten sind seit S7 mit DPAPI geschuetzt und damit an Benutzer und Rechner
gebunden. In einer Sicherung waeren sie genau dann unbrauchbar, wenn man sie
braucht — bei einem defekten Rechner. PassphraseProtector schluesselt sie deshalb
beim Sichern auf eine Passphrase um (PBKDF2 mit 210.000 Runden, AES-GCM) und beim
Wiederherstellen zurueck auf DPAPI des Zielrechners. Alternativ laesst sich eine
Sicherung ganz ohne Zugangsdaten erstellen; sie ist dann gefahrlos ablegbar, die
Wiederherstellung aber unvollstaendig.

Das Umschluesseln arbeitet auf dem JSON-Baum statt ueber die typisierten
Konfigurationsklassen. Beim Deserialisieren und erneuten Serialisieren gingen
unbekannte Felder verloren — eine Sicherung darf aber nichts wegwerfen, nur weil
eine Programmfassung ein Feld nicht kennt. Ein Test haelt das fest.

Weitere Eigenschaften: Manifest mit Pruefsummen je Datei, sodass ein veraendertes
Archiv auffaellt, bevor etwas ueberschrieben wird. Vorschau-Modus. Vorhandene
Dateien werden ohne ausdrueckliche Zustimmung nicht ueberschrieben. Eintraege, die
aus dem Zielverzeichnis herauszeigen, werden abgelehnt.

Der wichtigste Test ist der vollstaendige Rundlauf: Instanz aufbauen, sichern, in
ein leeres Verzeichnis wiederherstellen und pruefen, dass Persoenlichkeit,
Arbeitsstand, Gedaechtnis und nutzbare Zugangsdaten zurueck sind. Ein ungepruefte
Wiederherstellung ist kein Backup, sondern eine Vermutung.

385 Tests gruen (237 Core, 148 Tools).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-29 15:00:42 +02:00
co-authored by Claude Opus 4.8
parent 1157d28588
commit b55512e734
5 changed files with 1121 additions and 0 deletions
@@ -0,0 +1,92 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.Core.Backup;
/// <summary>Wie mit Zugangsdaten in der Sicherung verfahren wird.</summary>
public enum SecretMode
{
/// <summary>
/// Zugangsdaten werden entfernt. Die Sicherung ist gefahrlos ablegbar, die
/// Wiederherstellung aber unvollständig — Schlüssel und Passwörter müssen
/// danach neu eingetragen werden.
/// </summary>
Exclude,
/// <summary>
/// Zugangsdaten werden mit einer Passphrase geschützt. Nur so überstehen sie
/// einen Rechner- oder Benutzerwechsel.
/// </summary>
Passphrase
}
public sealed record BackupOptions
{
public SecretMode Secrets { get; init; } = SecretMode.Exclude;
/// <summary>Erforderlich bei <see cref="SecretMode.Passphrase"/>.</summary>
public string? Passphrase { get; init; }
/// <summary>Protokolle sind groß und selten nötig.</summary>
public bool IncludeLogs { get; init; }
/// <summary>Chatverläufe gehören zum Arbeitsstand, können aber umfangreich sein.</summary>
public bool IncludeChatHistory { get; init; } = true;
}
public sealed record RestoreOptions
{
public string? Passphrase { get; init; }
/// <summary>Nur prüfen und berichten, nichts schreiben.</summary>
public bool DryRun { get; init; }
/// <summary>Vorhandene Dateien im Ziel überschreiben.</summary>
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
{
/// <summary>Erlaubt es späteren Fassungen, ältere Sicherungen zu erkennen.</summary>
[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);
/// <summary>Wie viele Zugangsdaten enthalten bzw. entfernt wurden.</summary>
[JsonPropertyName("secretCount")]
public int SecretCount { get; init; }
[JsonPropertyName("files")]
public List<BackupEntry> 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<string> Written,
IReadOnlyList<string> Skipped,
IReadOnlyList<string> WouldOverwrite)
{
public bool HasConflicts => WouldOverwrite.Count > 0;
}
public sealed class BackupException(string message, Exception? inner = null)
: Exception(message, inner);
@@ -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;
/// <summary>
/// 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. <c>VACUUM INTO</c> 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.
/// </summary>
public sealed class BackupService
{
private const string ManifestName = "manifest.json";
private const string DatabaseName = "state.db";
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
/// <summary>Wird nie mitgesichert — entweder erzeugt oder unerwünscht.</summary>
private static readonly string[] AlwaysExcludedDirectories = ["bin", "obj", ".vs"];
private static readonly string[] AlwaysExcludedExtensions = [".tmp", ".bak"];
// ═══════════════════════════════════════════════════════════
// Sichern
// ═══════════════════════════════════════════════════════════
public async Task<BackupResult> 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<BackupEntry>();
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);
}
}
/// <summary>
/// Erzeugt eine konsistente Kopie der Datenbank, auch während sie in Benutzung ist.
/// </summary>
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
// ═══════════════════════════════════════════════════════════
/// <summary>Liest das Manifest, ohne etwas auszupacken.</summary>
public async Task<BackupManifest> 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<BackupManifest>(json)
?? throw new BackupException("Das Manifest ist unlesbar.");
}
// ═══════════════════════════════════════════════════════════
// Wiederherstellen
// ═══════════════════════════════════════════════════════════
public async Task<RestoreResult> 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<string>();
var skipped = new List<string>();
var wouldOverwrite = new List<string>();
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<string> 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<BackupEntry> 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<byte[]> 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();
/// <summary>
/// Verhindert, dass ein präpariertes Archiv über Einträge wie <c>..\..\evil</c>
/// außerhalb des Zielverzeichnisses schreibt.
/// </summary>
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 */ }
}
}
@@ -0,0 +1,85 @@
using System.Text.Json;
using System.Text.Json.Nodes;
namespace ClawdDotNet.Core.Security;
/// <summary>
/// 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 <see cref="ConfigSecrets.IsSecretKey"/> —
/// dieselbe Liste wie im laufenden Betrieb.
/// </summary>
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
};
/// <summary>
/// Wendet <paramref name="transform"/> auf alle Werte an, deren Feldname als
/// Zugangsdatum gilt. Gibt das neue JSON zurück.
/// </summary>
public static string Rewrite(string json, Func<string, string?> transform)
{
var root = JsonNode.Parse(json, NodeOptions, DocumentOptions);
if (root is null)
return json;
Walk(root, transform);
return root.ToJsonString(WriteOptions);
}
/// <summary>Zählt, wie viele Zugangsdaten in der Datei stecken — für das Manifest.</summary>
public static int CountSecrets(string json)
{
var count = 0;
Rewrite(json, value => { count++; return value; });
return count;
}
private static void Walk(JsonNode node, Func<string, string?> 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<string>(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;
}
}
}
@@ -0,0 +1,120 @@
using System.Security.Cryptography;
using System.Text;
namespace ClawdDotNet.Core.Security;
/// <summary>
/// Verschlüsselt Werte mit einer Passphrase statt mit DPAPI.
///
/// Hintergrund: <see cref="SecretProtector"/> 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:
/// <code>pbe:v1:&lt;salt&gt;:&lt;nonce&gt;:&lt;tag&gt;:&lt;ciphertext&gt;</code>
/// Alle Teile Base64. Jeder Wert bekommt ein eigenes Salz und einen eigenen Nonce —
/// gleiche Klartexte ergeben dadurch unterschiedliche Chiffrate.
/// </summary>
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
/// <summary>
/// Rundenzahl der Schlüsselableitung. Hoch genug, um Rateversuche teuer zu machen,
/// niedrig genug für ein Backup mit vielen Einzelwerten.
/// </summary>
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);
}
@@ -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;
/// <summary>
/// Ein ungeprüftes Wiederherstellen ist kein Backup, sondern eine Vermutung.
/// Deshalb liegt der Schwerpunkt hier auf dem vollständigen Rundlauf.
/// </summary>
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<BackupException>(
() => _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<SecretProtectionException>(
() => _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<BackupException>(
() => _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<BackupException>(() => _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<BackupException>(
() => _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<BackupException>(() => _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();
}
}