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 */ }
}
}