using System.Security.AccessControl; using System.Security.Cryptography; using Agent.Windows.Security; using Agent.Windows.Network; namespace Agent.Windows.Recovery; public sealed record RecoveryResult(int JournalsRecovered, int ResourcesRecovered, IReadOnlyList Errors) { public bool IsClean => Errors.Count == 0; } public sealed class RecoveryManager(string journalDirectory, ISecurityAuditSink audit) { public RecoveryResult RecoverAll() { if (!Directory.Exists(journalDirectory)) return new(0, 0, []); int journals = 0; int resources = 0; var errors = new List(); foreach (string path in Directory.EnumerateFiles(journalDirectory, "*.journal.json").Order(StringComparer.Ordinal)) { journals++; try { TaskResourceJournal journal = TaskResourceJournal.Open(path); foreach (JournalEntry entry in journal.Entries.Reverse()) { if (entry.State == JournalEntryState.Cleaned) continue; if (entry.State == JournalEntryState.Applied) RecoverEntry(journal.TaskId, entry); CleanupPrepared(entry); journal.MarkCleaned(entry.Id); resources++; } journal.CompleteAndDelete(); audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "recovery.complete", "clean", new Dictionary())); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException) { errors.Add($"{path}: {ex.Message}"); } } return new(journals, resources, errors); } private static void RecoverEntry(Guid taskId, JournalEntry entry) { switch (entry.Kind) { case "file.replace": RecoverFile(entry); break; case "file.delete": RecoverDeletedFile(entry); break; case "acl": RecoverAcl(entry); break; case "wfp.dynamic": break; // BFE removes every object when the originating dynamic session ends. case "network.loopback_exemption": RecoverLoopback(entry); break; default: throw new InvalidDataException($"Unknown journal resource kind '{entry.Kind}' for task '{taskId}'."); } } private static void RecoverLoopback(JournalEntry entry) { if (!bool.Parse(entry.Data["originally_present"])) LoopbackExemption.Remove(new System.Security.Principal.SecurityIdentifier(entry.Target)); } private static void RecoverFile(JournalEntry entry) { string replacement = entry.Data["replacement_sha256"]; if (!File.Exists(entry.Target) || !Hash(entry.Target).Equals(replacement, StringComparison.Ordinal)) throw new InvalidOperationException($"File '{entry.Target}' changed after crash; automatic rollback refused."); if (bool.Parse(entry.Data["existed"])) { byte[] original = entry.Data["backup_path"].Length > 0 ? File.ReadAllBytes(entry.Data["backup_path"]) : Convert.FromBase64String(entry.Data["original_base64"]); if (!Convert.ToHexString(SHA256.HashData(original)).Equals(entry.Data["original_sha256"], StringComparison.Ordinal)) throw new InvalidDataException("Recovery backup hash mismatch."); string temporary = entry.Target + ".recovery.tmp"; File.WriteAllBytes(temporary, original); File.Move(temporary, entry.Target, overwrite: true); } else File.Delete(entry.Target); } private static void RecoverAcl(JournalEntry entry) { byte[] descriptor = Convert.FromBase64String(entry.Data["security_descriptor"]); FileSystemSecurity security = Directory.Exists(entry.Target) ? new DirectorySecurity() : new FileSecurity(); security.SetSecurityDescriptorBinaryForm(descriptor, AccessControlSections.Access); if (security is DirectorySecurity directory) FileSystemAclExtensions.SetAccessControl(new DirectoryInfo(entry.Target), directory); else FileSystemAclExtensions.SetAccessControl(new FileInfo(entry.Target), (FileSecurity)security); } private static void RecoverDeletedFile(JournalEntry entry) { if (File.Exists(entry.Target)) throw new InvalidOperationException($"Deleted path '{entry.Target}' was recreated after crash; automatic recovery refused."); byte[] original = entry.Data["backup_path"].Length > 0 ? File.ReadAllBytes(entry.Data["backup_path"]) : Convert.FromBase64String(entry.Data["original_base64"]); if (!Convert.ToHexString(SHA256.HashData(original)).Equals(entry.Data["original_sha256"], StringComparison.Ordinal)) throw new InvalidDataException("Delete recovery backup hash mismatch."); File.WriteAllBytes(entry.Target, original); } private static void CleanupPrepared(JournalEntry entry) { if (entry.Kind == "network.loopback_exemption" && !bool.Parse(entry.Data["originally_present"])) LoopbackExemption.Remove(new System.Security.Principal.SecurityIdentifier(entry.Target)); if (entry.Data.TryGetValue("temporary_path", out string? temporary) && temporary.Length > 0) File.Delete(temporary); if (entry.Data.TryGetValue("backup_path", out string? backup) && backup.Length > 0) { File.Delete(backup); string? parent = System.IO.Path.GetDirectoryName(backup); if (parent is not null && Directory.Exists(parent) && !Directory.EnumerateFileSystemEntries(parent).Any()) Directory.Delete(parent); } } private static string Hash(string target) { using FileStream stream = File.OpenRead(target); return Convert.ToHexString(SHA256.HashData(stream)); } }