Files
fluid-explorer/Services/Search/Usn/MftReader.cs
T

355 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Buffers.Binary;
using Microsoft.Win32.SafeHandles;
namespace FluidExplorer.Services.Search.Usn;
/// <summary>从一条 $MFT 文件记录里解出来的关键字段。</summary>
internal struct MftRecordInfo
{
internal bool InUse;
internal bool IsDirectory;
internal long Size; // 数据实大小;目录或未找到 $DATA 时为 -1
internal long ModifiedFileTime; // $STANDARD_INFORMATION 偏移 8(LastDataChangeTime)
internal long CreatedFileTime; // $STANDARD_INFORMATION 偏移 0
internal uint Attributes;
internal ulong ParentRecordNumber; // $FILE_NAME 的父目录记录号(低 48 位)
internal int NameLength; // 字符数;-1 表示没有 $FILE_NAME
internal int NameRecordOffset; // 名字在记录内的字节偏移
internal byte NameNamespace; // 0=POSIX 1=Win32 2=DOS 3=Win32&DOS
}
/// <summary>
/// 原始 $MFT 读取器:绕过 USN 日志,直接读卷上的 MFT 数据来拿“真实文件大小 / 精确时间戳 / 真实属性”。
///
/// 步骤:
/// 1) FSCTL_GET_NTFS_VOLUME_DATA 拿 BytesPerSector / BytesPerCluster / BytesPerFileRecordSegment / MftStartLcn;
/// 2) 直接按字节偏移读 MFT 的第 0 条记录($MFT 自身),做 fixup 修正后解析它的 $DATA(0x80) 属性 run list;
/// 3) 由 run list 得到 $MFT 在卷上的全部簇区间,之后按区间批量 ReadFile 并逐条做 fixup 修正 + 属性解析。
///
/// 权限:ReadFile 卷句柄需要 GENERIC_READ,即必须有管理员权限。拿不到时由调用方降级为纯 USN 索引。
/// </summary>
internal sealed class MftReader
{
private const int ReadBlockSize = 4 * 1024 * 1024;
private const uint AttrStandardInformation = 0x10;
private const uint AttrFileName = 0x30;
private const uint AttrData = 0x80;
private const uint AttrEnd = 0xFFFFFFFF;
private const uint FileRecordSignature = 0x454C4946; // "FILE"
private readonly SafeFileHandle _volume;
private readonly int _bytesPerRecord;
private readonly int _bytesPerSector;
private readonly int _bytesPerCluster;
private readonly (long Start, long Length)[] _extents;
internal long MftValidDataLength { get; }
internal int BytesPerRecord => _bytesPerRecord;
internal int BytesPerSector => _bytesPerSector;
/// <summary>MFT 在卷上的簇区间(已按字节换算),可用于日志与自检。</summary>
internal IReadOnlyList<(long Start, long Length)> Extents => _extents;
private MftReader(SafeFileHandle volume, int bytesPerRecord, int bytesPerSector, int bytesPerCluster,
(long, long)[] extents, long mftValidDataLength)
{
_volume = volume;
_bytesPerRecord = bytesPerRecord;
_bytesPerSector = bytesPerSector;
_bytesPerCluster = bytesPerCluster;
_extents = extents;
MftValidDataLength = mftValidDataLength;
}
/// <summary>
/// 尝试建立 MftReader。失败(非 NTFS、无权限、run list 解析不出来)返回 null 并通过 <paramref name="error"/> 说明原因。
/// </summary>
internal static MftReader? TryCreate(SafeFileHandle volume, in UsnNative.NtfsVolumeDataBuffer vd, out string? error)
{
error = null;
if (vd.BytesPerSector is < 256 or > 65536) { error = $"扇区尺寸异常({vd.BytesPerSector} 字节)"; return null; }
if (vd.BytesPerCluster is < 256 or > 8 * 1024 * 1024) { error = $"簇尺寸异常({vd.BytesPerCluster} 字节)"; return null; }
if (vd.BytesPerFileRecordSegment is < 256 or > 65536 || vd.BytesPerFileRecordSegment % vd.BytesPerSector != 0)
{
error = $"MFT 记录尺寸异常({vd.BytesPerFileRecordSegment} 字节)";
return null;
}
int bytesPerSector = (int)vd.BytesPerSector;
int bytesPerCluster = (int)vd.BytesPerCluster;
int bytesPerRecord = (int)vd.BytesPerFileRecordSegment;
// ---- 1) 读 $MFT 自己的记录(记录号 0),位置 = MftStartLcn 个簇 ----
var record0 = new byte[bytesPerRecord];
long mftStartByte = vd.MftStartLcn * bytesPerCluster;
if (UsnNative.ReadAt(volume, record0, mftStartByte) != bytesPerRecord)
{
error = "无法读取 $MFT 的第一条记录(卷句柄缺少读数据权限,或磁盘未就绪)";
return null;
}
var reader = new MftReader(volume, bytesPerRecord, bytesPerSector, bytesPerCluster, [], vd.MftValidDataLength);
if (!ApplyFixup(record0, bytesPerSector))
{
error = "$MFT 记录 0 的 fixup(USA) 校验失败";
return null;
}
// ---- 2) 解析 $DATA(0x80) 的 run list,得到 MFT 在卷上的簇区间 ----
List<(long, long)> extents = [];
if (reader.TryReadDataRuns(record0, extents) && extents.Count > 0)
{
return new MftReader(volume, bytesPerRecord, bytesPerSector, bytesPerCluster, [.. extents], vd.MftValidDataLength);
}
// ---- 3) 降级:绝大多数卷上 $MFT 是连续的,直接按 MftStartLcn 连续读 ----
long needed = vd.MftValidDataLength > 0 ? vd.MftValidDataLength : vd.NumberSectors * bytesPerSector;
long span = (needed + bytesPerCluster - 1) / bytesPerCluster * bytesPerCluster;
extents.Clear();
extents.Add((mftStartByte, span));
error = "$MFT 的 run list 解析失败,已按“MFT 连续存放”的假设降级读取";
return new MftReader(volume, bytesPerRecord, bytesPerSector, bytesPerCluster, [.. extents], vd.MftValidDataLength);
}
// ================================================================ fixup / USA
/// <summary>
/// 应用 NTFS 的 fixup(Update Sequence Array)修正:
/// 每个扇区最后 2 个字节在磁盘上被换成了 USA 里的“更新序列号”,
/// 必须在解析前用 USA 中保存的真实值逐个还原,否则跨扇区的字段会是垃圾数据。
/// 返回 false 表示 USN 不匹配(记录在读取过程中被改写或已损坏),调用方应跳过该记录。
/// 设计成 static 是为了能被“合成记录”单元测试直接调用(无需真实卷句柄)。
/// </summary>
internal static bool ApplyFixup(Span<byte> record, int bytesPerSector)
{
if (record.Length < 8 || bytesPerSector <= 0) return false;
int usaOffset = BinaryPrimitives.ReadUInt16LittleEndian(record[4..]);
int usaCount = BinaryPrimitives.ReadUInt16LittleEndian(record[6..]);
if (usaCount < 1) return false;
if (usaOffset + usaCount * 2 > record.Length) return false;
// USA 覆盖的扇区数必须与记录尺寸一致
if ((usaCount - 1) * bytesPerSector > record.Length) return false;
ushort usn = BinaryPrimitives.ReadUInt16LittleEndian(record[usaOffset..]);
for (int i = 1; i < usaCount; i++)
{
int pos = i * bytesPerSector - 2;
if (pos + 2 > record.Length) return false;
if (BinaryPrimitives.ReadUInt16LittleEndian(record[pos..]) != usn) return false;
ushort real = BinaryPrimitives.ReadUInt16LittleEndian(record[(usaOffset + i * 2)..]);
BinaryPrimitives.WriteUInt16LittleEndian(record[pos..], real);
}
return true;
}
// ================================================================ 属性解析
/// <summary>
/// 从记录里读取 $DATA(0x80) 的 mapping pairs(run list),换算成卷内字节区间。
/// 这里刻意手写属性链循环而不用委托回调:该路径在构建期会被调用百万次,闭包分配不可接受。
/// </summary>
private bool TryReadDataRuns(ReadOnlySpan<byte> record, List<(long, long)> extents)
{
if (record.Length < 48) return false;
int offset = BinaryPrimitives.ReadUInt16LittleEndian(record[20..]);
int guard = 0;
while (offset >= 24 && offset + 8 <= record.Length && guard++ < 1024)
{
uint type = BinaryPrimitives.ReadUInt32LittleEndian(record[offset..]);
if (type == AttrEnd) break;
uint length = BinaryPrimitives.ReadUInt32LittleEndian(record[(offset + 4)..]);
if (length < 24 || offset + length > record.Length) return false;
bool nonResident = record[offset + 8] != 0;
if (type != AttrData || !nonResident)
{
offset += (int)length;
continue;
}
int runOffset = BinaryPrimitives.ReadUInt16LittleEndian(record[(offset + 32)..]);
int end = offset + (int)length;
if (runOffset <= 0 || offset + runOffset >= end) return false;
long lcn = 0;
int p = offset + runOffset;
while (p < end)
{
int header = record[p++];
if (header == 0) break;
int lenSize = header & 0x0F;
int offSize = header >> 4;
if (lenSize == 0 || p + lenSize + offSize > end) break;
long runLength = 0;
for (int i = 0; i < lenSize; i++) runLength |= (long)record[p + i] << (8 * i);
p += lenSize;
long delta = 0;
bool sparse = offSize == 0;
if (!sparse)
{
// 偏移字段是相对上一个 LCN 的“有符号”小端整数,必须做符号扩展
for (int i = 0; i < offSize; i++) delta |= (long)record[p + i] << (8 * i);
long signBit = 1L << (8 * offSize - 1);
if ((delta & signBit) != 0) delta -= signBit << 1;
p += offSize;
lcn += delta;
}
// 稀疏区段(offset 字段宽度为 0)不占物理簇,直接跳过
if (runLength > 0 && !sparse) extents.Add((lcn * _bytesPerCluster, runLength * _bytesPerCluster));
}
return extents.Count > 0;
}
return false;
}
/// <summary>解析一条 MFT 记录的 $STANDARD_INFORMATION(0x10) / $FILE_NAME(0x30) / $DATA(0x80)。</summary>
internal static bool TryParseRecord(Span<byte> record, int bytesPerSector, out MftRecordInfo info)
{
info = default;
info.Size = -1;
info.NameLength = -1;
if (record.Length < 56) return false;
if (BinaryPrimitives.ReadUInt32LittleEndian(record) != FileRecordSignature) return false;
if (!ApplyFixup(record, bytesPerSector)) return false;
ushort flags = BinaryPrimitives.ReadUInt16LittleEndian(record[22..]);
info.InUse = (flags & 0x0001) != 0;
info.IsDirectory = (flags & 0x0002) != 0;
int bestNamespace = -1;
int offset = BinaryPrimitives.ReadUInt16LittleEndian(record[20..]);
int guard = 0;
while (offset >= 24 && offset + 8 <= record.Length && guard++ < 1024)
{
uint type = BinaryPrimitives.ReadUInt32LittleEndian(record[offset..]);
if (type == AttrEnd) break;
uint length = BinaryPrimitives.ReadUInt32LittleEndian(record[(offset + 4)..]);
if (length < 24 || offset + length > record.Length) break;
bool nonResident = record[offset + 8] != 0;
if (!nonResident && type == AttrStandardInformation)
{
int valueOffset = BinaryPrimitives.ReadUInt16LittleEndian(record[(offset + 20)..]);
int valueLength = (int)BinaryPrimitives.ReadUInt32LittleEndian(record[(offset + 16)..]);
int v = offset + valueOffset;
if (valueLength >= 36 && v + 36 <= record.Length)
{
info.CreatedFileTime = BinaryPrimitives.ReadInt64LittleEndian(record[v..]);
info.ModifiedFileTime = BinaryPrimitives.ReadInt64LittleEndian(record[(v + 8)..]);
info.Attributes = BinaryPrimitives.ReadUInt32LittleEndian(record[(v + 32)..]);
}
}
else if (!nonResident && type == AttrFileName)
{
int valueOffset = BinaryPrimitives.ReadUInt16LittleEndian(record[(offset + 20)..]);
int valueLength = (int)BinaryPrimitives.ReadUInt32LittleEndian(record[(offset + 16)..]);
int v = offset + valueOffset;
if (valueLength >= 66 && v + 66 <= record.Length)
{
int nameLength = record[v + 64];
int nameSpace = record[v + 65];
// 同一文件可能有多个 $FILE_NAME(硬链接 / 8.3 短名):优先 Win32 系列,跳过纯 DOS 名
bool better = bestNamespace < 0 || (bestNamespace == 2 && nameSpace != 2);
if (better && v + 66 + nameLength * 2 <= record.Length)
{
bestNamespace = nameSpace;
info.ParentRecordNumber = BinaryPrimitives.ReadUInt64LittleEndian(record[v..]) & UsnNative.RecordNumberMask;
info.NameLength = nameLength;
info.NameRecordOffset = v + 66;
info.NameNamespace = (byte)nameSpace;
}
}
}
else if (type == AttrData)
{
if (nonResident)
{
if (offset + 56 <= record.Length)
info.Size = BinaryPrimitives.ReadInt64LittleEndian(record[(offset + 48)..]); // RealSize
}
else
{
info.Size = BinaryPrimitives.ReadUInt32LittleEndian(record[(offset + 16)..]); // 驻留数据的大小 = 值长度
}
}
offset += (int)length;
}
if (info.IsDirectory) info.Size = -1; // 目录没有“文件大小”概念,统一记为未知
return true;
}
// ================================================================ 批量枚举
internal delegate void RecordVisitor(ulong recordNumber, Span<byte> record);
/// <summary>
/// 按区间流式读取整个 MFT,逐条回调(缓冲区复用,不产生 per-record 分配)。
/// <paramref name="maxByteOffset"/> 一般传 MftValidDataLength。
/// </summary>
internal void Enumerate(RecordVisitor visitor, long maxByteOffset, Action<long>? onBytesRead, CancellationToken cancellationToken)
{
var buffer = new byte[Math.Max(ReadBlockSize, _bytesPerRecord * 2)];
long consumed = 0; // 相对 MFT 起点的字节数
foreach (var (start, length) in _extents)
{
long extentRead = 0;
while (extentRead + _bytesPerRecord <= length)
{
cancellationToken.ThrowIfCancellationRequested();
if (maxByteOffset > 0 && consumed >= maxByteOffset) return;
long remaining = Math.Min(length - extentRead, maxByteOffset > 0 ? maxByteOffset - consumed : long.MaxValue);
if (remaining < _bytesPerRecord) return;
// 让每次读取边界都落在整条记录上,避免记录被拆到两次读里
int want = (int)Math.Min(buffer.Length, remaining);
want -= want % _bytesPerRecord;
if (want < _bytesPerRecord) want = _bytesPerRecord;
int got = UsnNative.ReadAt(_volume, buffer.AsSpan(0, want), start + extentRead);
if (got < _bytesPerRecord) return;
got -= got % _bytesPerRecord;
var span = buffer.AsSpan(0, got);
for (int off = 0; off + _bytesPerRecord <= got; off += _bytesPerRecord)
{
ulong recordNumber = (ulong)((consumed + off) / _bytesPerRecord);
visitor(recordNumber, span.Slice(off, _bytesPerRecord));
}
extentRead += got;
consumed += got;
onBytesRead?.Invoke(consumed);
if (got < want) return;
}
if (maxByteOffset > 0 && consumed >= maxByteOffset) return;
}
}
/// <summary>
/// 按记录号读一条 MFT 记录(走 run list 换算物理偏移)。用于增量监听时刷新单个文件的大小/时间。
/// </summary>
internal bool TryReadRecord(ulong recordNumber, Span<byte> destination)
{
if (destination.Length < _bytesPerRecord) return false;
long mftByteOffset = (long)recordNumber * _bytesPerRecord;
foreach (var (start, length) in _extents)
{
if (mftByteOffset < length)
{
int got = UsnNative.ReadAt(_volume, destination[.._bytesPerRecord], start + mftByteOffset);
return got == _bytesPerRecord;
}
mftByteOffset -= length;
}
return false;
}
}