diff options
Diffstat (limited to 'src/Agent.Windows/Recovery')
| -rw-r--r-- | src/Agent.Windows/Recovery/RecoveryManager.cs | 114 | ||||
| -rw-r--r-- | src/Agent.Windows/Recovery/TaskResourceJournal.cs | 108 | ||||
| -rw-r--r-- | src/Agent.Windows/Recovery/TransactionalFileEditor.cs | 141 |
3 files changed, 363 insertions, 0 deletions
diff --git a/src/Agent.Windows/Recovery/RecoveryManager.cs b/src/Agent.Windows/Recovery/RecoveryManager.cs new file mode 100644 index 0000000..df69a5e --- /dev/null +++ b/src/Agent.Windows/Recovery/RecoveryManager.cs @@ -0,0 +1,114 @@ +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<string> 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<string>(); + 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<string, string>())); + } + 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)); + } +} diff --git a/src/Agent.Windows/Recovery/TaskResourceJournal.cs b/src/Agent.Windows/Recovery/TaskResourceJournal.cs new file mode 100644 index 0000000..b638041 --- /dev/null +++ b/src/Agent.Windows/Recovery/TaskResourceJournal.cs @@ -0,0 +1,108 @@ +using System.Text.Json; + +namespace Agent.Windows.Recovery; + +public enum JournalEntryState { Prepared, Applied, Cleaned } + +public sealed class JournalEntry +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Kind { get; set; } = string.Empty; + public string Target { get; set; } = string.Empty; + public JournalEntryState State { get; set; } + public Dictionary<string, string> Data { get; set; } = new(StringComparer.Ordinal); +} + +public sealed class JournalDocument +{ + public int SchemaVersion { get; set; } = 1; + public Guid TaskId { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public List<JournalEntry> Entries { get; set; } = []; +} + +public sealed class TaskResourceJournal +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + private readonly object _sync = new(); + private readonly JournalDocument _document; + + private TaskResourceJournal(string path, JournalDocument document) { Path = path; _document = document; } + public string Path { get; } + public Guid TaskId => _document.TaskId; + public IReadOnlyList<JournalEntry> Entries { get { lock (_sync) return _document.Entries.Select(Clone).ToArray(); } } + + public static TaskResourceJournal Create(string journalDirectory, Guid taskId) + { + Directory.CreateDirectory(journalDirectory); + string path = System.IO.Path.Combine(journalDirectory, taskId.ToString("N") + ".journal.json"); + if (File.Exists(path)) throw new InvalidOperationException($"Journal already exists for task '{taskId}'."); + var journal = new TaskResourceJournal(path, new JournalDocument { TaskId = taskId }); + journal.Persist(); + return journal; + } + + public static TaskResourceJournal Open(string path) + { + JournalDocument document = JsonSerializer.Deserialize<JournalDocument>(File.ReadAllText(path), JsonOptions) + ?? throw new InvalidDataException($"Journal '{path}' is empty."); + if (document.SchemaVersion != 1 || document.TaskId == Guid.Empty) throw new InvalidDataException($"Journal '{path}' is invalid."); + return new(path, document); + } + + public Guid Prepare(string kind, string target, IReadOnlyDictionary<string, string> data) + { + lock (_sync) + { + var entry = new JournalEntry { Kind = kind, Target = target, State = JournalEntryState.Prepared, Data = new(data, StringComparer.Ordinal) }; + _document.Entries.Add(entry); + Persist(); + return entry.Id; + } + } + + public void MarkApplied(Guid entryId, IReadOnlyDictionary<string, string>? additionalData = null) + { + lock (_sync) + { + JournalEntry entry = Get(entryId); + if (entry.State != JournalEntryState.Prepared) throw new InvalidOperationException("Only prepared resources can be marked applied."); + if (additionalData is not null) foreach ((string key, string value) in additionalData) entry.Data[key] = value; + entry.State = JournalEntryState.Applied; + Persist(); + } + } + + public void MarkCleaned(Guid entryId) + { + lock (_sync) + { + JournalEntry entry = Get(entryId); + if (entry.State == JournalEntryState.Cleaned) return; + entry.State = JournalEntryState.Cleaned; + Persist(); + } + } + + public void CompleteAndDelete() + { + lock (_sync) + { + if (_document.Entries.Any(entry => entry.State != JournalEntryState.Cleaned)) throw new InvalidOperationException("Journal still contains live resources."); + File.Delete(Path); + } + } + + private JournalEntry Get(Guid id) => _document.Entries.SingleOrDefault(entry => entry.Id == id) ?? throw new KeyNotFoundException($"Journal entry '{id}' not found."); + private void Persist() + { + string temporary = Path + ".tmp"; + using (FileStream stream = new(temporary, FileMode.Create, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough)) + { + JsonSerializer.Serialize(stream, _document, JsonOptions); + stream.Flush(flushToDisk: true); + } + File.Move(temporary, Path, overwrite: true); + } + private static JournalEntry Clone(JournalEntry source) => new() { Id = source.Id, Kind = source.Kind, Target = source.Target, State = source.State, Data = new(source.Data, StringComparer.Ordinal) }; +} diff --git a/src/Agent.Windows/Recovery/TransactionalFileEditor.cs b/src/Agent.Windows/Recovery/TransactionalFileEditor.cs new file mode 100644 index 0000000..52963a0 --- /dev/null +++ b/src/Agent.Windows/Recovery/TransactionalFileEditor.cs @@ -0,0 +1,141 @@ +using System.Security.Cryptography; +using System.Text; +using Agent.Windows.Security; + +namespace Agent.Windows.Recovery; + +public sealed class TransactionalFileEditor(TaskResourceJournal journal, string backupRoot, long memoryBackupLimitBytes = 16 * 1024 * 1024) +{ + public FileEditTransaction Replace(string path, string content, string? expectedSha256 = null) => Replace(path, Encoding.UTF8.GetBytes(content), expectedSha256); + + public FileEditTransaction Replace(string path, ReadOnlySpan<byte> content, string? expectedSha256 = null) + { + string canonical = CanonicalPath.Resolve(path, requireExisting: false); + bool existed = File.Exists(canonical); + byte[]? original = existed ? File.ReadAllBytes(canonical) : null; + string originalHash = original is null ? string.Empty : Convert.ToHexString(SHA256.HashData(original)); + if (expectedSha256 is not null && !originalHash.Equals(NormalizeHash(expectedSha256), StringComparison.Ordinal)) + throw new SandboxPolicyException("file.concurrent_change", "File hash does not match expected_sha256."); + + string backupPath = string.Empty; + string inline = string.Empty; + if (original is not null && original.LongLength > memoryBackupLimitBytes) + { + string directory = System.IO.Path.Combine(backupRoot, journal.TaskId.ToString("N")); + Directory.CreateDirectory(directory); + backupPath = System.IO.Path.Combine(directory, Guid.NewGuid().ToString("N") + ".backup"); + File.WriteAllBytes(backupPath, original); + } + else if (original is not null) inline = Convert.ToBase64String(original); + + string temporary = canonical + ".agent-mcp-" + Guid.NewGuid().ToString("N") + ".tmp"; + var data = new Dictionary<string, string> + { + ["existed"] = existed.ToString(), ["original_sha256"] = originalHash, ["original_base64"] = inline, + ["backup_path"] = backupPath, ["temporary_path"] = temporary + }; + Guid entryId = journal.Prepare("file.replace", canonical, data); + File.WriteAllBytes(temporary, content); + string replacementHash = Convert.ToHexString(SHA256.HashData(content)); + File.Move(temporary, canonical, overwrite: true); + journal.MarkApplied(entryId, new Dictionary<string, string> { ["replacement_sha256"] = replacementHash }); + return new(journal, entryId, canonical, originalHash, replacementHash, backupPath, inline, existed); + } + + public FileDeleteTransaction Delete(string path, string? expectedSha256 = null) + { + string canonical = CanonicalPath.Resolve(path); + byte[] original = File.ReadAllBytes(canonical); + string originalHash = Convert.ToHexString(SHA256.HashData(original)); + if (expectedSha256 is not null && !originalHash.Equals(NormalizeHash(expectedSha256), StringComparison.Ordinal)) + throw new SandboxPolicyException("file.concurrent_change", "File hash does not match expected_sha256."); + string backupPath = string.Empty; + string inline = string.Empty; + if (original.LongLength > memoryBackupLimitBytes) + { + string directory = System.IO.Path.Combine(backupRoot, journal.TaskId.ToString("N")); + Directory.CreateDirectory(directory); + backupPath = System.IO.Path.Combine(directory, Guid.NewGuid().ToString("N") + ".backup"); + File.WriteAllBytes(backupPath, original); + } + else inline = Convert.ToBase64String(original); + Guid entryId = journal.Prepare("file.delete", canonical, new Dictionary<string, string> + { + ["original_sha256"] = originalHash, ["original_base64"] = inline, ["backup_path"] = backupPath + }); + File.Delete(canonical); + journal.MarkApplied(entryId); + return new(journal, entryId, canonical, originalHash, backupPath, inline); + } + + private static string NormalizeHash(string hash) => hash.Replace("-", string.Empty, StringComparison.Ordinal).Trim().ToUpperInvariant(); +} + +public sealed class FileDeleteTransaction(TaskResourceJournal journal, Guid entryId, string path, string originalHash, string backupPath, string originalBase64) +{ + private bool _finished; + public void Commit() { if (_finished) return; Cleanup(); journal.MarkCleaned(entryId); _finished = true; } + public void Rollback() + { + if (_finished) return; + if (File.Exists(path)) throw new InvalidOperationException("Rollback refused because the deleted path was recreated."); + byte[] original = backupPath.Length > 0 ? File.ReadAllBytes(backupPath) : Convert.FromBase64String(originalBase64); + if (!Convert.ToHexString(SHA256.HashData(original)).Equals(originalHash, StringComparison.Ordinal)) throw new InvalidDataException("Delete backup hash verification failed."); + File.WriteAllBytes(path, original); + Cleanup(); journal.MarkCleaned(entryId); _finished = true; + } + private void Cleanup() + { + if (backupPath.Length == 0) return; + File.Delete(backupPath); + string? parent = System.IO.Path.GetDirectoryName(backupPath); + if (parent is not null && Directory.Exists(parent) && !Directory.EnumerateFileSystemEntries(parent).Any()) Directory.Delete(parent); + } +} + +public sealed class FileEditTransaction( + TaskResourceJournal journal, Guid entryId, string path, string originalHash, string replacementHash, + string backupPath, string originalBase64, bool originallyExisted) +{ + private bool _finished; + + public void Commit() + { + if (_finished) return; + DeleteBackup(); + journal.MarkCleaned(entryId); + _finished = true; + } + + public void Rollback() + { + if (_finished) return; + if (!File.Exists(path) || !Hash(path).Equals(replacementHash, StringComparison.Ordinal)) + throw new InvalidOperationException("Rollback refused because the replacement file changed after the transaction."); + if (originallyExisted) + { + byte[] original = backupPath.Length > 0 ? File.ReadAllBytes(backupPath) : Convert.FromBase64String(originalBase64); + if (!Convert.ToHexString(SHA256.HashData(original)).Equals(originalHash, StringComparison.Ordinal)) throw new InvalidDataException("Backup hash verification failed."); + string temporary = path + ".rollback-" + Guid.NewGuid().ToString("N") + ".tmp"; + File.WriteAllBytes(temporary, original); + File.Move(temporary, path, overwrite: true); + } + else File.Delete(path); + DeleteBackup(); + journal.MarkCleaned(entryId); + _finished = true; + } + + private void DeleteBackup() + { + if (backupPath.Length == 0) return; + File.Delete(backupPath); + string? parent = System.IO.Path.GetDirectoryName(backupPath); + 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)); + } +} |
