Skip to content

Commit 0493337

Browse files
committed
feat: Install Monitor 2.0 — USN journal-based filesystem change tracking
1 parent e37e16c commit 0493337

4 files changed

Lines changed: 329 additions & 2 deletions

File tree

src/DeepPurge.App/ViewModels/MainViewModel.Extensions.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,14 @@ private async Task RunWinapp2Async()
328328
SnapshotStatus = $"Capturing baseline for {programName}...";
329329
try
330330
{
331-
var delta = await new InstallSnapshotEngine().TraceInstallAsync(programName, installerPath, args);
331+
var engine = new InstallSnapshotEngine();
332+
var useV2 = UsnJournalReader.IsSupported(@"C:\");
333+
SnapshotStatus = useV2
334+
? $"Tracing {programName} via USN journal..."
335+
: $"Capturing baseline for {programName}...";
336+
var delta = useV2
337+
? await engine.TraceInstallV2Async(programName, installerPath, args)
338+
: await engine.TraceInstallAsync(programName, installerPath, args);
332339
_dispatcher.Invoke(() =>
333340
{
334341
var parts = new List<string>

src/DeepPurge.Cli/Program.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,11 @@ private static async Task<int> CmdSnapshotAsync(ParsedArgs a, CancellationToken
316316
var extraArgs = a.GetOption("args");
317317

318318
var engine = new InstallSnapshotEngine();
319-
var delta = await engine.TraceInstallAsync(name, installer, extraArgs, ct);
319+
var useV2 = !a.HasFlag("legacy") && UsnJournalReader.IsSupported(@"C:\");
320+
if (useV2) Console.WriteLine("[v2 mode: USN journal + registry snapshot]");
321+
var delta = useV2
322+
? await engine.TraceInstallV2Async(name, installer, extraArgs, ct)
323+
: await engine.TraceInstallAsync(name, installer, extraArgs, ct);
320324
if (delta.IsUpgrade) Console.WriteLine("[upgrade detected — showing diff against prior version]");
321325
Console.WriteLine($"Added files: {delta.AddedFiles.Count,5} ({FormatBytes(delta.TotalAddedBytes)})");
322326
Console.WriteLine($"Added regkeys: {delta.AddedRegistryKeys.Count,5}");

src/DeepPurge.Core/InstallMonitor/InstallSnapshotEngine.cs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,98 @@ await Task.Run(() =>
230230
return (removed, skipped, freed);
231231
}
232232

233+
// ═══════════════════════════════════════════════════════
234+
// USN JOURNAL TRACE (v2 — catches every filesystem change)
235+
// ═══════════════════════════════════════════════════════
236+
237+
public async Task<InstallDelta> TraceInstallV2Async(
238+
string programName,
239+
string installerPath,
240+
string? installerArgs = null,
241+
CancellationToken ct = default)
242+
{
243+
if (string.IsNullOrWhiteSpace(programName))
244+
throw new ArgumentException("programName is required", nameof(programName));
245+
if (string.IsNullOrWhiteSpace(installerPath) || !File.Exists(installerPath))
246+
throw new FileNotFoundException("installer not found", installerPath);
247+
248+
var volumeRoot = Path.GetPathRoot(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles)) ?? @"C:\";
249+
250+
UsnJournalReader.EnsureJournalSize(volumeRoot);
251+
long startUsn = UsnJournalReader.GetCurrentUsn(volumeRoot);
252+
bool useUsn = startUsn >= 0;
253+
254+
var beforeReg = await CaptureRegistryOnlyAsync(ct);
255+
256+
var psi = new ProcessStartInfo
257+
{
258+
FileName = installerPath,
259+
Arguments = installerArgs ?? "",
260+
UseShellExecute = true,
261+
};
262+
try
263+
{
264+
using var p = Process.Start(psi);
265+
if (p != null)
266+
{
267+
await p.WaitForExitAsync(ct);
268+
try { await Task.Delay(5000, ct); } catch (OperationCanceledException) { }
269+
}
270+
}
271+
catch (Exception ex) { Log.Warn($"Installer launch failed: {ex.Message}"); }
272+
273+
var delta = new InstallDelta();
274+
275+
if (useUsn)
276+
{
277+
var usnChanges = UsnJournalReader.ReadChangesSince(volumeRoot, startUsn);
278+
foreach (var c in usnChanges)
279+
{
280+
if (c.Reason == UsnChangeReason.Created || c.Reason == UsnChangeReason.Modified)
281+
{
282+
try
283+
{
284+
var fi = new FileInfo(c.Path);
285+
if (fi.Exists) delta.AddedFiles.Add(new SnapshotEntry(c.Path, fi.Length, fi.LastWriteTimeUtc));
286+
}
287+
catch { }
288+
}
289+
else if (c.Reason == UsnChangeReason.Deleted)
290+
{
291+
delta.RemovedFiles.Add(c.Path);
292+
}
293+
}
294+
}
295+
else
296+
{
297+
Log.Warn("USN journal unavailable — falling back to snapshot diff");
298+
var before = await CaptureAsync(programName, installerPath, ct);
299+
var after = await CaptureAsync(programName, installerPath, ct);
300+
var fsDiff = Diff(before, after);
301+
delta.AddedFiles = fsDiff.AddedFiles;
302+
delta.RemovedFiles = fsDiff.RemovedFiles;
303+
}
304+
305+
var afterReg = await CaptureRegistryOnlyAsync(ct);
306+
var beforeKeys = new HashSet<string>(beforeReg.Select(k => k.Path), StringComparer.OrdinalIgnoreCase);
307+
var afterKeys = new HashSet<string>(afterReg.Select(k => k.Path), StringComparer.OrdinalIgnoreCase);
308+
foreach (var k in afterReg) if (!beforeKeys.Contains(k.Path)) delta.AddedRegistryKeys.Add(k.Path);
309+
foreach (var k in beforeReg) if (!afterKeys.Contains(k.Path)) delta.RemovedRegistryKeys.Add(k.Path);
310+
311+
SaveManifest(programName, delta);
312+
return delta;
313+
}
314+
315+
private async Task<List<RegistryKeyEntry>> CaptureRegistryOnlyAsync(CancellationToken ct)
316+
{
317+
var keysBag = new ConcurrentBag<RegistryKeyEntry>();
318+
var regTasks = RegRoots
319+
.Select(t => Task.Run(() => EnumerateRegKeys(t.Hive, t.Sub, keysBag, maxDepth: 3, ct), ct))
320+
.ToArray();
321+
await Task.WhenAll(regTasks);
322+
return keysBag.ToList();
323+
}
324+
233325
// ═══════════════════════════════════════════════════════
234326

235327
private static string ManifestPath(string programName)
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
using System.Runtime.InteropServices;
2+
using DeepPurge.Core.Diagnostics;
3+
using Microsoft.Win32.SafeHandles;
4+
5+
namespace DeepPurge.Core.InstallMonitor;
6+
7+
public record UsnChange(string Path, UsnChangeReason Reason, DateTime TimestampUtc);
8+
9+
public enum UsnChangeReason { Created, Modified, Renamed, Deleted }
10+
11+
public class UsnJournalReader
12+
{
13+
private const uint FSCTL_QUERY_USN_JOURNAL = 0x000900f4;
14+
private const uint FSCTL_READ_USN_JOURNAL = 0x000900bb;
15+
private const uint FSCTL_CREATE_USN_JOURNAL = 0x000900e7;
16+
17+
private const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;
18+
private const uint GENERIC_READ = 0x80000000;
19+
private const uint FILE_SHARE_READ = 1;
20+
private const uint FILE_SHARE_WRITE = 2;
21+
private const uint OPEN_EXISTING = 3;
22+
23+
private const uint USN_REASON_FILE_CREATE = 0x00000100;
24+
private const uint USN_REASON_FILE_DELETE = 0x00000200;
25+
private const uint USN_REASON_RENAME_NEW = 0x00002000;
26+
private const uint USN_REASON_DATA_EXTEND = 0x00000002;
27+
private const uint USN_REASON_DATA_OVERWRITE = 0x00000001;
28+
private const uint USN_REASON_DATA_TRUNCATION = 0x00000004;
29+
30+
public static bool IsSupported(string volumeRoot)
31+
{
32+
try
33+
{
34+
using var h = OpenVolume(volumeRoot);
35+
return QueryJournal(h, out _);
36+
}
37+
catch { return false; }
38+
}
39+
40+
public static long GetCurrentUsn(string volumeRoot)
41+
{
42+
using var h = OpenVolume(volumeRoot);
43+
if (!QueryJournal(h, out var data)) return -1;
44+
return data.NextUsn;
45+
}
46+
47+
public static void EnsureJournalSize(string volumeRoot, long desiredBytes = 64 * 1024 * 1024)
48+
{
49+
try
50+
{
51+
using var h = OpenVolume(volumeRoot);
52+
var create = new CREATE_USN_JOURNAL_DATA
53+
{
54+
MaximumSize = (ulong)desiredBytes,
55+
AllocationDelta = (ulong)(desiredBytes / 8),
56+
};
57+
int bytesReturned;
58+
DeviceIoControl(h, FSCTL_CREATE_USN_JOURNAL, ref create,
59+
Marshal.SizeOf<CREATE_USN_JOURNAL_DATA>(), IntPtr.Zero, 0, out bytesReturned, IntPtr.Zero);
60+
}
61+
catch (Exception ex)
62+
{
63+
Log.Warn($"EnsureJournalSize: {ex.Message}");
64+
}
65+
}
66+
67+
public static List<UsnChange> ReadChangesSince(string volumeRoot, long startUsn)
68+
{
69+
var changes = new List<UsnChange>();
70+
try
71+
{
72+
using var h = OpenVolume(volumeRoot);
73+
if (!QueryJournal(h, out var journalData)) return changes;
74+
75+
var readData = new READ_USN_JOURNAL_DATA_V0
76+
{
77+
StartUsn = startUsn,
78+
ReasonMask = USN_REASON_FILE_CREATE | USN_REASON_FILE_DELETE |
79+
USN_REASON_RENAME_NEW | USN_REASON_DATA_EXTEND |
80+
USN_REASON_DATA_OVERWRITE | USN_REASON_DATA_TRUNCATION,
81+
ReturnOnlyOnClose = 0,
82+
Timeout = 0,
83+
BytesToWaitFor = 0,
84+
UsnJournalID = journalData.UsnJournalID,
85+
};
86+
87+
const int bufferSize = 64 * 1024;
88+
var buffer = Marshal.AllocHGlobal(bufferSize);
89+
try
90+
{
91+
while (true)
92+
{
93+
int bytesReturned;
94+
bool ok = DeviceIoControl(h, FSCTL_READ_USN_JOURNAL, ref readData,
95+
Marshal.SizeOf<READ_USN_JOURNAL_DATA_V0>(), buffer, bufferSize,
96+
out bytesReturned, IntPtr.Zero);
97+
if (!ok || bytesReturned <= sizeof(long)) break;
98+
99+
long nextUsn = Marshal.ReadInt64(buffer);
100+
int offset = sizeof(long);
101+
while (offset < bytesReturned)
102+
{
103+
var recordLen = Marshal.ReadInt32(buffer + offset);
104+
if (recordLen <= 0) break;
105+
106+
var record = ParseRecord(buffer + offset, volumeRoot);
107+
if (record != null) changes.Add(record);
108+
109+
offset += recordLen;
110+
}
111+
readData.StartUsn = nextUsn;
112+
}
113+
}
114+
finally { Marshal.FreeHGlobal(buffer); }
115+
}
116+
catch (Exception ex)
117+
{
118+
Log.Warn($"ReadChangesSince: {ex.Message}");
119+
}
120+
return changes;
121+
}
122+
123+
private static UsnChange? ParseRecord(IntPtr ptr, string volumeRoot)
124+
{
125+
int fileNameOffset = Marshal.ReadInt16(ptr + 58);
126+
int fileNameLength = Marshal.ReadInt16(ptr + 56);
127+
uint reason = (uint)Marshal.ReadInt32(ptr + 40);
128+
129+
if (fileNameLength <= 0) return null;
130+
131+
var fileName = Marshal.PtrToStringUni(ptr + fileNameOffset, fileNameLength / 2) ?? "";
132+
var changeReason = ClassifyReason(reason);
133+
if (changeReason == null) return null;
134+
135+
var timestampTicks = Marshal.ReadInt64(ptr + 32);
136+
var timestamp = timestampTicks > 0 ? DateTime.FromFileTimeUtc(timestampTicks) : DateTime.UtcNow;
137+
138+
return new UsnChange(Path.Combine(volumeRoot, fileName), changeReason.Value, timestamp);
139+
}
140+
141+
private static UsnChangeReason? ClassifyReason(uint reason)
142+
{
143+
if ((reason & USN_REASON_FILE_CREATE) != 0) return UsnChangeReason.Created;
144+
if ((reason & USN_REASON_FILE_DELETE) != 0) return UsnChangeReason.Deleted;
145+
if ((reason & USN_REASON_RENAME_NEW) != 0) return UsnChangeReason.Renamed;
146+
if ((reason & (USN_REASON_DATA_EXTEND | USN_REASON_DATA_OVERWRITE | USN_REASON_DATA_TRUNCATION)) != 0) return UsnChangeReason.Modified;
147+
return null;
148+
}
149+
150+
private static SafeFileHandle OpenVolume(string volumeRoot)
151+
{
152+
var drive = Path.GetPathRoot(volumeRoot)?.TrimEnd('\\') ?? "C:";
153+
var path = $@"\\.\{drive}";
154+
var h = CreateFileW(path, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE,
155+
IntPtr.Zero, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, IntPtr.Zero);
156+
if (h.IsInvalid) throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
157+
return h;
158+
}
159+
160+
private static bool QueryJournal(SafeFileHandle h, out USN_JOURNAL_DATA_V0 data)
161+
{
162+
data = default;
163+
int bytesReturned;
164+
return DeviceIoControl(h, FSCTL_QUERY_USN_JOURNAL, IntPtr.Zero, 0,
165+
out data, Marshal.SizeOf<USN_JOURNAL_DATA_V0>(), out bytesReturned, IntPtr.Zero);
166+
}
167+
168+
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
169+
private static extern SafeFileHandle CreateFileW(
170+
string lpFileName, uint dwDesiredAccess, uint dwShareMode,
171+
IntPtr lpSecurityAttributes, uint dwCreationDisposition,
172+
uint dwFlagsAndAttributes, IntPtr hTemplateFile);
173+
174+
[DllImport("kernel32.dll", SetLastError = true)]
175+
private static extern bool DeviceIoControl(
176+
SafeFileHandle hDevice, uint dwIoControlCode,
177+
ref READ_USN_JOURNAL_DATA_V0 lpInBuffer, int nInBufferSize,
178+
IntPtr lpOutBuffer, int nOutBufferSize,
179+
out int lpBytesReturned, IntPtr lpOverlapped);
180+
181+
[DllImport("kernel32.dll", SetLastError = true)]
182+
private static extern bool DeviceIoControl(
183+
SafeFileHandle hDevice, uint dwIoControlCode,
184+
ref CREATE_USN_JOURNAL_DATA lpInBuffer, int nInBufferSize,
185+
IntPtr lpOutBuffer, int nOutBufferSize,
186+
out int lpBytesReturned, IntPtr lpOverlapped);
187+
188+
[DllImport("kernel32.dll", SetLastError = true)]
189+
private static extern bool DeviceIoControl(
190+
SafeFileHandle hDevice, uint dwIoControlCode,
191+
IntPtr lpInBuffer, int nInBufferSize,
192+
out USN_JOURNAL_DATA_V0 lpOutBuffer, int nOutBufferSize,
193+
out int lpBytesReturned, IntPtr lpOverlapped);
194+
195+
[StructLayout(LayoutKind.Sequential)]
196+
private struct USN_JOURNAL_DATA_V0
197+
{
198+
public ulong UsnJournalID;
199+
public long FirstUsn;
200+
public long NextUsn;
201+
public long LowestValidUsn;
202+
public long MaxUsn;
203+
public ulong MaximumSize;
204+
public ulong AllocationDelta;
205+
}
206+
207+
[StructLayout(LayoutKind.Sequential)]
208+
private struct READ_USN_JOURNAL_DATA_V0
209+
{
210+
public long StartUsn;
211+
public uint ReasonMask;
212+
public uint ReturnOnlyOnClose;
213+
public ulong Timeout;
214+
public ulong BytesToWaitFor;
215+
public ulong UsnJournalID;
216+
}
217+
218+
[StructLayout(LayoutKind.Sequential)]
219+
private struct CREATE_USN_JOURNAL_DATA
220+
{
221+
public ulong MaximumSize;
222+
public ulong AllocationDelta;
223+
}
224+
}

0 commit comments

Comments
 (0)