diff options
Diffstat (limited to 'src/Agent.Windows/Recovery/TransactionalFileEditor.cs')
| -rw-r--r-- | src/Agent.Windows/Recovery/TransactionalFileEditor.cs | 141 |
1 files changed, 141 insertions, 0 deletions
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)); + } +} |
