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.cs13
-rw-r--r--src/Agent.Windows/Recovery/TransactionalFileEditor.cs41
2 files changed, 54 insertions, 0 deletions
diff --git a/src/Agent.Windows/Recovery/RecoveryManager.cs b/src/Agent.Windows/Recovery/RecoveryManager.cs
index 8fd7368..c9b28bf 100644
--- a/src/Agent.Windows/Recovery/RecoveryManager.cs
+++ b/src/Agent.Windows/Recovery/RecoveryManager.cs
@@ -49,6 +49,7 @@ public sealed class RecoveryManager(string journalDirectory, ISecurityAuditSink
{
case "file.replace": RecoverFile(entry); break;
case "file.delete": RecoverDeletedFile(entry); break;
+ case "file.rename": RecoverRenamedFile(entry); break;
case "dir.mkdir": RecoverMkdir(entry); break;
case "dir.rmdir": RecoverRmdir(entry); break;
case "acl": RecoverAcl(entry); break;
@@ -96,6 +97,18 @@ public sealed class RecoveryManager(string journalDirectory, ISecurityAuditSink
File.WriteAllBytes(entry.Target, original);
}
+ private static void RecoverRenamedFile(JournalEntry entry)
+ {
+ string destination = entry.Data["destination_path"];
+ string sourceHash = entry.Data["source_sha256"];
+ if (!File.Exists(destination)) return; // Already rolled back or destination missing — skip.
+ if (!Hash(destination).Equals(sourceHash, StringComparison.Ordinal))
+ throw new InvalidOperationException($"Renamed destination '{destination}' hash does not match journal record; automatic recovery refused.");
+ if (File.Exists(entry.Target))
+ throw new InvalidOperationException($"Source path '{entry.Target}' was recreated after crash; automatic recovery refused.");
+ File.Move(destination, entry.Target);
+ }
+
private static void RecoverMkdir(JournalEntry entry)
{
if (!Directory.Exists(entry.Target)) return;
diff --git a/src/Agent.Windows/Recovery/TransactionalFileEditor.cs b/src/Agent.Windows/Recovery/TransactionalFileEditor.cs
index 805a069..25c949d 100644
--- a/src/Agent.Windows/Recovery/TransactionalFileEditor.cs
+++ b/src/Agent.Windows/Recovery/TransactionalFileEditor.cs
@@ -93,6 +93,26 @@ public sealed class TransactionalFileEditor(TaskResourceJournal journal, string
return new(journal, entryId, canonical);
}
+ public FileRenameTransaction Rename(string sourcePath, string destinationPath)
+ {
+ string source = CanonicalPath.Resolve(sourcePath);
+ string destination = CanonicalPath.Resolve(destinationPath, requireExisting: false);
+ if (!File.Exists(source)) throw new FileNotFoundException($"Source file does not exist: '{source}'.");
+ if (File.Exists(destination)) throw new ToolValidationException($"Destination already exists: '{destination}'. Rename overwrite is not allowed.");
+ if (!source.StartsWith(System.IO.Path.GetPathRoot(source)!, StringComparison.OrdinalIgnoreCase) ||
+ !destination.StartsWith(System.IO.Path.GetPathRoot(source)!, StringComparison.OrdinalIgnoreCase))
+ throw new ToolValidationException("Cross-volume rename is not supported.");
+ byte[] content = File.ReadAllBytes(source);
+ string sourceHash = Convert.ToHexString(SHA256.HashData(content));
+ Guid entryId = journal.Prepare("file.rename", source, new Dictionary<string, string>
+ {
+ ["destination_path"] = destination, ["source_sha256"] = sourceHash
+ });
+ File.Move(source, destination);
+ journal.MarkApplied(entryId);
+ return new(journal, entryId, source, destination, sourceHash);
+ }
+
private static string NormalizeHash(string hash) => hash.Replace("-", string.Empty, StringComparison.Ordinal).Trim().ToUpperInvariant();
}
@@ -144,6 +164,27 @@ public sealed class DirectoryRmdirTransaction(TaskResourceJournal journal, Guid
}
}
+public sealed class FileRenameTransaction(TaskResourceJournal journal, Guid entryId, string sourcePath, string destinationPath, string sourceHash)
+{
+ private bool _finished;
+ public void Commit() { if (_finished) return; journal.MarkCleaned(entryId); _finished = true; }
+ public void Rollback()
+ {
+ if (_finished) return;
+ if (!File.Exists(destinationPath)) throw new InvalidOperationException("Rollback refused because the destination file is missing.");
+ if (!Hash(destinationPath).Equals(sourceHash, StringComparison.Ordinal))
+ throw new InvalidDataException("Rollback refused because the destination file hash does not match the original source hash.");
+ File.Move(destinationPath, sourcePath);
+ journal.MarkCleaned(entryId);
+ _finished = true;
+ }
+ private static string Hash(string target)
+ {
+ using FileStream stream = File.OpenRead(target);
+ return Convert.ToHexString(SHA256.HashData(stream));
+ }
+}
+
public sealed class FileEditTransaction(
TaskResourceJournal journal, Guid entryId, string path, string originalHash, string replacementHash,
string backupPath, string originalBase64, bool originallyExisted)