using System.Collections.Concurrent; using System.Runtime.InteropServices; using Microsoft.Win32.SafeHandles; namespace FastSeek.Core.Mft; public sealed class UsnWatcher : IDisposable { private const int BufferSize = 64 * 1024; private readonly SafeFileHandle _handle; private readonly NtfsDrive _drive; private readonly BlockingCollection _sender; public long NextUsn { get; private set; } public ulong JournalId { get; private set; } private UsnWatcher(SafeFileHandle handle, NtfsDrive drive, BlockingCollection sender, long nextUsn, ulong journalId) { _handle = handle; _drive = drive; _sender = sender; NextUsn = nextUsn; JournalId = journalId; } public static UsnWatcher New(NtfsDrive drive, BlockingCollection sender) => NewFrom(drive, sender, null); public static UsnWatcher NewFrom(NtfsDrive drive, BlockingCollection sender, JournalCheckpoint? checkpoint) { var h = NativeMethods.CreateFile(drive.DevicePath, 0, NativeMethods.FILE_SHARE_READ | NativeMethods.FILE_SHARE_WRITE | NativeMethods.FILE_SHARE_DELETE, IntPtr.Zero, NativeMethods.OPEN_EXISTING, NativeMethods.FILE_FLAG_BACKUP_SEMANTICS, IntPtr.Zero); if (h.IsInvalid) throw new IOException("USN open failed"); var outPtr = Marshal.AllocHGlobal(Marshal.SizeOf()); try { if (!NativeMethods.DeviceIoControl(h, NativeMethods.FSCTL_QUERY_USN_JOURNAL, IntPtr.Zero, 0, outPtr, (uint)Marshal.SizeOf(), out _, IntPtr.Zero)) throw new IOException("USN query failed"); var data = Marshal.PtrToStructure(outPtr); long nextUsn; if (checkpoint is not null) { if (checkpoint.JournalId != data.UsnJournalID || checkpoint.NextUsn < data.FirstUsn || checkpoint.NextUsn > data.NextUsn) throw new IOException("Journal mismatch - rescan needed"); nextUsn = checkpoint.NextUsn; } else nextUsn = data.NextUsn; return new UsnWatcher(h, drive, sender, nextUsn, data.UsnJournalID); } finally { Marshal.FreeHGlobal(outPtr); } } public JournalCheckpoint Checkpoint() => new() { NextUsn = NextUsn, JournalId = JournalId, DriveLetter = _drive.Letter }; public void RunShared(List checkpoints, object checkpointLock, CancellationToken token) { var buffer = new byte[BufferSize]; while (!token.IsCancellationRequested) { Thread.Sleep(500); Poll(buffer); lock (checkpointLock) { checkpoints.RemoveAll(c => c.DriveLetter == _drive.Letter); checkpoints.Add(Checkpoint()); } } } public int Drain() { var buffer = new byte[BufferSize]; var count = 0; while (true) { var before = NextUsn; Poll(buffer); if (NextUsn == before) break; count++; } return count; } private void Poll(byte[] buffer) { var read = new ReadUsnJournalDataV0 { StartUsn = NextUsn, ReasonMask = NativeMethods.USN_REASON_FILE_CREATE | NativeMethods.USN_REASON_FILE_DELETE | NativeMethods.USN_REASON_RENAME_NEW_NAME | NativeMethods.USN_REASON_RENAME_OLD_NAME, ReturnOnlyOnClose = 0, Timeout = 0, BytesToWaitFor = 0, UsnJournalID = JournalId, }; var inPtr = Marshal.AllocHGlobal(Marshal.SizeOf()); try { Marshal.StructureToPtr(read, inPtr, false); if (!NativeMethods.DeviceIoControl(_handle, NativeMethods.FSCTL_READ_USN_JOURNAL, inPtr, (uint)Marshal.SizeOf(), buffer, (uint)buffer.Length, out var returned, IntPtr.Zero) || returned <= 8) return; NextUsn = BitConverter.ToInt64(buffer, 0); var offset = 8; while (offset + Marshal.SizeOf() <= returned) { var rec = MemoryMarshal.Read(new ReadOnlySpan(buffer, offset, Marshal.SizeOf())); if (rec.RecordLength == 0) break; ProcessRecord(rec, buffer, offset); offset += (int)rec.RecordLength; } } finally { Marshal.FreeHGlobal(inPtr); } } private void ProcessRecord(UsnRecordV2 record, byte[] buffer, int offset) { var nameLen = record.FileNameLength / 2; var nameStart = offset + record.FileNameOffset; var name = new string(MemoryMarshal.Cast(new ReadOnlySpan(buffer, nameStart, nameLen * 2))); var isDir = (record.FileAttributes & 0x10) != 0; var kind = isDir ? FileKind.Directory : FileKind.File; if ((record.Reason & NativeMethods.USN_REASON_FILE_DELETE) != 0) { _sender.Add(new IndexEvent.Deleted(record.FileReferenceNumber)); return; } if ((record.Reason & NativeMethods.USN_REASON_RENAME_NEW_NAME) != 0) { _sender.Add(new IndexEvent.Moved(record.FileReferenceNumber, record.ParentFileReferenceNumber, name, kind)); return; } if ((record.Reason & NativeMethods.USN_REASON_FILE_CREATE) != 0) _sender.Add(new IndexEvent.Created(new FileRecord { FileRef = record.FileReferenceNumber, ParentRef = record.ParentFileReferenceNumber, Name = name, Kind = kind })); } public void Dispose() => _handle.Dispose(); }