using System.Text.Json; namespace CodexProviderSync.Core; public sealed class BackupService { private readonly SessionRolloutService _sessionRolloutService; private readonly SqliteStateService _sqliteStateService; public BackupService(SessionRolloutService sessionRolloutService, SqliteStateService sqliteStateService) { _sessionRolloutService = sessionRolloutService; _sqliteStateService = sqliteStateService; } public async Task CreateBackupAsync( string codexHome, string targetProvider, IReadOnlyList sessionChanges, string configPath, string? configBackupText = null) { string backupRoot = AppConstants.DefaultBackupRoot(codexHome); string backupDir = Path.Combine(backupRoot, DateTimeOffset.UtcNow.ToString("yyyyMMdd'T'HHmmssfff'Z'")); string dbDir = Path.Combine(backupDir, "db"); Directory.CreateDirectory(dbDir); List copiedDbFiles = []; StateDbLocation? stateDb = _sqliteStateService.DetectStateDb(codexHome); if (stateDb is not null) { foreach (string suffix in new[] { string.Empty, "-shm", "-wal" }) { string relativePath = DbBackupRelativePath(codexHome, stateDb.Path, suffix); if (await CopyIfPresentAsync(stateDb.Path + suffix, Path.Combine(dbDir, relativePath), overwrite: false)) { copiedDbFiles.Add(relativePath); } } } string configBackupPath = Path.Combine(backupDir, "config.toml"); if (configBackupText is not null) { await File.WriteAllTextAsync(configBackupPath, configBackupText); } else { await CopyIfPresentAsync(configPath, configBackupPath, overwrite: false); } await CopyIfPresentAsync( Path.Combine(codexHome, AppConstants.GlobalStateFileBasename), Path.Combine(backupDir, AppConstants.GlobalStateFileBasename), overwrite: false); await CopyIfPresentAsync( Path.Combine(codexHome, AppConstants.GlobalStateBackupFileBasename), Path.Combine(backupDir, AppConstants.GlobalStateBackupFileBasename), overwrite: false); DateTimeOffset createdAt = DateTimeOffset.UtcNow; SessionBackupManifest sessionManifest = new() { Version = 1, Namespace = AppConstants.BackupNamespace, CodexHome = codexHome, TargetProvider = targetProvider, CreatedAt = createdAt, Files = sessionChanges.Select(static change => new SessionBackupManifestEntry { Path = change.Path, OriginalFirstLine = change.OriginalFirstLine, OriginalSeparator = change.OriginalSeparator, OriginalLastWriteTimeUtcTicks = change.OriginalLastWriteTimeUtcTicks }).ToList() }; await File.WriteAllTextAsync( Path.Combine(backupDir, "session-meta-backup.json"), JsonSerializer.Serialize(sessionManifest, JsonOptions())); BackupMetadataFile metadata = new() { Version = 1, Namespace = AppConstants.BackupNamespace, CodexHome = codexHome, TargetProvider = targetProvider, CreatedAt = createdAt, DbFiles = copiedDbFiles, ChangedSessionFiles = sessionChanges.Count }; await File.WriteAllTextAsync( Path.Combine(backupDir, "metadata.json"), JsonSerializer.Serialize(metadata, JsonOptions())); return backupDir; } public async Task RestoreBackupAsync( string backupDir, string codexHome, RestoreBackupOptions? options = null) { options ??= new RestoreBackupOptions(); string normalizedBackupDir = Path.GetFullPath(backupDir); BackupMetadataFile metadata = JsonSerializer.Deserialize( await File.ReadAllTextAsync(Path.Combine(normalizedBackupDir, "metadata.json")), JsonOptions()) ?? throw new InvalidOperationException($"Backup metadata is invalid: {backupDir}"); if (!string.Equals(metadata.CodexHome, codexHome, StringComparison.Ordinal)) { throw new InvalidOperationException($"Backup was created for {metadata.CodexHome}, not {codexHome}."); } SessionBackupManifest? sessionManifest = null; if (options.RestoreSessions) { sessionManifest = JsonSerializer.Deserialize( await File.ReadAllTextAsync(Path.Combine(normalizedBackupDir, "session-meta-backup.json")), JsonOptions()) ?? throw new InvalidOperationException($"Session backup manifest is invalid: {backupDir}"); await _sessionRolloutService.AssertSessionFilesWritableAsync( sessionManifest.Files.Select(static entry => entry.Path)); } if (options.RestoreConfig) { await CopyIfPresentAsync( Path.Combine(normalizedBackupDir, "config.toml"), Path.Combine(codexHome, "config.toml"), overwrite: true); await CopyIfPresentAsync( Path.Combine(normalizedBackupDir, AppConstants.GlobalStateFileBasename), Path.Combine(codexHome, AppConstants.GlobalStateFileBasename), overwrite: true); await CopyIfPresentAsync( Path.Combine(normalizedBackupDir, AppConstants.GlobalStateBackupFileBasename), Path.Combine(codexHome, AppConstants.GlobalStateBackupFileBasename), overwrite: true); } if (options.RestoreDatabase) { await _sqliteStateService.AssertSqliteWritableAsync(codexHome); string dbDir = Path.Combine(normalizedBackupDir, "db"); HashSet backedUpFiles = new(metadata.DbFiles, StringComparer.Ordinal); foreach (string baseFile in metadata.DbFiles.Where(static fileName => Path.GetFileName(fileName) == AppConstants.DbFileBasename)) { string basePath = RestoreDbTargetPath(codexHome, baseFile); foreach (string suffix in new[] { "-shm", "-wal" }) { string sidecarFile = baseFile + suffix; string sidecarPath = basePath + suffix; if (!backedUpFiles.Contains(sidecarFile) && File.Exists(sidecarPath)) { File.Delete(sidecarPath); } } } foreach (string fileName in metadata.DbFiles) { await CopyIfPresentAsync(Path.Combine(dbDir, fileName), RestoreDbTargetPath(codexHome, fileName), overwrite: true); } } if (options.RestoreSessions && sessionManifest is not null) { await _sessionRolloutService.RestoreSessionChangesAsync(sessionManifest.Files); } return new RestoreResult { CodexHome = codexHome, BackupDir = normalizedBackupDir, TargetProvider = metadata.TargetProvider, CreatedAt = metadata.CreatedAt, ChangedSessionFiles = metadata.ChangedSessionFiles }; } public async Task UpdateSessionBackupManifestAsync(string backupDir, IReadOnlyList sessionChanges) { string normalizedBackupDir = Path.GetFullPath(backupDir); string manifestPath = Path.Combine(normalizedBackupDir, "session-meta-backup.json"); string metadataPath = Path.Combine(normalizedBackupDir, "metadata.json"); SessionBackupManifest sessionManifest = JsonSerializer.Deserialize( await File.ReadAllTextAsync(manifestPath), JsonOptions()) ?? throw new InvalidOperationException($"Session backup manifest is invalid: {backupDir}"); BackupMetadataFile metadata = JsonSerializer.Deserialize( await File.ReadAllTextAsync(metadataPath), JsonOptions()) ?? throw new InvalidOperationException($"Backup metadata is invalid: {backupDir}"); sessionManifest = new SessionBackupManifest { Version = sessionManifest.Version, Namespace = sessionManifest.Namespace, CodexHome = sessionManifest.CodexHome, TargetProvider = sessionManifest.TargetProvider, CreatedAt = sessionManifest.CreatedAt, Files = sessionChanges.Select(static change => new SessionBackupManifestEntry { Path = change.Path, OriginalFirstLine = change.OriginalFirstLine, OriginalSeparator = change.OriginalSeparator, OriginalLastWriteTimeUtcTicks = change.OriginalLastWriteTimeUtcTicks }).ToList() }; metadata = new BackupMetadataFile { Version = metadata.Version, Namespace = metadata.Namespace, CodexHome = metadata.CodexHome, TargetProvider = metadata.TargetProvider, CreatedAt = metadata.CreatedAt, DbFiles = metadata.DbFiles, ChangedSessionFiles = sessionChanges.Count }; await File.WriteAllTextAsync(manifestPath, JsonSerializer.Serialize(sessionManifest, JsonOptions())); await File.WriteAllTextAsync(metadataPath, JsonSerializer.Serialize(metadata, JsonOptions())); } public async Task RestoreGlobalStateFilesAsync(string backupDir, string codexHome) { string normalizedBackupDir = Path.GetFullPath(backupDir); await CopyIfPresentAsync( Path.Combine(normalizedBackupDir, AppConstants.GlobalStateFileBasename), Path.Combine(codexHome, AppConstants.GlobalStateFileBasename), overwrite: true); await CopyIfPresentAsync( Path.Combine(normalizedBackupDir, AppConstants.GlobalStateBackupFileBasename), Path.Combine(codexHome, AppConstants.GlobalStateBackupFileBasename), overwrite: true); } public Task GetBackupSummaryAsync(string codexHome) { string backupRoot = AppConstants.DefaultBackupRoot(codexHome); return Task.Run(() => { if (!Directory.Exists(backupRoot)) { return new BackupSummary { Count = 0, TotalBytes = 0 }; } List entries = GetManagedBackupDirectories(backupRoot); long totalBytes = entries.Sum(static entry => GetDirectorySize(entry.FullName)); return new BackupSummary { Count = entries.Count, TotalBytes = totalBytes }; }); } public Task PruneBackupsAsync(string codexHome, int keepCount = AppConstants.DefaultBackupRetentionCount) { if (keepCount < 0) { throw new ArgumentOutOfRangeException(nameof(keepCount), keepCount, "keepCount must be 0 or greater."); } string backupRoot = AppConstants.DefaultBackupRoot(codexHome); return Task.Run(() => { if (!Directory.Exists(backupRoot)) { return new BackupPruneResult { BackupRoot = backupRoot, DeletedCount = 0, RemainingCount = 0, FreedBytes = 0 }; } List entries = GetManagedBackupDirectories(backupRoot); List toDelete = entries.Skip(keepCount).ToList(); long freedBytes = 0; foreach (DirectoryInfo entry in toDelete) { freedBytes += GetDirectorySize(entry.FullName); entry.Delete(recursive: true); } return new BackupPruneResult { BackupRoot = backupRoot, DeletedCount = toDelete.Count, RemainingCount = entries.Count - toDelete.Count, FreedBytes = freedBytes }; }); } private static async Task CopyIfPresentAsync(string sourcePath, string destinationPath, bool overwrite) { if (!File.Exists(sourcePath)) { return false; } Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); File.Copy(sourcePath, destinationPath, overwrite); await Task.CompletedTask; return true; } private static string DbBackupRelativePath(string codexHome, string dbPath, string suffix) { string relativePath = Path.GetRelativePath(codexHome, dbPath + suffix); return !relativePath.StartsWith("..", StringComparison.Ordinal) && !Path.IsPathRooted(relativePath) ? relativePath : AppConstants.DbFileBasename + suffix; } private static string RestoreDbTargetPath(string codexHome, string relativePath) { if (Path.IsPathRooted(relativePath) || relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Contains("..", StringComparer.Ordinal)) { throw new InvalidOperationException($"Invalid database backup path: {relativePath}"); } return Path.Combine(codexHome, relativePath); } private static JsonSerializerOptions JsonOptions() { return new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true }; } private static long GetDirectorySize(string directoryPath) { if (!Directory.Exists(directoryPath)) { return 0; } return Directory .EnumerateFiles(directoryPath, "*", SearchOption.AllDirectories) .Sum(static filePath => new FileInfo(filePath).Length); } private static List GetManagedBackupDirectories(string backupRoot) { return new DirectoryInfo(backupRoot) .EnumerateDirectories() .Where(static entry => IsManagedBackupDirectory(entry.FullName)) .OrderByDescending(static entry => entry.Name, StringComparer.Ordinal) .ToList(); } private static bool IsManagedBackupDirectory(string backupDirectoryPath) { string metadataPath = Path.Combine(backupDirectoryPath, "metadata.json"); if (!File.Exists(metadataPath)) { return false; } try { BackupMetadataFile? metadata = JsonSerializer.Deserialize( File.ReadAllText(metadataPath), JsonOptions()); return string.Equals(metadata?.Namespace, AppConstants.BackupNamespace, StringComparison.Ordinal); } catch { return false; } } }