summaryrefslogtreecommitdiff
path: root/src/Agent.Windows/Recovery
diff options
context:
space:
mode:
Diffstat (limited to 'src/Agent.Windows/Recovery')
-rw-r--r--src/Agent.Windows/Recovery/RecoveryManager.cs114
-rw-r--r--src/Agent.Windows/Recovery/TaskResourceJournal.cs108
-rw-r--r--src/Agent.Windows/Recovery/TransactionalFileEditor.cs141
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));
+ }
+}