summaryrefslogtreecommitdiff
path: root/src/Agent.Windows/Recovery/RecoveryManager.cs
blob: 8fd73680bf4906fc47078377020f6dc46b58c0a3 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
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 "dir.mkdir": RecoverMkdir(entry); break;
            case "dir.rmdir": RecoverRmdir(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 RecoverMkdir(JournalEntry entry)
    {
        if (!Directory.Exists(entry.Target)) return;
        if (Directory.EnumerateFileSystemEntries(entry.Target, "*", SearchOption.TopDirectoryOnly).Any())
            throw new InvalidOperationException($"Directory '{entry.Target}' created by mkdir is not empty; automatic rollback refused.");
        Directory.Delete(entry.Target, recursive: false);
    }

    private static void RecoverRmdir(JournalEntry entry)
    {
        if (Directory.Exists(entry.Target)) throw new InvalidOperationException($"Directory '{entry.Target}' was recreated after crash; automatic recovery refused.");
        Directory.CreateDirectory(entry.Target);
    }

    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));
    }
}