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

1566 lines
65 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 System.Collections.Concurrent;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;
namespace FluidExplorer.Services.Search.Usn;
/// <summary>增量监听实际生效的通道。</summary>
public enum IndexWatchMode
{
/// <summary>未监听。</summary>
None,
/// <summary>FSCTL_READ_USN_JOURNAL(首选,能拿到 FRN、改名、删除等完整信息)。</summary>
UsnJournal,
/// <summary>ReadDirectoryChangesW 回退(日志不可用/无权限时;靠路径 → FileIndex 反查 FRN)。</summary>
DirectoryChanges
}
/// <summary>
/// Everything 式的单卷 NTFS 索引。
///
/// 构建:FSCTL_ENUM_USN_DATA 一次性枚举整个 MFT 拿到“全部文件名/父目录/属性”,
/// 再(若权限允许)直接读原始 $MFT 补齐真实文件大小与精确时间戳。
/// 监听:FSCTL_READ_USN_JOURNAL 阻塞式循环消费 USN 变更,墓碑标记删除、就地更新改名/大小。
/// 查询:在 Struct-of-Arrays + 大字符池上做并行扫描,候选零 string 分配,毫秒级返回 Top-N。
///
/// 线程模型:
/// * <see cref="IndexStore"/> 快照通过 <see cref="Volatile"/> 发布;结构只增不减,读侧无需持锁。
/// * 追加/扩容走 <c>_gate</c> 串行化(写极少),因此读查询永远不会被写长期阻塞。
/// </summary>
public sealed class UsnVolumeIndex : IFileIndex, IDisposable
{
private const int DefaultMaxResults = 2000;
/// <summary>
/// FSCTL_ENUM_USN_DATA 的输出缓冲。参考实现用 0x3900,但更大的缓冲能显著减少往返次数;
/// 有些驱动对超大缓冲不友好,1MB 是实践中安全的上限。
/// </summary>
private const int EnumBufferSize = 1024 * 1024;
private const int WatchBufferSize = 1024 * 1024;
private const long WatchBytesToWaitFor = 64 * 1024;
private const int MaxPathDepth = 256;
private const int MaxPathCacheEntries = 262144;
private const long FileTimeToTicksOffset = 504911232000000000L; // 1601-01-01 → 0001-01-01
private const int MaxTrackedRecords = 64 * 1024 * 1024;
private readonly string _devicePath; // \\.\C:
private readonly string _volumeRoot; // C:\
private readonly ConcurrentDictionary<ulong, string> _dirPathCache = new();
private readonly object _gate = new();
private IndexStore _store = new(1024);
private FrnMap _frnMap = new(16);
private SafeFileHandle? _volumeHandle;
private MftReader? _mftReader;
private Thread? _watchThread;
private IntPtr _watchThreadHandle;
private SafeFileHandle? _watchHandle;
private int _state = (int)IndexState.NotStarted;
private long _liveCount;
private long _changeCount;
private volatile bool _watchRequested;
private volatile bool _stopRequested;
private DirectoryChangeWatcher? _directoryWatcher;
private bool _disposed;
public UsnVolumeIndex(string volumeRoot)
{
(_volumeRoot, _devicePath) = NormalizeVolumeRoot(volumeRoot);
}
// ================================================================ IFileIndex
public string VolumeRoot => _volumeRoot;
public IndexState State => (IndexState)Volatile.Read(ref _state);
/// <summary>存活条目数(不含已打墓碑的删除项)。</summary>
public long EntryCount => Volatile.Read(ref _liveCount);
/// <summary>为 true 表示大小/时间来自原始 MFT 解析;false 表示 USN 降级(<see cref="IndexedEntry.Size"/> 多为 -1)。</summary>
public bool UsesRawMft { get; private set; }
/// <summary>降级或失败的具体原因(中文),便于 UI 直接展示。</summary>
public string? DegradationReason { get; private set; }
/// <summary>监听期间累计处理的 USN 变更条数。</summary>
public long WatchedChangeCount => Volatile.Read(ref _changeCount);
/// <summary>
/// 当前实际生效的增量监听通道:优先 USN 变更日志;日志不可用(无权限/不存在且建不了)时回退为
/// <see cref="IndexWatchMode.DirectoryChanges"/>(ReadDirectoryChangesW,不需要日志权限)。
/// </summary>
public IndexWatchMode WatchMode { get; private set; } = IndexWatchMode.None;
/// <summary>
/// 卷上没有 USN 日志时是否允许自动创建(FSCTL_CREATE_USN_JOURNAL,系统默认大小)。
/// 默认 true(Everything 的行为);创建属于对卷的持久改动,调用方若不愿改动系统可置 false。
/// 无论此值如何,本类<b>从不删除</b>日志。
/// </summary>
public bool CreateJournalIfMissing { get; set; } = true;
/// <summary>索引构建是否因为找不到 $MFT 而完全没有建立(此时只有回退监听可用)。</summary>
public bool HasIndex => Volatile.Read(ref _liveCount) > 0 || Volatile.Read(ref _store).Count > 0;
public event EventHandler<IndexStateChangedEventArgs>? StateChanged;
/// <summary>
/// 建立索引。本方法内部使用线程池,绝不阻塞调用线程,也绝不向外抛异常:
/// 权限不足 → <see cref="IndexState.RequiresElevation"/>;非 NTFS → <see cref="IndexState.Failed"/>;
/// 取消 → <see cref="IndexState.Stopped"/>。调用方一律通过 <see cref="State"/> / <see cref="StateChanged"/> 判断结果。
/// </summary>
public Task BuildAsync(IProgress<double>? progress, CancellationToken cancellationToken)
{
// 注意:这里传 CancellationToken.None —— 取消由内部协作式处理,避免调用方拿到 Canceled 状态的 Task。
return Task.Run(() => BuildCore(progress, cancellationToken), CancellationToken.None);
}
public void StartWatching()
{
_watchRequested = true;
var state = State;
// 构建还没结束的话,构建完成后会自动开始监听(见 BuildCore 末尾)
if (state is not (IndexState.Ready or IndexState.Watching)) return;
StartWatchThread();
}
public void Stop()
{
// 显式 Stop 之后不再自动开始监听,直到调用方再次 StartWatching()
_watchRequested = false;
StopWatchingInternal();
SetState(IndexState.Stopped, $"{_volumeRoot} 的索引已停止。");
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
StopWatchingInternal();
try { _volumeHandle?.Dispose(); } catch (ObjectDisposedException) { }
_volumeHandle = null;
_mftReader = null;
}
// ================================================================ 构建
private void BuildCore(IProgress<double>? progress, CancellationToken cancellationToken)
{
try
{
StopWatchingInternal();
SetState(IndexState.Building, $"正在建立 {_volumeRoot} 的文件索引…");
progress?.Report(0d);
// ---- 1) 打开卷句柄:首选 GENERIC_READ|GENERIC_WRITE,失败后逐级降权 ----
var (handle, access, openError) = OpenVolumeWithBestAccess();
if (handle is null)
{
DegradationReason = UsnNative.DescribeError(openError, $"无法打开卷 {_devicePath}");
if (openError == UsnNative.ERROR_ACCESS_DENIED)
{
SetState(IndexState.RequiresElevation,
"读取 NTFS 主文件表需要管理员权限,可点击以管理员身份重启索引服务。");
}
else
{
// 其它错误(卷未就绪 / 盘符不存在 / 设备被占用…)不是权限问题,别误导用户去提权
SetState(IndexState.Failed, $"{_volumeRoot} 索引不可用:{DegradationReason}");
}
return;
}
SafeFileHandle? old;
lock (_gate)
{
old = _volumeHandle;
_volumeHandle = handle;
}
old?.Dispose();
bool canReadData = (access & (UsnNative.GENERIC_READ | UsnNative.FILE_READ_DATA)) != 0;
// ---- 2) 卷信息:同时用来判断是不是 NTFS ----
var volumeData = default(UsnNative.NtfsVolumeDataBuffer);
bool hasVolumeData = QueryNtfsVolumeData(handle, out volumeData, out var volumeDataError);
if (!hasVolumeData)
{
var fileSystem = TryGetFileSystemName();
if (fileSystem is not null && !fileSystem.Equals("NTFS", StringComparison.OrdinalIgnoreCase))
{
DegradationReason = $"卷 {_volumeRoot} 的文件系统是 {fileSystem},不是 NTFS";
SetState(IndexState.Failed, $"{_volumeRoot} 不是 NTFS 卷(检测到 {fileSystem}),Everything 式 USN 索引仅支持 NTFS。");
return;
}
// 确实是 NTFS(或无法判定)却拿不到 NTFS 卷结构 —— 实测在非管理员下
// FSCTL_GET_NTFS_VOLUME_DATA 会返回 ERROR_INVALID_FUNCTION(1) 而不是 ERROR_ACCESS_DENIED(5),
// 所以这里必须按“权限不足”处理,否则会把正常的 NTFS 卷误判成“不是 NTFS”。
DegradationReason = UsnNative.DescribeError(volumeDataError, "FSCTL_GET_NTFS_VOLUME_DATA 失败");
SetState(IndexState.RequiresElevation,
"读取 NTFS 主文件表需要管理员权限,可点击以管理员身份重启索引服务。");
return;
}
// ---- 3) USN 变更日志:不存在就尝试创建(创建后绝不自动删除)----
bool hasJournal = EnsureUsnJournal(handle, out var journal, out var journalError, out bool journalCreated);
if (hasJournal && journalCreated)
{
SetState(IndexState.Building, "本卷原先没有 USN 变更日志,已按系统默认大小创建以便做增量监听。");
}
else if (!hasJournal)
{
DegradationReason ??= UsnNative.DescribeError(journalError, "FSCTL_QUERY_USN_JOURNAL / FSCTL_CREATE_USN_JOURNAL 失败");
}
// ---- 4) 原始 MFT 读取器(需要管理员,成功则大小/时间用真实值)----
MftReader? mft = null;
string? mftError = null;
if (canReadData && hasVolumeData)
{
mft = MftReader.TryCreate(handle, in volumeData, out mftError);
}
else if (!canReadData)
{
mftError = "卷句柄不含 FILE_READ_DATA 权限(非管理员),无法直接读取 $MFT";
}
else
{
mftError = "无法获取 NTFS 卷结构信息";
}
UsesRawMft = mft is not null;
if (mft is null)
{
DegradationReason = mftError;
}
// ---- 5) 枚举 MFT ----
int estimated = 65536;
int bytesPerRecord = 1024;
if (hasVolumeData && volumeData.BytesPerFileRecordSegment > 0)
{
bytesPerRecord = (int)volumeData.BytesPerFileRecordSegment;
if (volumeData.MftValidDataLength > 0)
{
long total = volumeData.MftValidDataLength / bytesPerRecord;
if (total > 0 && total <= MaxTrackedRecords) estimated = (int)total;
else if (total > MaxTrackedRecords) estimated = MaxTrackedRecords;
}
}
var store = new IndexStore(estimated);
var map = new FrnMap(Math.Min(estimated, 1 << 20));
int live = 0;
string? degradedNote = null;
// 枚举阶段占总进度的 65%,原始 MFT 补齐阶段占 35%
double enumWeight = mft is null ? 1.0 : 0.65;
long reportTicks = Environment.TickCount64;
// 输入按参考实现取 [FirstUsn, NextUsn];若一条都没枚举到(日志刚建好、区间过窄等),
// 再退一次 [0, MAXLONGLONG] 全量范围 —— 那条路径必然能拿到整个 MFT。
long lowUsn = hasJournal ? journal.FirstUsn : 0;
long highUsn = hasJournal ? journal.NextUsn : long.MaxValue;
long visited = EnumMft(handle, lowUsn, highUsn, estimated, cancellationToken, progress, enumWeight, ref reportTicks,
(ReadOnlySpan<char> name, ulong frn, ulong parent, uint attributes, long timeStamp) =>
{
bool isDir = (attributes & UsnNative.FILE_ATTRIBUTE_DIRECTORY) != 0;
int idx = AppendEntry(ref store, frn, parent, name, isDir,
isDir ? 0 : -1, FileTimeToTicks(timeStamp), attributes);
map.AddBuild(frn, idx);
live++;
});
if (visited == 0 && (lowUsn != 0 || highUsn != long.MaxValue))
{
degradedNote = "USN 区间 [FirstUsn, NextUsn] 未枚举到记录,已改用 [0, MAXLONGLONG] 全量枚举";
visited = EnumMft(handle, 0, long.MaxValue, estimated, cancellationToken, progress, enumWeight, ref reportTicks,
(ReadOnlySpan<char> name, ulong frn, ulong parent, uint attributes, long timeStamp) =>
{
bool isDir = (attributes & UsnNative.FILE_ATTRIBUTE_DIRECTORY) != 0;
int idx = AppendEntry(ref store, frn, parent, name, isDir,
isDir ? 0 : -1, FileTimeToTicks(timeStamp), attributes);
map.AddBuild(frn, idx);
live++;
});
}
progress?.Report(enumWeight);
// ---- 6) 用原始 MFT 给每条记录补齐真实大小/时间/属性 ----
if (mft is not null)
{
long totalBytes = mft.MftValidDataLength;
mft.Enumerate(
(ulong recordNumber, Span<byte> record) =>
{
if (!MftReader.TryParseRecord(record, mft.BytesPerSector, out var info)) return;
if (!info.InUse) return;
if (!map.TryGet(recordNumber, out int idx)) return;
if ((uint)idx >= (uint)store.Count) return;
store.ModifiedTicks[idx] = FileTimeToTicks(info.ModifiedFileTime);
store.Attributes[idx] = info.Attributes;
if (info.IsDirectory) store.Size[idx] = 0;
else if (info.Size >= 0) store.Size[idx] = info.Size;
},
totalBytes,
bytesRead =>
{
long now = Environment.TickCount64;
if (now - reportTicks < 60) return;
reportTicks = now;
double ratio = totalBytes > 0 ? Math.Min(1d, (double)bytesRead / totalBytes) : 0d;
progress?.Report(enumWeight + (1d - enumWeight) * ratio);
},
cancellationToken);
}
// ---- 7) 发布新快照 ----
// 注意发布顺序:先写 _frnMap(release),最后才用 volatile 写 _store 作为整批数据的发布点。
map.Optimize();
lock (_gate)
{
Volatile.Write(ref _frnMap, map);
Volatile.Write(ref _liveCount, live);
_mftReader = mft;
_changeCount = 0;
Volatile.Write(ref _store, store);
}
_dirPathCache.Clear();
if (degradedNote is not null)
{
DegradationReason = DegradationReason is null ? degradedNote : $"{DegradationReason};{degradedNote}";
}
progress?.Report(1d);
string summary = mft is null
? $"索引完成:{live:N0} 项(USN 降级模式,文件大小未知)。"
: $"索引完成:{live:N0} 项(已解析原始 MFT,大小/时间精确)。";
SetState(IndexState.Ready, summary);
// ---- 8) 构建前就调用过 StartWatching() 的话,这里补上 ----
// 注意:无论日志是否可用都要起监听线程 —— 线程内部会在日志不可用时回退到 ReadDirectoryChangesW。
if (_watchRequested) StartWatchThread();
}
catch (OperationCanceledException)
{
SetState(IndexState.Stopped, "索引构建已取消。");
}
catch (VolumeAccessException ex)
{
// 卷句柄能打开、但 FSCTL 被拒:同样是权限问题(非管理员下内核返回 INVALID_FUNCTION)
DegradationReason = ex.Message;
SetState(IndexState.RequiresElevation,
"读取 NTFS 主文件表需要管理员权限,可点击以管理员身份重启索引服务。");
}
catch (Exception ex)
{
DegradationReason = ex.Message;
SetState(IndexState.Failed, $"{_volumeRoot} 索引构建失败:{ex.Message}");
}
}
/// <summary>FSCTL_ENUM_USN_DATA 主循环:按 StartFileReferenceNumber 递增游标直到 ERROR_HANDLE_EOF。</summary>
private long EnumMft(
SafeFileHandle handle,
long lowUsn,
long highUsn,
int estimatedRecords,
CancellationToken cancellationToken,
IProgress<double>? progress,
double weight,
ref long reportTicks,
EnumSink sink)
{
// MFT_ENUM_DATA_V0{ StartFileReferenceNumber = 0, LowUsn = ujd.FirstUsn, HighUsn = ujd.NextUsn }
var input = new UsnNative.MftEnumDataV0
{
StartFileReferenceNumber = 0,
LowUsn = lowUsn,
HighUsn = highUsn
};
var inBuffer = new byte[Marshal.SizeOf<UsnNative.MftEnumDataV0>()];
var outBuffer = new byte[EnumBufferSize];
long visited = 0;
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
WriteStruct(inBuffer, in input);
if (!UsnNative.Ioctl(handle, UsnNative.FSCTL_ENUM_USN_DATA, inBuffer, outBuffer, out int bytesReturned))
{
int error = Marshal.GetLastWin32Error();
// 枚举到尾时内核返回 ERROR_HANDLE_EOF,属于正常结束,不是错误
if (error == UsnNative.ERROR_HANDLE_EOF) break;
throw new VolumeAccessException(error, UsnNative.DescribeError(error, "FSCTL_ENUM_USN_DATA 枚举 MFT 失败"));
}
// 输出缓冲最开头的 8 字节就是下一页游标(ulong),记录数据从 +8 处开始
if (bytesReturned <= 8) break;
ulong nextStart = BinaryPrimitives.ReadUInt64LittleEndian(outBuffer);
ParseEnumBuffer(outBuffer.AsSpan(8, bytesReturned - 8), sink, ref visited);
if (nextStart == 0 || nextStart == input.StartFileReferenceNumber) break;
input.StartFileReferenceNumber = nextStart;
long now = Environment.TickCount64;
if (now - reportTicks >= 60)
{
reportTicks = now;
double ratio = estimatedRecords > 0 ? Math.Min(1d, (double)visited / estimatedRecords) : 0d;
progress?.Report(weight * ratio);
}
}
progress?.Report(weight);
return visited;
}
/// <summary>FSCTL_ENUM_USN_DATA 输出缓冲解析回调(internal 以便被合成数据集测试直接驱动)。</summary>
internal delegate void EnumSink(ReadOnlySpan<char> name, ulong frn, ulong parent, uint attributes, long timeStamp);
internal static void ParseEnumBuffer(ReadOnlySpan<byte> records, EnumSink sink, ref long visited)
{
int offset = 0;
while (offset + UsnNative.UsnRecordV2.Size <= records.Length)
{
var header = MemoryMarshal.Read<UsnNative.UsnRecordV2>(records[offset..]);
int recordLength = (int)header.RecordLength;
if (recordLength < UsnNative.UsnRecordV2.Size || offset + recordLength > records.Length) break;
visited++;
// USN_RECORD_V2 之外(V3/V4)在 NTFS 卷上不会出现;这里只处理 V2
if (header.MajorVersion == 2
&& header.FileNameLength > 0
&& header.FileNameOffset + header.FileNameLength <= recordLength)
{
var name = MemoryMarshal.Cast<byte, char>(
records.Slice(offset + header.FileNameOffset, header.FileNameLength));
// 卷根目录的名字是 ".",父目录自指,入索引没有意义
if (!(name.Length == 1 && name[0] == '.'))
{
sink(name,
UsnNative.NormalizeFrn(header.FileReferenceNumber),
UsnNative.NormalizeFrn(header.ParentFileReferenceNumber),
header.FileAttributes,
header.TimeStamp);
}
}
offset += recordLength;
}
}
// ================================================================ 增量监听
private void StartWatchThread()
{
lock (_gate)
{
if (_disposed) return;
if (_watchThread is { IsAlive: true }) return;
_stopRequested = false;
var thread = new Thread(WatchLoop)
{
IsBackground = true,
Name = $"UsnWatch[{_volumeRoot}]",
Priority = ThreadPriority.BelowNormal
};
_watchThread = thread;
thread.Start();
}
}
/// <summary>
/// FSCTL_READ_USN_JOURNAL 阻塞式循环。BytesToWaitFor=64KB 让内核攒够一批再唤醒,
/// 每次唤醒只处理一个批次,改完一次性触发 StateChanged(Watching)。
/// </summary>
private void WatchLoop()
{
SafeFileHandle? handle = null;
try
{
_watchThreadHandle = UsnNative.OpenThread(UsnNative.THREAD_TERMINATE, false, UsnNative.GetCurrentThreadId());
var (opened, _, openError) = OpenVolumeWithBestAccess();
handle = opened;
bool needFallback;
if (handle is null)
{
ReportWatchIssue($"无法打开卷句柄用于增量监听({UsnNative.DescribeError(openError)}),改用 ReadDirectoryChangesW 回退监听。");
needFallback = true;
}
else
{
Volatile.Write(ref _watchHandle, handle);
// 日志不存在就尝试创建(需要管理员);拿不到就回退
if (EnsureUsnJournal(handle, out var journal, out int journalError, out bool created))
{
if (created)
{
ReportWatchIssue("本卷原先没有 USN 变更日志,已按系统默认大小创建(不会自动删除)以便做增量监听。");
}
needFallback = RunUsnJournalLoop(handle, in journal);
}
else
{
ReportWatchIssue($"USN 变更日志不可用({UsnNative.DescribeError(journalError)}),改用 ReadDirectoryChangesW 回退监听。");
needFallback = true;
}
}
if (needFallback && !_stopRequested) RunDirectoryChangesLoop();
}
catch (Exception ex)
{
if (!_stopRequested) SetState(IndexState.Ready, $"实时监听异常退出:{ex.Message}");
}
finally
{
var watchHandle = Interlocked.Exchange(ref _watchHandle, null);
watchHandle?.Dispose();
handle?.Dispose();
var threadHandle = Interlocked.Exchange(ref _watchThreadHandle, IntPtr.Zero);
if (threadHandle != IntPtr.Zero) UsnNative.CloseHandle(threadHandle);
}
}
/// <summary>
/// FSCTL_READ_USN_JOURNAL 阻塞循环。返回 true 表示「USN 通道不可用,请回退到 RDCW」;
/// false 表示正常收到停止信号退出。
/// </summary>
private bool RunUsnJournalLoop(SafeFileHandle handle, in UsnNative.UsnJournalDataV0 journal)
{
var read = new UsnNative.ReadUsnJournalDataV0
{
StartUsn = journal.NextUsn,
ReasonMask = UsnNative.USN_REASON_ANY,
ReturnOnlyOnClose = 0,
Timeout = 0, // 0 = 无数据时无限等待,靠 CancelSynchronousIo 打断
BytesToWaitFor = WatchBytesToWaitFor,
UsnJournalID = journal.UsnJournalID
};
var inBuffer = new byte[Marshal.SizeOf<UsnNative.ReadUsnJournalDataV0>()];
var outBuffer = new byte[WatchBufferSize];
WatchMode = IndexWatchMode.UsnJournal;
SetState(IndexState.Watching, $"{_volumeRoot} 索引已就绪,正在通过 USN 日志实时监听文件变更。");
while (!_stopRequested)
{
WriteStruct(inBuffer, in read);
bool ok = UsnNative.Ioctl(handle, UsnNative.FSCTL_READ_USN_JOURNAL, inBuffer, outBuffer, out int bytesReturned);
if (!ok)
{
int error = Marshal.GetLastWin32Error();
if (_stopRequested
|| error is UsnNative.ERROR_OPERATION_ABORTED or UsnNative.ERROR_CANCELLED or UsnNative.ERROR_INVALID_HANDLE)
{
return false;
}
// 日志被删除/重建:重新确保日志可用后继续(期间可能漏掉少量变更,重建索引可修正)
if (error is UsnNative.ERROR_JOURNAL_DELETE_IN_PROGRESS
or UsnNative.ERROR_JOURNAL_NOT_ACTIVE
or UsnNative.ERROR_JOURNAL_ENTRY_DELETED)
{
if (!EnsureUsnJournal(handle, out var refreshed, out _, out _))
{
ReportWatchIssue("USN 日志已被删除且无法重新获取,改用 ReadDirectoryChangesW 回退监听。");
return true;
}
read.StartUsn = refreshed.FirstUsn;
read.UsnJournalID = refreshed.UsnJournalID;
continue;
}
ReportWatchIssue($"USN 实时监听中断({UsnNative.DescribeError(error)}),改用 ReadDirectoryChangesW 回退监听。");
return true;
}
if (bytesReturned <= 8) continue;
read.StartUsn = BinaryPrimitives.ReadInt64LittleEndian(outBuffer);
int applied = ApplyChanges(outBuffer.AsSpan(8, bytesReturned - 8));
if (applied > 0)
{
Interlocked.Add(ref _changeCount, applied);
SetState(IndexState.Watching, $"{_volumeRoot} 索引已更新({applied} 条变更)。");
}
}
return false;
}
/// <summary>ReadDirectoryChangesW 回退监听(不需要 USN 日志权限)。</summary>
private void RunDirectoryChangesLoop()
{
var watcher = new DirectoryChangeWatcher(this);
if (!watcher.TryOpen(out var error))
{
WatchMode = IndexWatchMode.None;
SetState(IndexState.Ready, $"实时监听不可用:{error}");
return;
}
_directoryWatcher = watcher;
WatchMode = IndexWatchMode.DirectoryChanges;
SetState(IndexState.Watching,
$"{_volumeRoot} 索引已就绪(USN 日志不可用,回退为 ReadDirectoryChangesW 实时监听)。");
try
{
watcher.Run();
}
finally
{
_directoryWatcher = null;
}
if (!_stopRequested)
{
WatchMode = IndexWatchMode.None;
SetState(IndexState.Ready, "实时监听已停止。");
}
}
/// <summary>应用一批 USN 变更记录,返回处理条数(internal 以便被合成数据集测试直接驱动)。</summary>
internal int ApplyChanges(ReadOnlySpan<byte> records)
{
int offset = 0;
int applied = 0;
while (offset + UsnNative.UsnRecordV2.Size <= records.Length)
{
var header = MemoryMarshal.Read<UsnNative.UsnRecordV2>(records[offset..]);
int recordLength = (int)header.RecordLength;
if (recordLength < UsnNative.UsnRecordV2.Size || offset + recordLength > records.Length) break;
if (header.MajorVersion == 2)
{
ReadOnlySpan<char> name = default;
if (header.FileNameLength > 0 && header.FileNameOffset + header.FileNameLength <= recordLength)
{
name = MemoryMarshal.Cast<byte, char>(
records.Slice(offset + header.FileNameOffset, header.FileNameLength));
}
ApplyOne(in header, name);
applied++;
}
offset += recordLength;
}
return applied;
}
private void ApplyOne(in UsnNative.UsnRecordV2 header, ReadOnlySpan<char> name)
{
ulong frn = UsnNative.NormalizeFrn(header.FileReferenceNumber);
ulong parent = UsnNative.NormalizeFrn(header.ParentFileReferenceNumber);
uint reason = header.Reason;
var store = Volatile.Read(ref _store);
bool exists = _frnMap.TryGet(frn, out int index) && (uint)index < (uint)store.Count;
// ---- 删除:打墓碑,不搬移数组 ----
if ((reason & UsnNative.USN_REASON_FILE_DELETE) != 0)
{
if (exists && (store.Flags[index] & IndexStore.FlagDeleted) == 0)
{
store.Flags[index] |= IndexStore.FlagDeleted;
Interlocked.Decrement(ref _liveCount);
}
return;
}
bool isDir = (header.FileAttributes & UsnNative.FILE_ATTRIBUTE_DIRECTORY) != 0;
if (!exists)
{
if (name.Length == 0) return;
index = AppendEntryThreadSafe(frn, parent, name, isDir,
isDir ? 0 : -1, FileTimeToTicks(header.TimeStamp), header.FileAttributes);
Interlocked.Increment(ref _liveCount);
RefreshFromDisk(frn, index);
return;
}
// ---- 改名 / 移动:就地改名字引用与父目录(NameRef 单次 8 字节原子写,读侧不会看到半新半旧)----
if (name.Length > 0)
{
var current = store.GetName(index);
if (!current.SequenceEqual(name))
{
if (name.Length > NamePool.MaxNameLength) name = name[..NamePool.MaxNameLength];
int nameOffset = store.Names.Add(name);
Volatile.Write(ref store.NameRef[index], NamePool.Pack(nameOffset, name.Length));
store.ParentFrn[index] = parent;
}
else if (store.ParentFrn[index] != parent)
{
store.ParentFrn[index] = parent;
}
}
// 复活:之前被标记删除的记录号又被新文件复用了
if ((store.Flags[index] & IndexStore.FlagDeleted) != 0 && (reason & UsnNative.USN_REASON_FILE_CREATE) != 0)
{
store.Flags[index] &= unchecked((byte)~IndexStore.FlagDeleted);
Interlocked.Increment(ref _liveCount);
}
RefreshFromDisk(frn, index);
}
/// <summary>用原始 MFT 刷新单个条目的大小/时间/属性;没有 MFT 时退化为用 USN 的变更时间。</summary>
private void RefreshFromDisk(ulong frn, int index)
{
var store = Volatile.Read(ref _store);
if ((uint)index >= (uint)store.Count) return;
var mft = _mftReader;
if (mft is null) return; // USN 降级模式:大小保持 -1(未知),时间已在创建/改名时写入
Span<byte> buffer = mft.BytesPerRecord <= 4096 ? stackalloc byte[4096] : new byte[mft.BytesPerRecord];
if (!mft.TryReadRecord(frn, buffer)) return;
if (!MftReader.TryParseRecord(buffer, mft.BytesPerSector, out var info)) return;
if (info.IsDirectory) store.Size[index] = 0;
else if (info.Size >= 0) store.Size[index] = info.Size;
if (info.ModifiedFileTime > 0) store.ModifiedTicks[index] = FileTimeToTicks(info.ModifiedFileTime);
if (info.Attributes != 0) store.Attributes[index] = info.Attributes;
}
// ================================================================ RDCW 回退通道的落地接口
/// <summary>
/// 由 <see cref="DirectoryChangeWatcher"/> 调用:按 FRN 写入/更新一条记录(RDCW 只有路径,FRN 来自句柄)。
/// </summary>
internal void UpsertFromFileSystem(ulong frn, ulong parentFrn, ReadOnlySpan<char> name,
bool isDirectory, long size, long modifiedTicks, uint attributes)
{
frn = UsnNative.NormalizeFrn(frn);
parentFrn = UsnNative.NormalizeFrn(parentFrn);
var store = Volatile.Read(ref _store);
if (_frnMap.TryGet(frn, out int index) && (uint)index < (uint)store.Count)
{
if (name.Length > 0)
{
var current = store.GetName(index);
if (!current.SequenceEqual(name))
{
if (name.Length > NamePool.MaxNameLength) name = name[..NamePool.MaxNameLength];
int nameOffset = store.Names.Add(name);
Volatile.Write(ref store.NameRef[index], NamePool.Pack(nameOffset, name.Length));
}
if (parentFrn != 0) store.ParentFrn[index] = parentFrn;
}
store.Size[index] = isDirectory ? 0 : size;
if (modifiedTicks > 0) store.ModifiedTicks[index] = modifiedTicks;
if (attributes != 0) store.Attributes[index] = attributes;
store.Flags[index] = (byte)((store.Flags[index] & IndexStore.FlagDeleted)
| (isDirectory ? IndexStore.FlagDirectory : 0));
// 记录号被复用:之前打的墓碑要撤掉
if ((store.Flags[index] & IndexStore.FlagDeleted) != 0)
{
store.Flags[index] &= unchecked((byte)~IndexStore.FlagDeleted);
Interlocked.Increment(ref _liveCount);
}
Interlocked.Increment(ref _changeCount);
return;
}
if (name.Length == 0) return;
AppendEntryThreadSafe(frn, parentFrn, name, isDirectory, isDirectory ? 0 : size, modifiedTicks, attributes);
Interlocked.Increment(ref _liveCount);
Interlocked.Increment(ref _changeCount);
}
/// <summary>
/// 由 <see cref="DirectoryChangeWatcher"/> 调用:对某个目录做「磁盘现状 vs 索引」对账,
/// 把索引里存在、磁盘上已消失的孩子打上墓碑。返回打墓碑的条数。
/// RDCW 的删除事件只给路径、无法反查 FRN,所以删除统一走这条对账路径。
/// </summary>
internal int ResyncDirectoryFromDisk(string directoryPath)
{
if (!UsnNative.TryStatPath(directoryPath, out var directoryInfo)) return 0;
ulong directoryFrn = UsnNative.NormalizeFrn(directoryInfo.FileIndex);
if (directoryFrn == 0) return 0;
HashSet<string> liveNames;
try
{
liveNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in Directory.EnumerateFileSystemEntries(directoryPath))
{
var leaf = Path.GetFileName(entry);
if (leaf.Length > 0) liveNames.Add(leaf);
}
}
catch (IOException)
{
return 0;
}
catch (UnauthorizedAccessException)
{
return 0;
}
var store = Volatile.Read(ref _store);
int tombstoned = 0;
for (int i = 0; i < store.Count; i++)
{
if ((store.Flags[i] & IndexStore.FlagDeleted) != 0) continue;
if (store.ParentFrn[i] != directoryFrn) continue;
var name = store.GetName(i);
if (name.Length == 0) continue;
if (liveNames.Contains(name.ToString())) continue;
store.Flags[i] |= IndexStore.FlagDeleted;
Interlocked.Decrement(ref _liveCount);
Interlocked.Increment(ref _changeCount);
tombstoned++;
}
return tombstoned;
}
/// <summary>把监听通道的异常/进展以 StateChanged(Watching) 的形式抛给 UI。</summary>
internal void ReportWatchIssue(string? message, int tombstoned = 0)
{
if (message is null && tombstoned <= 0) return;
var text = message is null
? $"{_volumeRoot} 索引已更新({tombstoned} 项删除)。"
: tombstoned > 0
? $"{message}({tombstoned} 项删除)"
: message;
SetState(WatchMode == IndexWatchMode.None ? IndexState.Ready : IndexState.Watching, text);
}
private void StopWatchingInternal() {
_stopRequested = true;
Thread? thread;
lock (_gate)
{
thread = _watchThread;
_watchThread = null;
}
var threadHandle = _watchThreadHandle;
if (threadHandle != IntPtr.Zero) UsnNative.CancelSynchronousIo(threadHandle);
// 回退通道用的是目录句柄上的 ReadDirectoryChangesW,同样用 CancelSynchronousIo 打断,外加关句柄兜底
_directoryWatcher?.RequestStop();
if (thread is { IsAlive: true })
{
if (!thread.Join(1500))
{
// 兜底:CancelSynchronousIo 没生效时直接关掉卷句柄,让阻塞中的 DeviceIoControl 立刻失败返回
var watchHandle = Interlocked.Exchange(ref _watchHandle, null);
watchHandle?.Dispose();
UsnNative.CancelSynchronousIo(threadHandle);
thread.Join(1500);
}
}
}
// ================================================================ 查询
public IEnumerable<IndexedEntry> Query(SearchQuery query, int maxResults, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(query);
var store = Volatile.Read(ref _store);
int count = store.Count;
int k = maxResults > 0 ? maxResults : DefaultMaxResults;
if (count == 0) return [];
var spec = new QuerySpec(query);
// 并行分片:分片数按 CPU 核数放大,同时限制每片 Top-K 堆的总内存
int partitions = Math.Min(count, Math.Max(1, Environment.ProcessorCount * 4));
if (partitions > 1 && (long)k * partitions > 4_000_000L)
{
partitions = (int)Math.Max(1, 4_000_000L / k);
}
int chunk = (count + partitions - 1) / partitions;
var partials = new TopK?[partitions];
var options = new ParallelOptions
{
CancellationToken = cancellationToken,
MaxDegreeOfParallelism = Environment.ProcessorCount
};
Parallel.For(0, partitions, options, p =>
{
int start = p * chunk;
int end = Math.Min(count, start + chunk);
if (start >= end) return;
var top = new TopK(k);
for (int i = start; i < end; i++)
{
long key = Probe(store, i, in spec);
if (key >= 0) top.Add(key, i);
}
partials[p] = top;
});
var merged = new TopK(k);
foreach (var partial in partials)
{
partial?.DrainInto(merged);
}
var hits = merged.ToSortedArray();
var results = new List<IndexedEntry>(hits.Length);
foreach (var (key, index) in hits)
{
cancellationToken.ThrowIfCancellationRequested();
var entry = Materialize(store, index);
if (entry is not null) results.Add(entry.Value);
}
return results;
}
/// <summary>
/// 单条候选的过滤 + 打分。返回 &gt;=0 的排序键(越小越好),-1 表示被过滤掉。
/// 过滤顺序刻意从最便宜的条件到最贵的:先看标志位/大小/时间,再做名字匹配,最后才解析路径。
/// </summary>
private long Probe(IndexStore store, int i, in QuerySpec spec)
{
byte flags = store.Flags[i];
if ((flags & IndexStore.FlagDeleted) != 0) return -1;
bool isDir = (flags & IndexStore.FlagDirectory) != 0;
if (spec.DirectoriesOnly && !isDir) return -1;
if (spec.FilesOnly && isDir) return -1;
long size = store.Size[i];
if (size >= 0)
{
// 注意:Size < 0 表示“未知”(USN 降级),必须放行,绝不能当成 0 字节
if (spec.MinSize != long.MinValue && size < spec.MinSize) return -1;
if (spec.MaxSize != long.MaxValue && size > spec.MaxSize) return -1;
}
long ticks = store.ModifiedTicks[i];
if (ticks > 0)
{
if (spec.ModifiedAfterTicks > 0 && ticks < spec.ModifiedAfterTicks) return -1;
if (spec.ModifiedBeforeTicks > 0 && ticks > spec.ModifiedBeforeTicks) return -1;
}
var name = store.GetName(i);
if (spec.Extensions.Length > 0 && !MatchExtension(name, spec.Extensions)) return -1;
if (spec.ExcludeExtensions.Length > 0 && MatchExtension(name, spec.ExcludeExtensions)) return -1;
string? path = null;
if (!MatchNameAll(name, in spec, out int rank))
{
// 名字没全中:只有允许整路径匹配时才付出解析路径的代价
if (!spec.MatchWholePath) return -1;
path = ResolveFullPath(i);
if (path is null || !MatchNameAll(path.AsSpan(), in spec, out rank)) return -1;
}
if (spec.PathFilter is not null)
{
path ??= ResolveFullPath(i);
if (path is null) return -1;
if (path.IndexOf(spec.PathFilter, StringComparison.OrdinalIgnoreCase) < 0) return -1;
}
return PackKey(rank, name.Length, i);
}
private static long PackKey(int rank, int nameLength, int index)
=> ((long)rank << 56) | ((long)Math.Min(nameLength, 0xFFFFFF) << 32) | (uint)index;
/// <summary>IncludeTerms / IncludeRegexLike 全部命中才算通过;rank 取最差的一档。</summary>
private static bool MatchNameAll(ReadOnlySpan<char> name, in QuerySpec spec, out int rank)
{
rank = 0;
var terms = spec.IncludeTerms;
for (int t = 0; t < terms.Length; t++)
{
int r = MatchLiteral(name, terms[t]);
if (r < 0) return false;
if (r > rank) rank = r;
}
var patterns = spec.Wildcards;
for (int t = 0; t < patterns.Length; t++)
{
var pattern = patterns[t];
if (!WildcardMatcher.IsMatch(name, pattern)) return false;
int r = WildcardRank(name, pattern);
if (r > rank) rank = r;
}
var excludes = spec.ExcludeTerms;
for (int t = 0; t < excludes.Length; t++)
{
if (name.IndexOf(excludes[t], StringComparison.OrdinalIgnoreCase) >= 0) return false;
}
return true;
}
/// <summary>字面量子串匹配并给出优先级:完全相等(0) &gt; 前缀(1) &gt; 词边界(2) &gt; 普通子串(3)。</summary>
private static int MatchLiteral(ReadOnlySpan<char> name, string term)
{
int idx = name.IndexOf(term, StringComparison.OrdinalIgnoreCase);
if (idx < 0) return -1;
if (idx != 0) return !char.IsLetterOrDigit(name[idx - 1]) ? 2 : 3;
return name.Length == term.Length ? 0 : 1;
}
private static int WildcardRank(ReadOnlySpan<char> name, ReadOnlySpan<char> pattern)
{
var prefix = WildcardMatcher.LiteralPrefix(pattern);
if (prefix.Length == 0) return 3;
if (!name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return 3;
return name.Length == prefix.Length ? 0 : 1;
}
/// <summary>扩展名比较:忽略大小写,且允许查询侧不带点("log" 与 ".LOG" 都算命中)。</summary>
private static bool MatchExtension(ReadOnlySpan<char> name, string[] extensions)
{
int dot = name.LastIndexOf('.');
if (dot <= 0 || dot == name.Length - 1) return false;
var extension = name[(dot + 1)..];
foreach (var candidate in extensions)
{
if (extension.Equals(candidate, StringComparison.OrdinalIgnoreCase)) return true;
}
return false;
}
private IndexedEntry? Materialize(IndexStore store, int index)
{
if ((uint)index >= (uint)store.Count) return null;
long nameRef = Volatile.Read(ref store.NameRef[index]);
var nameSpan = store.Names.Get(NamePool.UnpackOffset(nameRef), NamePool.UnpackLength(nameRef));
if (nameSpan.Length == 0) return null;
bool isDir = (store.Flags[index] & IndexStore.FlagDirectory) != 0;
long ticks = store.ModifiedTicks[index];
var modified = ticks > 0 ? new DateTime(ticks, DateTimeKind.Utc) : default;
return new IndexedEntry(
store.Frn[index],
store.ParentFrn[index],
nameSpan.ToString(),
isDir,
store.Size[index],
modified,
store.Attributes[index])
{
FullPath = ResolveFullPath(index)
};
}
// ================================================================ 路径解析
public bool TryResolvePath(ulong frn, out string fullPath)
{
fullPath = string.Empty;
if (!_frnMap.TryGet(frn, out int index)) return false;
var store = Volatile.Read(ref _store);
if ((uint)index >= (uint)store.Count) return false;
var path = ResolveFullPath(index);
if (path is null) return false;
fullPath = path;
return true;
}
/// <summary>条目 → 完整路径。父链缺失或 FRN==0 时以 <see cref="VolumeRoot"/> 终止;彻底失败返回 null。</summary>
private string? ResolveFullPath(int index)
{
var store = Volatile.Read(ref _store);
if ((uint)index >= (uint)store.Count) return null;
bool isDir = (store.Flags[index] & IndexStore.FlagDirectory) != 0;
if (isDir) return ResolveDirectoryPath(index);
var name = store.GetName(index);
if (name.Length == 0) return null;
ulong frn = store.Frn[index];
ulong parent = store.ParentFrn[index];
if (parent == 0 || parent == frn) return string.Concat(VolumeRoot, name);
if (!_frnMap.TryGet(parent, out int parentIndex) || (uint)parentIndex >= (uint)store.Count)
{
return string.Concat(VolumeRoot, name);
}
var directory = ResolveDirectoryPath(parentIndex);
return directory is null ? null : string.Concat(directory, name);
}
/// <summary>
/// 目录条目 → 以 '\' 结尾的完整路径。父链结果整体缓存(键是目录 FRN),
/// 上层目录一旦算过,子目录拼一次字符串即可 —— 这是路径过滤能保持毫秒级的关键。
/// </summary>
private string? ResolveDirectoryPath(int index)
{
var store = Volatile.Read(ref _store);
if ((uint)index >= (uint)store.Count) return null;
ulong startFrn = store.Frn[index];
if (_dirPathCache.TryGetValue(startFrn, out var cached))
{
return cached.Length == 0 ? null : cached;
}
List<int>? chain = null;
string? prefix = null;
int current = index;
for (int depth = 0; depth < MaxPathDepth; depth++)
{
ulong frn = store.Frn[current];
ulong parent = store.ParentFrn[current];
// 终止条件(三条,都不硬编码根 FRN):
// (a) 父指向自身 —— NTFS 卷根(MFT 记录 5)的特征;
// (b) parent == 0(没有父);
// (c) 父不在映射里(父已被删/记录号已复用)。
// 命中任一条就以卷符为前缀结束,并且当前这一层(卷根自己)不写进路径。
if (parent == 0 || parent == frn)
{
prefix = VolumeRoot;
break;
}
(chain ??= new List<int>(8)).Add(current);
if (_dirPathCache.TryGetValue(parent, out var parentPath))
{
if (parentPath.Length == 0)
{
CacheDirectoryPath(startFrn, string.Empty);
return null;
}
prefix = parentPath;
break;
}
if (!_frnMap.TryGet(parent, out int parentIndex) || (uint)parentIndex >= (uint)store.Count)
{
prefix = VolumeRoot; // 父项缺失 → 以卷根终止
break;
}
current = parentIndex;
}
if (prefix is null)
{
// 超过深度上限:父链成环或数据损坏
CacheDirectoryPath(startFrn, string.Empty);
return null;
}
var builder = new StringBuilder(prefix, prefix.Length + chain!.Count * 16);
for (int i = chain.Count - 1; i >= 0; i--)
{
var segment = store.GetName(chain[i]);
if (segment.Length == 0) continue;
// 卷根目录在枚举里可能叫 "."(也可能是卷标),两种都不应该出现在路径里
if (segment.Length == 1 && segment[0] == '.') continue;
if (segment.Length == 2 && segment[0] == '.' && segment[1] == '.') continue;
builder.Append(segment).Append('\\');
}
var path = builder.ToString();
CacheDirectoryPath(startFrn, path);
return path;
}
private void CacheDirectoryPath(ulong frn, string path)
{
if (_dirPathCache.Count >= MaxPathCacheEntries) _dirPathCache.Clear();
_dirPathCache[frn] = path;
}
// ================================================================ 卷 / 日志
/// <summary>
/// 打开卷句柄。
///
/// 首选参数严格按参考实现(Everything)来:
/// CreateFileW(@"\\.\C:", GENERIC_READ|GENERIC_WRITE,
/// FILE_SHARE_READ|FILE_SHARE_WRITE, null, OPEN_EXISTING, FILE_ATTRIBUTE_READONLY, null)
/// —— 共享模式必须带 FILE_SHARE_WRITE;dwFlagsAndAttributes 用 FILE_ATTRIBUTE_READONLY(NORMAL 反而可能开不了)。
///
/// 之后逐级降权重试是为了「非管理员也能跑」:普通用户开不了读写卷句柄,
/// 但常常仍能拿到 FILE_READ_ATTRIBUTES 的句柄,从而完成 MFT 枚举(只是读不了原始数据 → 大小未知)。
/// </summary>
private (SafeFileHandle? Handle, uint Access, int Error) OpenVolumeWithBestAccess()
{
ReadOnlySpan<uint> accesses =
[
UsnNative.GENERIC_READ | UsnNative.GENERIC_WRITE,
UsnNative.GENERIC_READ,
UsnNative.FILE_READ_DATA | UsnNative.FILE_READ_ATTRIBUTES,
UsnNative.FILE_READ_ATTRIBUTES,
0
];
const uint share = UsnNative.FILE_SHARE_READ | UsnNative.FILE_SHARE_WRITE | UsnNative.FILE_SHARE_DELETE;
const uint flags = UsnNative.FILE_ATTRIBUTE_READONLY;
int lastError = 0;
foreach (var access in accesses)
{
var handle = UsnNative.CreateFileW(
_devicePath, access, share, IntPtr.Zero, UsnNative.OPEN_EXISTING, flags, IntPtr.Zero);
if (!handle.IsInvalid) return (handle, access, 0);
lastError = Marshal.GetLastWin32Error();
handle.Dispose();
}
return (null, 0, lastError);
}
private static bool QueryNtfsVolumeData(SafeFileHandle handle, out UsnNative.NtfsVolumeDataBuffer data, out int error)
{
data = default;
var buffer = new byte[Marshal.SizeOf<UsnNative.NtfsVolumeDataBuffer>()];
if (UsnNative.Ioctl(handle, UsnNative.FSCTL_GET_NTFS_VOLUME_DATA, [], buffer, out int bytes) && bytes >= buffer.Length)
{
data = MemoryMarshal.Read<UsnNative.NtfsVolumeDataBuffer>(buffer);
error = 0;
return true;
}
error = Marshal.GetLastWin32Error();
return false;
}
private static bool QueryUsnJournal(SafeFileHandle handle, out UsnNative.UsnJournalDataV0 journal, out int error)
{
journal = default;
var buffer = new byte[Marshal.SizeOf<UsnNative.UsnJournalDataV0>()];
if (UsnNative.Ioctl(handle, UsnNative.FSCTL_QUERY_USN_JOURNAL, [], buffer, out int bytes) && bytes >= buffer.Length)
{
journal = MemoryMarshal.Read<UsnNative.UsnJournalDataV0>(buffer);
error = 0;
return journal.UsnJournalID != 0;
}
error = Marshal.GetLastWin32Error();
return false;
}
/// <summary>
/// 确保卷上有 USN 变更日志可用:先 QUERY,拿不到(ERROR_JOURNAL_NOT_ACTIVE / ERROR_INVALID_FUNCTION 等)
/// 就尝试 FSCTL_CREATE_USN_JOURNAL 创建(MaximumSize/AllocationDelta 传 0 = 用系统默认值)。
///
/// 重要约定:**创建后绝不自动删除**(删掉就再也没法做增量监听了)。
/// 只有调用方显式调用 <see cref="DeleteUsnJournal"/> 才会删,且 DeleteFlags 用 USN_DELETE_FLAG_DELETE。
/// </summary>
private bool EnsureUsnJournal(SafeFileHandle handle, out UsnNative.UsnJournalDataV0 journal, out int error, out bool created)
{
created = false;
if (QueryUsnJournal(handle, out journal, out error)) return true;
if (!CreateJournalIfMissing) return false;
// 创建日志是需要管理员权限的操作;失败时把错误码原样带回给调用方分类
var input = new byte[Marshal.SizeOf<UsnNative.CreateUsnJournalData>()];
WriteStruct(input, new UsnNative.CreateUsnJournalData { MaximumSize = 0, AllocationDelta = 0 });
var output = new byte[16]; // 该 FSCTL 无输出,给个非空缓冲更保守
if (!UsnNative.Ioctl(handle, UsnNative.FSCTL_CREATE_USN_JOURNAL, input, output, out _))
{
error = Marshal.GetLastWin32Error();
return false;
}
created = true;
return QueryUsnJournal(handle, out journal, out error);
}
/// <summary>
/// 显式删除本卷的 USN 变更日志(DeleteFlags = USN_DELETE_FLAG_DELETE)。
/// 只有「用户主动关闭本卷索引并愿意放弃增量能力」时才调用;内部任何流程都不会自动调用它。
/// </summary>
public bool DeleteUsnJournal()
{
var (handle, _, _) = OpenVolumeWithBestAccess();
if (handle is null) return false;
try
{
if (!QueryUsnJournal(handle, out var journal, out _)) return false;
var input = new byte[Marshal.SizeOf<UsnNative.DeleteUsnJournalData>()];
WriteStruct(input, new UsnNative.DeleteUsnJournalData
{
UsnJournalID = journal.UsnJournalID,
DeleteFlags = UsnNative.USN_DELETE_FLAG_DELETE
});
var output = new byte[16];
return UsnNative.Ioctl(handle, UsnNative.FSCTL_DELETE_USN_JOURNAL, input, output, out _);
}
finally
{
handle.Dispose();
}
}
/// <summary>通过 FSCTL_GET_NTFS_FILE_RECORD 直接取一条 MFT 记录(备用通道,不依赖 ReadFile 权限)。</summary>
internal static bool TryGetNtfsFileRecord(SafeFileHandle handle, ulong recordNumber, byte[] buffer, out int recordLength)
{
recordLength = 0;
var input = new byte[Marshal.SizeOf<UsnNative.NtfsFileRecordInputBuffer>()];
WriteStruct(input, new UsnNative.NtfsFileRecordInputBuffer
{
FileReferenceNumber = UsnNative.NormalizeFrn(recordNumber)
});
if (!UsnNative.Ioctl(handle, UsnNative.FSCTL_GET_NTFS_FILE_RECORD, input, buffer, out int bytes)) return false;
if (bytes < Marshal.SizeOf<UsnNative.NtfsFileRecordOutputBuffer>()) return false;
recordLength = (int)BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(8));
return recordLength > 0;
}
// ================================================================ 工具
private static unsafe void WriteStruct<T>(byte[] buffer, in T value) where T : unmanaged
{
fixed (byte* p = buffer)
{
*(T*)p = value;
}
}
/// <summary>
/// 追加一条记录;容量不足时 copy-on-write 扩容。返回新下标(扩容后 store 引用可能被替换,故用 ref)。
/// 名字池始终取自 store.Names —— 绝不能让“写名字的池”和“快照的池”变成两个对象。
/// </summary>
private static int AppendEntry(ref IndexStore store, ulong frn, ulong parent,
ReadOnlySpan<char> name, bool isDirectory, long size, long modifiedTicks, uint attributes)
{
if (store.Count >= store.Capacity) store = IndexStore.Grow(store, store.Count + 1);
if (name.Length > NamePool.MaxNameLength) name = name[..NamePool.MaxNameLength];
int index = store.Count;
int nameOffset = store.Names.Add(name);
store.Frn[index] = frn;
store.ParentFrn[index] = parent;
store.NameRef[index] = NamePool.Pack(nameOffset, name.Length);
store.Size[index] = size;
store.ModifiedTicks[index] = modifiedTicks;
store.Attributes[index] = attributes;
store.Flags[index] = isDirectory ? IndexStore.FlagDirectory : (byte)0;
// 最后才发布条数:保证读侧看到的下标一定已经写满字段
store.Count = index + 1;
return index;
}
private int AppendEntryThreadSafe(ulong frn, ulong parent, ReadOnlySpan<char> name,
bool isDirectory, long size, long modifiedTicks, uint attributes)
{
lock (_gate)
{
var store = Volatile.Read(ref _store);
int index = AppendEntry(ref store, frn, parent, name, isDirectory, size, modifiedTicks, attributes);
Volatile.Write(ref _store, store);
_frnMap.Set(frn, index);
return index;
}
}
private void SetState(IndexState state, string? message = null)
{
Volatile.Write(ref _state, (int)state);
var handler = StateChanged;
if (handler is null) return;
try
{
handler(this, new IndexStateChangedEventArgs(state, message));
}
catch
{
// UI 回调里的异常绝不能影响索引线程
}
}
/// <summary>FILETIME(1601-01-01 起) → .NET ticks(0001-01-01 起);非法值一律变成 0(= 未知)。</summary>
internal static long FileTimeToTicks(long fileTime)
{
if (fileTime <= 0) return 0;
long ticks = fileTime + FileTimeToTicksOffset;
if (ticks < 0 || ticks > DateTime.MaxValue.Ticks) return 0;
return ticks;
}
/// <summary>查询侧时间边界 → UTC ticks(SearchQuery 里的日期都是本地时间语义)。</summary>
private static long ToUtcTicks(DateTime? value)
{
if (value is not { } date) return 0;
return date.Kind == DateTimeKind.Utc ? date.Ticks : date.ToUniversalTime().Ticks;
}
/// <summary>
/// 不带任何权限地用 DriveInfo 读出文件系统名("NTFS"/"FAT32"/"exFAT"…)。
/// 关键作用:把“不是 NTFS”和“没有权限”这两种都会返回 ERROR_INVALID_FUNCTION 的情况区分开。
/// </summary>
private string? TryGetFileSystemName()
{
try
{
if (_volumeRoot.Length >= 2 && _volumeRoot[1] == ':') return new DriveInfo(_volumeRoot).DriveFormat;
}
catch (IOException)
{
return null;
}
catch (UnauthorizedAccessException)
{
return null;
}
return null;
}
private static (string Root, string Device) NormalizeVolumeRoot(string volumeRoot) {
ArgumentException.ThrowIfNullOrWhiteSpace(volumeRoot);
var text = volumeRoot.Trim();
// \\?\Volume{GUID}\ 形式:直接用它当设备路径
if (text.StartsWith(@"\\?\", StringComparison.Ordinal) || text.StartsWith(@"\\.\", StringComparison.Ordinal))
{
var device = text.TrimEnd('\\');
return (device + "\\", device);
}
var letter = char.ToUpperInvariant(text[0]);
if (letter is < 'A' or > 'Z')
{
throw new ArgumentException($"卷根必须是盘符形式(如 \"C:\\\")或 \\\\?\\Volume{{GUID}}\\,收到:{volumeRoot}", nameof(volumeRoot));
}
return ($"{letter}:\\", $@"\\.\{letter}:");
}
// ================================================================ 查询预处理 / Top-K
/// <summary>把 SearchQuery 里每轮循环都要读的字段摊平,避免热路径上反复做属性/接口调用。</summary>
private readonly struct QuerySpec
{
internal readonly string[] IncludeTerms;
internal readonly string[] ExcludeTerms;
internal readonly string[] Wildcards;
internal readonly string[] Extensions;
internal readonly string[] ExcludeExtensions;
internal readonly string? PathFilter;
internal readonly bool MatchWholePath;
internal readonly bool DirectoriesOnly;
internal readonly bool FilesOnly;
internal readonly long MinSize;
internal readonly long MaxSize;
internal readonly long ModifiedAfterTicks;
internal readonly long ModifiedBeforeTicks;
internal QuerySpec(SearchQuery query)
{
IncludeTerms = [.. query.IncludeTerms];
ExcludeTerms = [.. query.ExcludeTerms];
Wildcards = [.. query.IncludeRegexLike];
Extensions = [.. query.Extensions];
ExcludeExtensions = [.. query.ExcludeExtensions];
PathFilter = string.IsNullOrEmpty(query.PathFilter) ? null : query.PathFilter;
MatchWholePath = query.MatchWholePath || PathFilter is not null;
DirectoriesOnly = query.DirectoriesOnly;
FilesOnly = query.FilesOnly;
MinSize = query.MinSize ?? long.MinValue;
MaxSize = query.MaxSize ?? long.MaxValue;
ModifiedAfterTicks = ToUtcTicks(query.ModifiedAfter);
ModifiedBeforeTicks = ToUtcTicks(query.ModifiedBefore);
// 说明:SearchQuery.CreatedAfter 无法参与过滤 —— IndexedEntry 没有创建时间字段
//($STANDARD_INFORMATION 的创建时间只在原始 MFT 解析里拿得到),需要接口扩展才能支持。
}
}
/// <summary>卷句柄可用但 FSCTL 被内核拒绝(权限/非 NTFS),携带原始 Win32 错误码供上层分类。</summary>
private sealed class VolumeAccessException(int error, string message) : Exception(message)
{
internal int Error { get; } = error;
}
/// <summary>容量受限的最大堆:始终只保留“最好的” K 条。键越小越好,堆顶是当前最差的一条。</summary>
private sealed class TopK(int capacity)
{
private readonly long[] _keys = new long[Math.Max(1, capacity)];
private readonly int[] _indexes = new int[Math.Max(1, capacity)];
private int _count;
internal void Add(long key, int index)
{
if (_count < _keys.Length)
{
int i = _count++;
_keys[i] = key;
_indexes[i] = index;
SiftUp(i);
}
else if (key < _keys[0])
{
_keys[0] = key;
_indexes[0] = index;
SiftDown(0);
}
}
internal void DrainInto(TopK other)
{
for (int i = 0; i < _count; i++) other.Add(_keys[i], _indexes[i]);
}
internal (long Key, int Index)[] ToSortedArray()
{
var result = new (long, int)[_count];
for (int i = 0; i < _count; i++) result[i] = (_keys[i], _indexes[i]);
Array.Sort(result, static (a, b) => a.Item1.CompareTo(b.Item1));
return result;
}
private void SiftUp(int i)
{
while (i > 0)
{
int parent = (i - 1) >> 1;
if (_keys[parent] >= _keys[i]) break;
Swap(parent, i);
i = parent;
}
}
private void SiftDown(int i)
{
while (true)
{
int left = (i << 1) + 1;
if (left >= _count) return;
int largest = left;
int right = left + 1;
if (right < _count && _keys[right] > _keys[left]) largest = right;
if (_keys[i] >= _keys[largest]) return;
Swap(i, largest);
i = largest;
}
}
private void Swap(int a, int b)
{
(_keys[a], _keys[b]) = (_keys[b], _keys[a]);
(_indexes[a], _indexes[b]) = (_indexes[b], _indexes[a]);
}
}
}