using System.Runtime.InteropServices; using Microsoft.Win32.SafeHandles; namespace FastSeek.Core.Mft; internal sealed class MftReader : IDisposable { private const int FallbackBuf = 4 * 1024 * 1024; private const int DirectBuf = 4 * 1024 * 1024; private readonly SafeFileHandle _handle; public NtfsDrive Drive { get; } private MftReader(SafeFileHandle handle, NtfsDrive drive) { _handle = handle; Drive = drive; } public static MftReader Open(NtfsDrive drive) { var h = NativeMethods.CreateFile(drive.DevicePath, NativeMethods.GENERIC_READ, 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($"Open drive failed: {drive.DevicePath}"); return new MftReader(h, drive); } public ScanResult? ScanDirect(TimeSpan? maxDuration = null, Action? log = null) { var started = DateTime.UtcNow; var recordSize = ReadMftRecordSize(); if (recordSize is null) { log?.Invoke("direct: failed to read NTFS record size"); return null; } log?.Invoke($"direct: record size={recordSize.Value} bytes"); var mftPath = Path.Combine(Drive.Root, "$MFT"); var mft = NativeMethods.CreateFile(mftPath, NativeMethods.GENERIC_READ, NativeMethods.FILE_SHARE_READ | NativeMethods.FILE_SHARE_WRITE | NativeMethods.FILE_SHARE_DELETE, IntPtr.Zero, NativeMethods.OPEN_EXISTING, NativeMethods.FILE_FLAG_BACKUP_SEMANTICS | NativeMethods.FILE_FLAG_SEQUENTIAL_SCAN, IntPtr.Zero); if (mft.IsInvalid) { log?.Invoke("direct: could not open $MFT"); return null; } try { var records = new List(3_000_000); var names = new List(40_000_000); var buffer = new byte[DirectBuf]; var readChunk = new byte[DirectBuf]; ulong mftIndex = 0; var leftover = 0; long totalRead = 0; var lastLog = DateTime.UtcNow; while (NativeMethods.ReadFile(mft, readChunk, (uint)(buffer.Length - leftover), out var read, IntPtr.Zero) && read > 0) { if (maxDuration.HasValue && (DateTime.UtcNow - started) > maxDuration.Value) { log?.Invoke($"direct: timed out after {(DateTime.UtcNow - started).TotalSeconds:F1}s, fallback to ioctl"); return null; } totalRead += read; Buffer.BlockCopy(readChunk, 0, buffer, leftover, (int)read); var total = leftover + (int)read; var offset = 0; while (offset + recordSize.Value <= total) { var slice = new Span(buffer, offset, recordSize.Value); if (ApplyFixup(slice)) ParseFileRecord(slice, mftIndex, records, names); mftIndex++; offset += recordSize.Value; } offset = total - (total % recordSize.Value); leftover = total - offset; if (leftover > 0) Buffer.BlockCopy(buffer, offset, buffer, 0, leftover); var now = DateTime.UtcNow; if ((now - lastLog).TotalSeconds >= 2.0) { log?.Invoke($"direct: read={totalRead / (1024 * 1024)}MB parsed={records.Count:N0}"); lastLog = now; } } log?.Invoke($"direct: completed read={totalRead / (1024 * 1024)}MB parsed={records.Count:N0}"); return new ScanResult { Records = records, NameData = names }; } finally { mft.Dispose(); } } public ScanResult Scan(Action? log = null) { var records = new List(3_000_000); var names = new List(40_000_000); var enumData = new MftEnumDataV0 { StartFileReferenceNumber = 0, LowUsn = 0, HighUsn = long.MaxValue }; var outBuf = new byte[FallbackBuf]; var loops = 0; var lastLog = DateTime.UtcNow; while (true) { var pIn = Marshal.AllocHGlobal(Marshal.SizeOf()); try { Marshal.StructureToPtr(enumData, pIn, false); if (!NativeMethods.DeviceIoControl(_handle, NativeMethods.FSCTL_ENUM_USN_DATA, pIn, (uint)Marshal.SizeOf(), outBuf, (uint)outBuf.Length, out var returned, IntPtr.Zero)) { var err = Marshal.GetLastWin32Error(); log?.Invoke($"ioctl: DeviceIoControl stop err=0x{err:X8}"); break; } if (returned <= 8) { log?.Invoke("ioctl: no more records"); break; } loops++; enumData.StartFileReferenceNumber = BitConverter.ToUInt64(outBuf, 0); var offset = 8; while (offset + Marshal.SizeOf() <= returned) { var rec = MemoryMarshal.Read(new ReadOnlySpan(outBuf, offset, Marshal.SizeOf())); if (rec.RecordLength == 0 || offset + rec.RecordLength > returned) break; var nameLen = (ushort)(rec.FileNameLength / 2); var nameStart = offset + rec.FileNameOffset; var chars = MemoryMarshal.Cast(new ReadOnlySpan(outBuf, nameStart, nameLen * 2)); var arenaOff = (uint)names.Count; AppendChars(names, chars); records.Add(new CompactRecord { FileRef = rec.FileReferenceNumber, ParentRef = rec.ParentFileReferenceNumber, NameOff = arenaOff, NameLen = nameLen, IsDir = (rec.FileAttributes & 0x10) != 0 }); offset += (int)rec.RecordLength; } var now = DateTime.UtcNow; if ((now - lastLog).TotalSeconds >= 2.0) { log?.Invoke($"ioctl: loops={loops:N0} parsed={records.Count:N0} nextRef={enumData.StartFileReferenceNumber}"); lastLog = now; } } finally { Marshal.FreeHGlobal(pIn); } } log?.Invoke($"ioctl: completed loops={loops:N0} parsed={records.Count:N0}"); return new ScanResult { Records = records, NameData = names }; } private int? ReadMftRecordSize() { if (!NativeMethods.SetFilePointerEx(_handle, 0, out _, NativeMethods.FILE_BEGIN)) return null; var boot = new byte[512]; if (!NativeMethods.ReadFile(_handle, boot, 512, out var read, IntPtr.Zero) || read < 512) return null; if (boot[3] != (byte)'N' || boot[4] != (byte)'T' || boot[5] != (byte)'F' || boot[6] != (byte)'S') return null; var bps = BitConverter.ToUInt16(boot, 0x0B); var spc = boot[0x0D]; var cluster = bps * spc; var raw = unchecked((sbyte)boot[0x40]); return raw > 0 ? raw * cluster : 1 << -raw; } private static bool ApplyFixup(Span rec) { if (rec.Length < 48 || rec[0] != (byte)'F' || rec[1] != (byte)'I' || rec[2] != (byte)'L' || rec[3] != (byte)'E') return false; var off = BitConverter.ToUInt16(rec.Slice(4, 2)); var cnt = BitConverter.ToUInt16(rec.Slice(6, 2)); if (cnt < 2 || off + cnt * 2 > rec.Length) return false; var c0 = rec[off]; var c1 = rec[off + 1]; for (var i = 1; i < cnt; i++) { var end = i * 512 - 2; if (end + 1 >= rec.Length) break; if (rec[end] != c0 || rec[end + 1] != c1) return false; rec[end] = rec[off + i * 2]; rec[end + 1] = rec[off + i * 2 + 1]; } return true; } private static void ParseFileRecord(ReadOnlySpan rec, ulong mftIndex, List outRecords, List outNames) { var flags = BitConverter.ToUInt16(rec.Slice(0x16, 2)); if ((flags & 0x01) == 0) return; var isDir = (flags & 0x02) != 0; var seq = BitConverter.ToUInt16(rec.Slice(0x10, 2)); var fileRef = mftIndex | ((ulong)seq << 48); var firstAttr = BitConverter.ToUInt16(rec.Slice(0x14, 2)); var aoff = (int)firstAttr; byte bestNs = 255; (int pos, int len, ulong parent)? best = null; while (aoff + 8 <= rec.Length) { var atype = BitConverter.ToUInt32(rec.Slice(aoff, 4)); if (atype == 0xFFFFFFFF) break; var alen = (int)BitConverter.ToUInt32(rec.Slice(aoff + 4, 4)); if (alen == 0 || aoff + alen > rec.Length) break; if (atype == 0x30 && rec[aoff + 8] == 0) { var voff = (int)BitConverter.ToUInt16(rec.Slice(aoff + 20, 2)); var vs = aoff + voff; if (vs + 66 <= rec.Length) { var parent = BitConverter.ToUInt64(rec.Slice(vs, 8)); var nlen = rec[vs + 64]; var ns = rec[vs + 65]; if (vs + 66 + nlen * 2 <= rec.Length && ns != 2) { var pr = ns == 1 ? 0 : ns == 3 ? 1 : ns == 0 ? 2 : 3; if (pr < bestNs) { bestNs = (byte)pr; best = (vs + 66, nlen, parent); if (pr == 0) break; } } } } aoff += alen; } if (best is { } b) { var arenaOff = (uint)outNames.Count; var chars = MemoryMarshal.Cast(rec.Slice(b.pos, b.len * 2)); AppendChars(outNames, chars); outRecords.Add(new CompactRecord { FileRef = fileRef, ParentRef = b.parent, NameOff = arenaOff, NameLen = (ushort)b.len, IsDir = isDir }); } } private static void AppendChars(List target, ReadOnlySpan source) { target.Capacity = Math.Max(target.Capacity, target.Count + source.Length); for (var i = 0; i < source.Length; i++) target.Add(source[i]); } public void Dispose() => _handle.Dispose(); }