using System.Security.Cryptography; using System.Text; using Agent.Core.Tools; 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 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 { ["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 { ["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 { ["original_sha256"] = originalHash, ["original_base64"] = inline, ["backup_path"] = backupPath }); File.Delete(canonical); journal.MarkApplied(entryId); return new(journal, entryId, canonical, originalHash, backupPath, inline); } public DirectoryMkdirTransaction Mkdir(string path) { string canonical = CanonicalPath.Resolve(path, requireExisting: false); bool existed = Directory.Exists(canonical); if (existed) throw new InvalidOperationException($"Directory already exists: '{canonical}'."); if (File.Exists(canonical)) throw new InvalidOperationException($"A file exists at the target path: '{canonical}'."); Guid entryId = journal.Prepare("dir.mkdir", canonical, new Dictionary { ["existed"] = existed.ToString() }); Directory.CreateDirectory(canonical); journal.MarkApplied(entryId); return new(journal, entryId, canonical); } public DirectoryRmdirTransaction Rmdir(string path) { string canonical = CanonicalPath.Resolve(path); if (!Directory.Exists(canonical)) throw new DirectoryNotFoundException($"Directory does not exist: '{canonical}'."); if (Directory.EnumerateFileSystemEntries(canonical, "*", SearchOption.TopDirectoryOnly).Any()) throw new ToolValidationException("Directory is not empty. Remove contents first before removing the directory."); Guid entryId = journal.Prepare("dir.rmdir", canonical, new Dictionary()); Directory.Delete(canonical, recursive: false); journal.MarkApplied(entryId); return new(journal, entryId, canonical); } 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 DirectoryMkdirTransaction(TaskResourceJournal journal, Guid entryId, string path) { private bool _finished; public void Commit() { if (_finished) return; journal.MarkCleaned(entryId); _finished = true; } public void Rollback() { if (_finished) return; if (Directory.Exists(path)) Directory.Delete(path, recursive: false); journal.MarkCleaned(entryId); _finished = true; } } public sealed class DirectoryRmdirTransaction(TaskResourceJournal journal, Guid entryId, string path) { private bool _finished; public void Commit() { if (_finished) return; journal.MarkCleaned(entryId); _finished = true; } public void Rollback() { if (_finished) return; if (!Directory.Exists(path)) Directory.CreateDirectory(path); journal.MarkCleaned(entryId); _finished = true; } } 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)); } }