Initial commit: FluidExplorer:从零实现的 WinUI 3 文件资源管理器替代品(Mica、命令栏、面包屑)
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
namespace FluidExplorer.Services.Search;
|
||||
|
||||
public enum IndexState
|
||||
{
|
||||
NotStarted,
|
||||
RequiresElevation,
|
||||
Building,
|
||||
Ready,
|
||||
Watching,
|
||||
Failed,
|
||||
Stopped
|
||||
}
|
||||
|
||||
/// <summary>索引里的一条记录(NTFS USN 数据的最小集合)。</summary>
|
||||
public readonly record struct IndexedEntry(
|
||||
ulong Frn,
|
||||
ulong ParentFrn,
|
||||
string Name,
|
||||
bool IsDirectory,
|
||||
long Size,
|
||||
DateTime ModifiedUtc,
|
||||
uint Attributes)
|
||||
{
|
||||
/// <summary>非 NTFS / 非索引来源(例如回退扫描器)可直接携带完整路径。</summary>
|
||||
public string? FullPath { get; init; }
|
||||
|
||||
public string Extension
|
||||
{
|
||||
get
|
||||
{
|
||||
if (IsDirectory) return string.Empty;
|
||||
var i = Name.LastIndexOf('.');
|
||||
return i > 0 && i < Name.Length - 1 ? Name[(i + 1)..] : string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class IndexStateChangedEventArgs(IndexState state, string? message = null) : EventArgs
|
||||
{
|
||||
public IndexState State { get; } = state;
|
||||
public string? Message { get; } = message;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 单卷文件索引:Everything 式体验的核心。
|
||||
/// 实现必须做到:构建阶段不阻塞调用线程(内部自行使用线程池)、
|
||||
/// 支持增量更新(USN 日志)、查询使用并行扫描并在毫秒级返回。
|
||||
/// </summary>
|
||||
public interface IFileIndex
|
||||
{
|
||||
/// <summary>卷根,例如 "C:\"。</summary>
|
||||
string VolumeRoot { get; }
|
||||
|
||||
IndexState State { get; }
|
||||
long EntryCount { get; }
|
||||
|
||||
event EventHandler<IndexStateChangedEventArgs>? StateChanged;
|
||||
|
||||
/// <summary>建立初始索引(枚举 MFT / 扫描)。可重复调用,完成后自动转入 Watching。</summary>
|
||||
Task BuildAsync(IProgress<double>? progress, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>启动增量监听(USN journal),保持索引实时。</summary>
|
||||
void StartWatching();
|
||||
|
||||
void Stop();
|
||||
|
||||
/// <summary>
|
||||
/// 查询。实现约定:
|
||||
/// 1) 先按 IncludeTerms/Extensions/大小/时间/属性过滤(纯内存操作,必须并行化);
|
||||
/// 2) 仅当 <see cref="SearchQuery.PathFilter"/> 不为空时,才对已命中的候选调用路径解析;
|
||||
/// 3) 命中数达到 maxResults 即可提前返回,但不要漏掉更"好"的匹配(短名优先)。
|
||||
/// </summary>
|
||||
IEnumerable<IndexedEntry> Query(SearchQuery query, int maxResults, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>把 FRN 解析为完整路径(内部要缓存父链解析结果)。失败返回 false。</summary>
|
||||
bool TryResolvePath(ulong frn, out string fullPath);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace FluidExplorer.Services.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Everything 风格的查询:空格 = AND,| = OR,"引号" = 精确短语,!term = 排除,
|
||||
/// 支持 ext: size: dm: dc: folder: file: path: 以及 * ? 通配符。
|
||||
/// </summary>
|
||||
public sealed class SearchQuery
|
||||
{
|
||||
public string RawText { get; init; } = string.Empty;
|
||||
public List<string> IncludeTerms { get; } = [];
|
||||
public List<string> ExcludeTerms { get; } = [];
|
||||
public List<string> IncludeRegexLike { get; } = []; // 含通配符的项
|
||||
public List<string> Extensions { get; } = [];
|
||||
public List<string> ExcludeExtensions { get; } = [];
|
||||
public bool DirectoriesOnly { get; set; }
|
||||
public bool FilesOnly { get; set; }
|
||||
public long? MinSize { get; set; }
|
||||
public long? MaxSize { get; set; }
|
||||
public DateTime? ModifiedAfter { get; set; }
|
||||
public DateTime? ModifiedBefore { get; set; }
|
||||
public DateTime? CreatedAfter { get; set; }
|
||||
public string? PathFilter { get; set; }
|
||||
public bool MatchWholePath { get; set; }
|
||||
public bool IsEmpty => IncludeTerms.Count == 0 && IncludeRegexLike.Count == 0 && Extensions.Count == 0
|
||||
&& !DirectoriesOnly && !FilesOnly && MinSize is null && MaxSize is null
|
||||
&& ModifiedAfter is null && ModifiedBefore is null && CreatedAfter is null;
|
||||
}
|
||||
|
||||
public static class SearchQueryParser
|
||||
{
|
||||
public static SearchQuery Parse(string? text)
|
||||
{
|
||||
var q = new SearchQuery { RawText = text ?? string.Empty };
|
||||
if (string.IsNullOrWhiteSpace(text)) return q;
|
||||
if (text.StartsWith('*') && text.EndsWith('*') && text.Length > 2)
|
||||
{
|
||||
q.IncludeTerms.Add(text.Trim('*'));
|
||||
return q;
|
||||
}
|
||||
|
||||
foreach (var token in Tokenize(text))
|
||||
{
|
||||
if (token.Length == 0) continue;
|
||||
var span = token.AsSpan();
|
||||
if (span[0] == '!')
|
||||
{
|
||||
var t = token[1..].Trim();
|
||||
if (t.Length == 0) continue;
|
||||
if (TryExt(t, out var ex)) q.ExcludeExtensions.Add(ex);
|
||||
else q.ExcludeTerms.Add(t.Trim('"'));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryPrefix(span, "ext:", out var extValue))
|
||||
{
|
||||
foreach (var e in extValue.Split([';', ','], StringSplitOptions.RemoveEmptyEntries))
|
||||
q.Extensions.Add(e.Trim().TrimStart('.').ToLowerInvariant());
|
||||
continue;
|
||||
}
|
||||
if (TryPrefix(span, "size:", out var sizeValue))
|
||||
{
|
||||
ParseSize(sizeValue, q);
|
||||
continue;
|
||||
}
|
||||
if (TryPrefix(span, "dm:", out var dm))
|
||||
{
|
||||
if (TryParseDate(dm, out var d)) q.ModifiedAfter = d;
|
||||
continue;
|
||||
}
|
||||
if (TryPrefix(span, "dc:", out var dc))
|
||||
{
|
||||
if (TryParseDate(dc, out var d)) q.CreatedAfter = d;
|
||||
continue;
|
||||
}
|
||||
if (TryPrefix(span, "folder:", out _) || token.Equals("folder:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
q.DirectoriesOnly = true;
|
||||
continue;
|
||||
}
|
||||
if (token.Equals("file:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
q.FilesOnly = true;
|
||||
continue;
|
||||
}
|
||||
if (TryPrefix(span, "path:", out var pathValue))
|
||||
{
|
||||
q.PathFilter = pathValue.Trim('"');
|
||||
q.MatchWholePath = true;
|
||||
continue;
|
||||
}
|
||||
if (token.Equals("dm:today", StringComparison.OrdinalIgnoreCase)) { q.ModifiedAfter = DateTime.Today; continue; }
|
||||
|
||||
var cleaned = token.Trim('"');
|
||||
if (cleaned.Length == 0) continue;
|
||||
if (cleaned.IndexOfAny(['*', '?']) >= 0) q.IncludeRegexLike.Add(cleaned);
|
||||
else q.IncludeTerms.Add(cleaned);
|
||||
}
|
||||
|
||||
return q;
|
||||
}
|
||||
|
||||
private static bool TryExt(string token, out string ext)
|
||||
{
|
||||
ext = string.Empty;
|
||||
if (!TryPrefix(token.AsSpan(), "ext:", out var v)) return false;
|
||||
ext = v.Trim().TrimStart('.').ToLowerInvariant();
|
||||
return ext.Length > 0;
|
||||
}
|
||||
|
||||
private static bool TryPrefix(ReadOnlySpan<char> token, string prefix, out string value)
|
||||
{
|
||||
value = string.Empty;
|
||||
if (token.Length <= prefix.Length) return false;
|
||||
if (!token.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
value = token[prefix.Length..].ToString();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void ParseSize(string value, SearchQuery q)
|
||||
{
|
||||
value = value.Trim();
|
||||
if (value.Length == 0) return;
|
||||
var op = '=';
|
||||
if (value[0] is '>' or '<' or '=') { op = value[0]; value = value[1..]; }
|
||||
else if (value.StartsWith(">=", StringComparison.Ordinal)) { op = '>'; value = value[2..]; }
|
||||
else if (value.StartsWith("<=", StringComparison.Ordinal)) { op = '<'; value = value[2..]; }
|
||||
|
||||
double mul = 1;
|
||||
var lower = value.ToLowerInvariant();
|
||||
foreach (var (suffix, factor) in SizeSuffixes)
|
||||
{
|
||||
if (lower.EndsWith(suffix, StringComparison.Ordinal))
|
||||
{
|
||||
mul = factor;
|
||||
value = value[..^suffix.Length];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!double.TryParse(value.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var num)) return;
|
||||
var bytes = (long)(num * mul);
|
||||
switch (op)
|
||||
{
|
||||
case '>': q.MinSize = bytes; break;
|
||||
case '<': q.MaxSize = bytes; break;
|
||||
default: q.MinSize = bytes; q.MaxSize = bytes; break;
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly (string, double)[] SizeSuffixes =
|
||||
[
|
||||
("kb", 1024d), ("mb", 1024d * 1024), ("gb", 1024d * 1024 * 1024), ("tb", 1024d * 1024 * 1024 * 1024),
|
||||
("k", 1024d), ("m", 1024d * 1024), ("g", 1024d * 1024 * 1024), ("b", 1d)
|
||||
];
|
||||
|
||||
private static bool TryParseDate(string value, out DateTime date)
|
||||
{
|
||||
date = default;
|
||||
var v = value.Trim().ToLowerInvariant();
|
||||
var now = DateTime.Now;
|
||||
switch (v)
|
||||
{
|
||||
case "today": date = now.Date; return true;
|
||||
case "yesterday": date = now.Date.AddDays(-1); return true;
|
||||
case "thisweek": date = now.Date.AddDays(-(int)now.DayOfWeek); return true;
|
||||
case "thismonth": date = new DateTime(now.Year, now.Month, 1); return true;
|
||||
case "thisyear": date = new DateTime(now.Year, 1, 1); return true;
|
||||
}
|
||||
if (v.EndsWith('d') && int.TryParse(v[..^1], out var days)) { date = now.AddDays(-days); return true; }
|
||||
if (v.EndsWith('h') && int.TryParse(v[..^1], out var hours)) { date = now.AddHours(-hours); return true; }
|
||||
if (v.EndsWith('w') && int.TryParse(v[..^1], out var weeks)) { date = now.AddDays(-7 * weeks); return true; }
|
||||
return DateTime.TryParse(v, CultureInfo.CurrentCulture, DateTimeStyles.None, out date);
|
||||
}
|
||||
|
||||
/// <summary>按空格切词,但保留引号内的空格。</summary>
|
||||
private static IEnumerable<string> Tokenize(string text)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
bool inQuotes = false;
|
||||
foreach (var ch in text)
|
||||
{
|
||||
if (ch == '"')
|
||||
{
|
||||
inQuotes = !inQuotes;
|
||||
sb.Append(ch);
|
||||
continue;
|
||||
}
|
||||
if (!inQuotes && char.IsWhiteSpace(ch))
|
||||
{
|
||||
if (sb.Length > 0) { yield return sb.ToString(); sb.Clear(); }
|
||||
continue;
|
||||
}
|
||||
sb.Append(ch);
|
||||
}
|
||||
if (sb.Length > 0) yield return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
using FluidExplorer.Services.Shell;
|
||||
using FluidExplorer.Services.Search;
|
||||
using FluidExplorer.Services.FileSystem;
|
||||
|
||||
namespace FluidExplorer.Services.Search;
|
||||
|
||||
/// <summary>一条搜索结果(对外给 UI 用的扁平结构)。</summary>
|
||||
public sealed record SearchHit(
|
||||
string Path,
|
||||
string Name,
|
||||
string Directory,
|
||||
bool IsDirectory,
|
||||
long Size,
|
||||
DateTime ModifiedUtc)
|
||||
{
|
||||
public string Extension
|
||||
{
|
||||
get
|
||||
{
|
||||
if (IsDirectory) return string.Empty;
|
||||
var i = Name.LastIndexOf('.');
|
||||
return i > 0 && i < Name.Length - 1 ? Name[(i + 1)..].ToLowerInvariant() : string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record SearchOutcome(
|
||||
IReadOnlyList<SearchHit> Hits,
|
||||
bool Truncated,
|
||||
bool UsedIndex,
|
||||
int IndexedVolumes,
|
||||
TimeSpan Elapsed,
|
||||
string? Note = null);
|
||||
|
||||
/// <summary>
|
||||
/// 搜索门面:优先使用 NTFS 索引(毫秒级、全盘),
|
||||
/// 没有索引时回退为带时间预算的实时枚举(并明确告诉用户"未使用索引")。
|
||||
/// </summary>
|
||||
public sealed class SearchService(IFileSystemService fileSystem)
|
||||
{
|
||||
private readonly Dictionary<string, IFileIndex> _indexes = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly IFileSystemService _fileSystem = fileSystem;
|
||||
|
||||
/// <summary>由 AppServices 注入的具体索引实现工厂(这样本层不依赖具体互操作实现)。</summary>
|
||||
public Func<string, IFileIndex?>? IndexFactory { get; set; }
|
||||
|
||||
public event EventHandler? IndexStateChanged;
|
||||
|
||||
public bool HasAnyIndex => _indexes.Count > 0;
|
||||
public int IndexedVolumeCount => _indexes.Count;
|
||||
public long TotalIndexedEntries => _indexes.Values.Sum(i => i.EntryCount);
|
||||
|
||||
public IndexState AggregateState
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_indexes.Count == 0) return IndexState.NotStarted;
|
||||
if (_indexes.Values.Any(i => i.State == IndexState.RequiresElevation)) return IndexState.RequiresElevation;
|
||||
if (_indexes.Values.Any(i => i.State == IndexState.Building)) return IndexState.Building;
|
||||
if (_indexes.Values.Any(i => i.State == IndexState.Failed)) return IndexState.Failed;
|
||||
if (_indexes.Values.All(i => i.State is IndexState.Ready or IndexState.Watching)) return IndexState.Ready;
|
||||
return IndexState.NotStarted;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>启动指定卷的索引(后台构建,不阻塞 UI)。</summary>
|
||||
public IFileIndex? EnsureIndex(string volumeRoot)
|
||||
{
|
||||
if (_indexes.TryGetValue(volumeRoot, out var existing)) return existing;
|
||||
var created = IndexFactory?.Invoke(volumeRoot);
|
||||
if (created is null) return null;
|
||||
created.StateChanged += (_, _) => IndexStateChanged?.Invoke(this, EventArgs.Empty);
|
||||
_indexes[volumeRoot] = created;
|
||||
IndexStateChanged?.Invoke(this, EventArgs.Empty);
|
||||
return created;
|
||||
}
|
||||
|
||||
public IFileIndex? GetIndex(string volumeRoot)
|
||||
=> _indexes.TryGetValue(volumeRoot, out var index) ? index : null;
|
||||
|
||||
public IReadOnlyCollection<IFileIndex> AllIndexes => _indexes.Values;
|
||||
|
||||
/// <summary>为当前所有固定卷建立索引(默认行为:只索引本地固定磁盘,避免扫网络盘)。</summary>
|
||||
public async Task BuildIndexesAsync(IEnumerable<string> volumeRoots, IProgress<(string Volume, double Progress)>? progress, CancellationToken ct)
|
||||
{
|
||||
foreach (var root in volumeRoots)
|
||||
{
|
||||
var index = EnsureIndex(root);
|
||||
if (index is null) continue;
|
||||
var capturedRoot = root;
|
||||
var sub = progress is null ? null : new Progress<double>(p => progress.Report((capturedRoot, p)));
|
||||
await index.BuildAsync(sub, ct).ConfigureAwait(false);
|
||||
if (index.State is IndexState.Ready or IndexState.Watching) index.StartWatching();
|
||||
IndexStateChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>执行搜索。Global 走索引;索引不可用时退回实时扫描(并如实告知用户)。</summary>
|
||||
public async Task<SearchOutcome> SearchAsync(SearchQuery query, SearchScope scope, string? basePath, int maxResults, CancellationToken ct)
|
||||
{
|
||||
if (query.IsEmpty) return new SearchOutcome([], false, false, 0, TimeSpan.Zero, "请输入搜索内容");
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var hits = new List<SearchHit>(maxResults > 0 ? Math.Min(maxResults, 4096) : 1024);
|
||||
var truncated = false;
|
||||
var indexedVolumeCount = 0;
|
||||
|
||||
if (scope == SearchScope.Global && _indexes.Count > 0)
|
||||
{
|
||||
foreach (var (root, index) in _indexes)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (index.State is not (IndexState.Ready or IndexState.Watching)) continue;
|
||||
indexedVolumeCount++;
|
||||
var remaining = maxResults - hits.Count;
|
||||
if (remaining <= 0) { truncated = true; break; }
|
||||
foreach (var entry in index.Query(query, remaining, ct))
|
||||
{
|
||||
var path = entry.FullPath;
|
||||
if (path is null && !index.TryResolvePath(entry.Frn, out path)) continue;
|
||||
if (!MatchesExtendedFilters(entry, query, path!)) continue;
|
||||
hits.Add(new SearchHit(
|
||||
path!,
|
||||
entry.Name,
|
||||
PathHelper.GetParent(path!),
|
||||
entry.IsDirectory,
|
||||
entry.Size,
|
||||
entry.ModifiedUtc == default ? DateTime.MinValue : DateTime.SpecifyKind(entry.ModifiedUtc, DateTimeKind.Utc)));
|
||||
if (hits.Count >= maxResults) { truncated = true; break; }
|
||||
}
|
||||
}
|
||||
|
||||
if (indexedVolumeCount > 0)
|
||||
{
|
||||
sw.Stop();
|
||||
return new SearchOutcome(hits, truncated, true, indexedVolumeCount, sw.Elapsed);
|
||||
}
|
||||
|
||||
// 一个可用索引都没有(未建、无管理员权限、非 NTFS):不要返回空结果,
|
||||
// 而是退回实时扫描,并明确告诉用户为什么慢、怎么才能变快。
|
||||
}
|
||||
|
||||
// 回退:实时枚举(限定范围 + 时间预算 + 结果上限,绝不卡住界面)
|
||||
var scanRoot = !string.IsNullOrEmpty(basePath) && Directory.Exists(basePath)
|
||||
? basePath
|
||||
: SafeSystemDriveRoot();
|
||||
|
||||
if (!string.IsNullOrEmpty(scanRoot))
|
||||
{
|
||||
var options = new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = 0,
|
||||
MaxRecursionDepth = 32
|
||||
};
|
||||
|
||||
await Task.Run(() =>
|
||||
{
|
||||
var budget = TimeSpan.FromSeconds(20);
|
||||
foreach (var path in Directory.EnumerateFileSystemEntries(scanRoot, "*", options))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (sw.Elapsed > budget) { truncated = true; break; }
|
||||
string name = PathHelper.GetName(path);
|
||||
if (!MatchesNameOnly(name, query)) continue;
|
||||
try
|
||||
{
|
||||
var isDir = Directory.Exists(path);
|
||||
var info = isDir ? null : new FileInfo(path);
|
||||
hits.Add(new SearchHit(path, name, PathHelper.GetParent(path), isDir,
|
||||
info?.Length ?? 0, info?.LastWriteTimeUtc ?? DateTime.MinValue));
|
||||
}
|
||||
catch
|
||||
{
|
||||
hits.Add(new SearchHit(path, name, PathHelper.GetParent(path), false, 0, DateTime.MinValue));
|
||||
}
|
||||
if (hits.Count >= maxResults) { truncated = true; break; }
|
||||
}
|
||||
}, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
var note = _indexes.Count == 0
|
||||
? "未启用 NTFS 索引,本次为实时扫描(可在设置中开启索引以获得毫秒级全盘搜索)"
|
||||
: $"索引尚未就绪,已在「{scanRoot}」实时扫描。以管理员身份重启可建立 NTFS 索引,全盘搜索将提升到毫秒级";
|
||||
return new SearchOutcome(hits, truncated, false, 0, sw.Elapsed, note);
|
||||
}
|
||||
|
||||
private static string? SafeSystemDriveRoot()
|
||||
{
|
||||
try
|
||||
{
|
||||
var root = Path.GetPathRoot(Environment.SystemDirectory);
|
||||
return string.IsNullOrEmpty(root) ? null : root;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool MatchesExtendedFilters(IndexedEntry entry, SearchQuery query, string path)
|
||||
{
|
||||
if (query.MinSize is { } min && entry.Size >= 0 && entry.Size < min) return false;
|
||||
if (query.MaxSize is { } max && entry.Size >= 0 && entry.Size > max) return false;
|
||||
if (!string.IsNullOrEmpty(query.PathFilter))
|
||||
{
|
||||
if (!path.Contains(query.PathFilter, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
}
|
||||
if (query.MatchWholePath && query.IncludeTerms.Count > 0)
|
||||
{
|
||||
// 名字里已经命中就不必再看路径;名字没命中的,允许整条路径命中
|
||||
foreach (var term in query.IncludeTerms)
|
||||
{
|
||||
if (entry.Name.Contains(term, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (path.Contains(term, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool MatchesNameOnly(string name, SearchQuery query)
|
||||
{
|
||||
foreach (var term in query.IncludeTerms)
|
||||
if (!name.Contains(term, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
foreach (var bad in query.ExcludeTerms)
|
||||
if (name.Contains(bad, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
if (query.Extensions.Count > 0)
|
||||
{
|
||||
var ext = Path.GetExtension(name).TrimStart('.').ToLowerInvariant();
|
||||
if (!query.Extensions.Contains(ext)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace FluidExplorer.Services.Search.Usn;
|
||||
|
||||
/// <summary>
|
||||
/// 增量监听的<b>回退通道</b>:ReadDirectoryChangesW。
|
||||
///
|
||||
/// 什么情况下用:USN 变更日志不可用(卷上没日志且没权限创建、被策略禁用、日志刚被删除等)。
|
||||
/// 这条路径不需要任何特殊权限,普通用户也能跑,从而保证「索引实时」这个卖点不落空。
|
||||
///
|
||||
/// 与 USN 的差别与应对:
|
||||
/// * RDCW 只给相对<b>路径</b>,不给 FRN。这里用 CreateFileW(FILE_READ_ATTRIBUTES) +
|
||||
/// GetFileInformationByHandle 取句柄上的 FileIndex —— 它与 USN 的 FRN 完全同口径
|
||||
/// (低 48 位记录号 + 高 16 位序列号),因此能直接命中同一个索引条目。
|
||||
/// * 新增/改名/内容变化都伴随着文件仍然存在,可以 stat 出来 → 直接 upsert。
|
||||
/// * 删除只剩一个路径(stat 必然失败),无法反查 FRN。应对:把受影响的<b>目录</b>记下来,
|
||||
/// 批处理结束后对该目录做一次「磁盘现状 vs 索引」的对账(<see cref="UsnVolumeIndex.ResyncDirectoryFromDisk"/>),
|
||||
/// 只把索引里存在、磁盘上已消失的孩子打墓碑。对账按目录去重、每轮有上限,代价可控。
|
||||
/// </summary>
|
||||
internal sealed class DirectoryChangeWatcher
|
||||
{
|
||||
private const int ReadBufferSize = 64 * 1024;
|
||||
|
||||
/// <summary>每轮最多对账多少个目录,防止一次批量删除把 CPU 打满。</summary>
|
||||
private const int MaxResyncDirectoriesPerPass = 256;
|
||||
|
||||
private static readonly uint NotifyFilter =
|
||||
UsnNative.FILE_NOTIFY_CHANGE_FILE_NAME |
|
||||
UsnNative.FILE_NOTIFY_CHANGE_DIR_NAME |
|
||||
UsnNative.FILE_NOTIFY_CHANGE_SIZE |
|
||||
UsnNative.FILE_NOTIFY_CHANGE_LAST_WRITE |
|
||||
UsnNative.FILE_NOTIFY_CHANGE_ATTRIBUTES;
|
||||
|
||||
private readonly UsnVolumeIndex _index;
|
||||
private readonly string _root;
|
||||
private readonly ConcurrentDictionary<string, ulong> _directoryFrnCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _pendingResync = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly List<string> _resyncScratch = [];
|
||||
|
||||
private SafeFileHandle? _handle;
|
||||
private volatile bool _stopRequested;
|
||||
|
||||
internal DirectoryChangeWatcher(UsnVolumeIndex index)
|
||||
{
|
||||
_index = index;
|
||||
_root = index.VolumeRoot;
|
||||
}
|
||||
|
||||
/// <summary>打断阻塞中的 ReadDirectoryChangesW:置停止标志 + 关目录句柄(双保险)。</summary>
|
||||
internal void RequestStop()
|
||||
{
|
||||
_stopRequested = true;
|
||||
Interlocked.Exchange(ref _handle, null)?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>打开卷根目录句柄。返回 false 时 <paramref name="error"/> 给出中文原因。</summary>
|
||||
internal bool TryOpen(out string? error)
|
||||
{
|
||||
error = null;
|
||||
var handle = UsnNative.CreateFileW(
|
||||
_root,
|
||||
UsnNative.FILE_LIST_DIRECTORY,
|
||||
UsnNative.FILE_SHARE_READ | UsnNative.FILE_SHARE_WRITE | UsnNative.FILE_SHARE_DELETE,
|
||||
IntPtr.Zero,
|
||||
UsnNative.OPEN_EXISTING,
|
||||
UsnNative.FILE_FLAG_BACKUP_SEMANTICS, // 打开目录必须带这个
|
||||
IntPtr.Zero);
|
||||
|
||||
if (handle.IsInvalid)
|
||||
{
|
||||
int openError = Marshal.GetLastWin32Error();
|
||||
handle.Dispose();
|
||||
error = UsnNative.DescribeError(openError, $"无法以 FILE_LIST_DIRECTORY 打开 {_root}");
|
||||
return false;
|
||||
}
|
||||
|
||||
Volatile.Write(ref _handle, handle);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>阻塞式监听循环,直到 <see cref="RequestStop"/> 或句柄被关闭。</summary>
|
||||
internal void Run()
|
||||
{
|
||||
var handle = Volatile.Read(ref _handle);
|
||||
if (handle is null) return;
|
||||
var buffer = new byte[ReadBufferSize];
|
||||
|
||||
try
|
||||
{
|
||||
while (!_stopRequested)
|
||||
{
|
||||
uint returned;
|
||||
bool ok;
|
||||
unsafe
|
||||
{
|
||||
fixed (byte* p = buffer)
|
||||
{
|
||||
ok = UsnNative.ReadDirectoryChangesW(
|
||||
handle, p, (uint)buffer.Length, true, NotifyFilter, out returned, IntPtr.Zero, IntPtr.Zero);
|
||||
}
|
||||
}
|
||||
|
||||
if (!ok)
|
||||
{
|
||||
int readError = Marshal.GetLastWin32Error();
|
||||
if (_stopRequested
|
||||
|| readError is UsnNative.ERROR_OPERATION_ABORTED or UsnNative.ERROR_CANCELLED or UsnNative.ERROR_INVALID_HANDLE)
|
||||
{
|
||||
break;
|
||||
}
|
||||
// 单次失败不放弃:报给 UI 后继续(例如枚举期间目录被临时独占)
|
||||
_index.ReportWatchIssue($"ReadDirectoryChangesW 出错:{UsnNative.DescribeError(readError)}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (returned == 0)
|
||||
{
|
||||
// 变更缓冲区溢出:期间的事件已经丢了,只能提示 UI 做一次重建
|
||||
_index.ReportWatchIssue("变更缓冲区溢出,部分变更已丢失,建议重建索引以保持一致。");
|
||||
continue;
|
||||
}
|
||||
|
||||
ProcessEvents(buffer.AsSpan(0, (int)returned));
|
||||
FlushPendingResync();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _handle, null)?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessEvents(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
int offset = 0;
|
||||
while (true)
|
||||
{
|
||||
if (offset + UsnNative.FileNotifyInformation.HeaderSize > buffer.Length) break;
|
||||
|
||||
uint nextEntry = BinaryPrimitives.ReadUInt32LittleEndian(buffer[offset..]);
|
||||
uint action = BinaryPrimitives.ReadUInt32LittleEndian(buffer[(offset + 4)..]);
|
||||
int nameBytes = (int)BinaryPrimitives.ReadUInt32LittleEndian(buffer[(offset + 8)..]);
|
||||
|
||||
int nameOffset = offset + UsnNative.FileNotifyInformation.HeaderSize;
|
||||
if (nameBytes <= 0 || nameOffset + nameBytes > buffer.Length) break;
|
||||
|
||||
// 文件名是相对被监听目录(这里是卷根)的路径,形如 "Users\Public\x.txt",不以 NUL 结尾
|
||||
var relative = MemoryMarshal.Cast<byte, char>(buffer.Slice(nameOffset, nameBytes));
|
||||
Handle(relative, action);
|
||||
|
||||
if (nextEntry == 0) break;
|
||||
offset += (int)nextEntry;
|
||||
}
|
||||
}
|
||||
|
||||
private void Handle(ReadOnlySpan<char> relative, uint action)
|
||||
{
|
||||
if (relative.Length == 0) return;
|
||||
var fullPath = string.Concat(_root, relative);
|
||||
|
||||
switch (action)
|
||||
{
|
||||
case UsnNative.FILE_ACTION_ADDED:
|
||||
case UsnNative.FILE_ACTION_RENAMED_NEW_NAME:
|
||||
case UsnNative.FILE_ACTION_MODIFIED:
|
||||
UpsertFromDisk(fullPath);
|
||||
break;
|
||||
|
||||
case UsnNative.FILE_ACTION_REMOVED:
|
||||
case UsnNative.FILE_ACTION_RENAMED_OLD_NAME:
|
||||
QueueDirectoryResync(fullPath);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>按路径 stat 出 FRN + 元数据,然后按 FRN 落入索引(存在则更新,不存在则新增)。</summary>
|
||||
private void UpsertFromDisk(string fullPath)
|
||||
{
|
||||
if (!UsnNative.TryStatPath(fullPath, out var info)) return; // 文件已再次消失等,忽略
|
||||
|
||||
var name = Path.GetFileName(fullPath.AsSpan());
|
||||
if (name.Length == 0) return;
|
||||
|
||||
ulong parentFrn = 0;
|
||||
var parentPath = Path.GetDirectoryName(fullPath);
|
||||
if (!string.IsNullOrEmpty(parentPath) && !parentPath.Equals(_root, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
parentFrn = GetDirectoryFrn(parentPath);
|
||||
}
|
||||
|
||||
bool isDirectory = (info.FileAttributes & UsnNative.FILE_ATTRIBUTE_DIRECTORY) != 0;
|
||||
_index.UpsertFromFileSystem(
|
||||
info.FileIndex,
|
||||
parentFrn,
|
||||
name,
|
||||
isDirectory,
|
||||
isDirectory ? 0 : info.FileSize,
|
||||
UsnVolumeIndex.FileTimeToTicks(info.LastWriteTime.ToInt64()),
|
||||
info.FileAttributes);
|
||||
|
||||
if (isDirectory) _directoryFrnCache[fullPath] = UsnNative.NormalizeFrn(info.FileIndex);
|
||||
}
|
||||
|
||||
private ulong GetDirectoryFrn(string directoryPath)
|
||||
{
|
||||
if (_directoryFrnCache.TryGetValue(directoryPath, out var cached)) return cached;
|
||||
if (!UsnNative.TryStatPath(directoryPath, out var info)) return 0;
|
||||
var frn = UsnNative.NormalizeFrn(info.FileIndex);
|
||||
_directoryFrnCache[directoryPath] = frn;
|
||||
return frn;
|
||||
}
|
||||
|
||||
private void QueueDirectoryResync(string fullPath)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(fullPath);
|
||||
if (!string.IsNullOrEmpty(directory)) _pendingResync.Add(directory);
|
||||
}
|
||||
|
||||
private void FlushPendingResync()
|
||||
{
|
||||
if (_pendingResync.Count == 0) return;
|
||||
|
||||
_resyncScratch.Clear();
|
||||
foreach (var directory in _pendingResync)
|
||||
{
|
||||
_resyncScratch.Add(directory);
|
||||
if (_resyncScratch.Count >= MaxResyncDirectoriesPerPass) break;
|
||||
}
|
||||
|
||||
int removed = 0;
|
||||
foreach (var directory in _resyncScratch)
|
||||
{
|
||||
_pendingResync.Remove(directory);
|
||||
removed += _index.ResyncDirectoryFromDisk(directory);
|
||||
}
|
||||
|
||||
if (removed > 0) _index.ReportWatchIssue(null, removed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace FluidExplorer.Services.Search.Usn;
|
||||
|
||||
/// <summary>
|
||||
/// 索引的紧凑存储:结构体数组(Struct-of-Arrays)而不是对象数组。
|
||||
///
|
||||
/// 每条记录只占 45 字节(8+8+8+8+8+4+1),100 万条约 45MB,
|
||||
/// 再加上名字池(约 2 字节/字符)就构成整个索引;绝无 per-entry 的对象头与 string。
|
||||
///
|
||||
/// 并发模型(读多写极少):
|
||||
/// * <see cref="Count"/> 用 volatile 发布:写入方先写满数据,最后自增 Count;读方只需读一次 Count 再顺序访问。
|
||||
/// * 结构只增不减(删除只打墓碑标志位),因此已经发布的 [0, Count) 区间永远有效。
|
||||
/// * 容量不足时 <see cref="Grow"/> 采用 copy-on-write,整体替换数组;老快照对正在查询的线程依然合法。
|
||||
/// </summary>
|
||||
internal sealed class IndexStore
|
||||
{
|
||||
internal const byte FlagDeleted = 0x01;
|
||||
internal const byte FlagDirectory = 0x02;
|
||||
internal const byte FlagSizeKnown = 0x04;
|
||||
|
||||
internal readonly ulong[] Frn; // 归一化 FRN(低 48 位记录号)
|
||||
internal readonly ulong[] ParentFrn; // 归一化父目录 FRN
|
||||
internal readonly long[] NameRef; // NamePool.Pack(偏移, 长度)
|
||||
internal readonly long[] Size; // -1 = 未知(USN 降级模式)
|
||||
internal readonly long[] ModifiedTicks; // UTC ticks;0 = 未知
|
||||
internal readonly uint[] Attributes; // FILE_ATTRIBUTE_*
|
||||
internal readonly byte[] Flags;
|
||||
|
||||
/// <summary>
|
||||
/// 名字池与数组快照绑定在一起:查询只需抓取一次 store 引用就能得到一致的 (名字池, 数组, 条数)。
|
||||
/// 关键:扩容(<see cref="Grow"/>)必须<b>复用同一个名字池</b> —— NameRef 里存的是池内偏移,
|
||||
/// 换池等于让所有老记录的名字全部失效。
|
||||
/// </summary>
|
||||
internal readonly NamePool Names;
|
||||
|
||||
internal readonly int Capacity;
|
||||
|
||||
/// <summary>已发布条数。写入方必须在写完所有字段之后再自增它。</summary>
|
||||
internal volatile int Count;
|
||||
|
||||
internal IndexStore(int capacity) : this(capacity, new NamePool())
|
||||
{
|
||||
}
|
||||
|
||||
private IndexStore(int capacity, NamePool names)
|
||||
{
|
||||
if (capacity < 16) capacity = 16;
|
||||
Capacity = capacity;
|
||||
Names = names;
|
||||
Frn = new ulong[capacity];
|
||||
ParentFrn = new ulong[capacity];
|
||||
NameRef = new long[capacity];
|
||||
Size = new long[capacity];
|
||||
ModifiedTicks = new long[capacity];
|
||||
Attributes = new uint[capacity];
|
||||
Flags = new byte[capacity];
|
||||
}
|
||||
|
||||
private IndexStore(IndexStore old, int capacity) : this(capacity, old.Names)
|
||||
{
|
||||
int n = old.Count;
|
||||
Array.Copy(old.Frn, Frn, n);
|
||||
Array.Copy(old.ParentFrn, ParentFrn, n);
|
||||
Array.Copy(old.NameRef, NameRef, n);
|
||||
Array.Copy(old.Size, Size, n);
|
||||
Array.Copy(old.ModifiedTicks, ModifiedTicks, n);
|
||||
Array.Copy(old.Attributes, Attributes, n);
|
||||
Array.Copy(old.Flags, Flags, n);
|
||||
Count = n;
|
||||
}
|
||||
|
||||
/// <summary>扩容(copy-on-write)。返回新快照,旧快照仍然可被并发查询安全使用。</summary>
|
||||
internal static IndexStore Grow(IndexStore old, int minCapacity)
|
||||
{
|
||||
int capacity = old.Capacity;
|
||||
while (capacity < minCapacity) capacity = capacity < 1024 ? capacity * 2 : capacity + (capacity >> 1);
|
||||
return new IndexStore(old, capacity);
|
||||
}
|
||||
|
||||
internal bool IsDeleted(int i) => (Flags[i] & FlagDeleted) != 0;
|
||||
|
||||
internal bool IsDirectory(int i) => (Flags[i] & FlagDirectory) != 0;
|
||||
|
||||
/// <summary>读名字(零分配)。</summary>
|
||||
internal ReadOnlySpan<char> GetName(int i)
|
||||
{
|
||||
long nameRef = Volatile.Read(ref NameRef[i]);
|
||||
return Names.Get(NamePool.UnpackOffset(nameRef), NamePool.UnpackLength(nameRef));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FRN(低 48 位记录号)→ 索引下标的映射。
|
||||
///
|
||||
/// 策略:构建期用普通 Dictionary 最快;构建结束后调用 <see cref="Optimize"/>,
|
||||
/// 如果记录号足够密集(maxRecord <= 8 * count),就换成“记录号直接寻址”的 int[],
|
||||
/// 100 万文件只占几 MB(而 Dictionary 要 30~40MB)。
|
||||
/// 稀疏卷或监听期新增的越界记录号退回到 <see cref="ConcurrentDictionary{TKey,TValue}"/> 侧表。
|
||||
/// </summary>
|
||||
internal sealed class FrnMap
|
||||
{
|
||||
private const int NoIndex = 0;
|
||||
private const int DenseSlack = 8;
|
||||
|
||||
private Dictionary<ulong, int>? _buildMap;
|
||||
private int[]? _dense; // 记录号 → 下标+1;0 表示不存在
|
||||
private ConcurrentDictionary<ulong, int>? _sparse;
|
||||
|
||||
internal FrnMap(int estimatedCount)
|
||||
{
|
||||
_buildMap = new Dictionary<ulong, int>(Math.Max(16, estimatedCount));
|
||||
}
|
||||
|
||||
internal int Count => _buildMap?.Count ?? _sparse?.Count ?? _dense?.Length ?? 0;
|
||||
|
||||
/// <summary>构建期写入(单线程,无锁,最快)。</summary>
|
||||
internal void AddBuild(ulong frn, int index)
|
||||
{
|
||||
_buildMap![UsnNative.NormalizeFrn(frn)] = index;
|
||||
}
|
||||
|
||||
/// <summary>构建完成后调用:把构建期的 Dictionary 压缩成密集数组或稀疏侧表。</summary>
|
||||
internal void Optimize()
|
||||
{
|
||||
var map = _buildMap ?? throw new InvalidOperationException("FrnMap 已经压缩过。");
|
||||
_buildMap = null;
|
||||
|
||||
ulong maxRecord = 0;
|
||||
foreach (var key in map.Keys)
|
||||
{
|
||||
if (key > maxRecord) maxRecord = key;
|
||||
}
|
||||
|
||||
// 密集数组代价 = (maxRecord+1)*4 字节;只要不超过 8 个记录号/条目的开销就值得。
|
||||
if (map.Count > 0 && maxRecord <= (ulong)map.Count * DenseSlack)
|
||||
{
|
||||
var dense = new int[maxRecord + 1];
|
||||
foreach (var (key, value) in map)
|
||||
{
|
||||
dense[key] = value + 1;
|
||||
}
|
||||
Volatile.Write(ref _dense, dense);
|
||||
return;
|
||||
}
|
||||
|
||||
var sparse = new ConcurrentDictionary<ulong, int>();
|
||||
foreach (var (key, value) in map)
|
||||
{
|
||||
sparse[key] = value;
|
||||
}
|
||||
Volatile.Write(ref _sparse, sparse);
|
||||
}
|
||||
|
||||
/// <summary>监听期写入(可能并发于查询,必须线程安全)。</summary>
|
||||
internal void Set(ulong frn, int index)
|
||||
{
|
||||
ulong record = UsnNative.NormalizeFrn(frn);
|
||||
var dense = Volatile.Read(ref _dense);
|
||||
if (dense is not null && record < (ulong)dense.Length)
|
||||
{
|
||||
Volatile.Write(ref dense[record], index + 1);
|
||||
return;
|
||||
}
|
||||
var sparse = _sparse;
|
||||
if (sparse is null)
|
||||
{
|
||||
var created = new ConcurrentDictionary<ulong, int>();
|
||||
sparse = Interlocked.CompareExchange(ref _sparse, created, null) ?? created;
|
||||
}
|
||||
sparse[record] = index;
|
||||
}
|
||||
|
||||
/// <summary>批量覆盖(重建索引时用,单线程)。</summary>
|
||||
internal void SetUnsafe(ulong frn, int index)
|
||||
{
|
||||
ulong record = UsnNative.NormalizeFrn(frn);
|
||||
var dense = _dense;
|
||||
if (dense is not null && record < (ulong)dense.Length)
|
||||
{
|
||||
dense[record] = index + 1;
|
||||
return;
|
||||
}
|
||||
_sparse![record] = index;
|
||||
}
|
||||
|
||||
internal bool TryGet(ulong frn, out int index)
|
||||
{
|
||||
ulong record = UsnNative.NormalizeFrn(frn);
|
||||
var dense = Volatile.Read(ref _dense);
|
||||
if (dense is not null && record < (ulong)dense.Length)
|
||||
{
|
||||
int slot = Volatile.Read(ref dense[record]);
|
||||
if (slot == NoIndex)
|
||||
{
|
||||
index = -1;
|
||||
return false;
|
||||
}
|
||||
index = slot - 1;
|
||||
return true;
|
||||
}
|
||||
var sparse = Volatile.Read(ref _sparse);
|
||||
if (sparse is not null && sparse.TryGetValue(record, out index)) return true;
|
||||
index = -1;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>NormizeFrn 的别名,便于调用点表达意图。</summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
internal static ulong Normalize(ulong frn) => UsnNative.NormalizeFrn(frn);
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
namespace FluidExplorer.Services.Search.Usn;
|
||||
|
||||
/// <summary>
|
||||
/// 紧凑文件名池 —— 索引“快且省内存”的关键之一。
|
||||
///
|
||||
/// 所有文件名以 UTF-16 连续存放在若干 <b>定长块</b>(每块 1<<20 个字符 = 2MB)里,
|
||||
/// 整个索引里不会为文件名产生任何 string 对象;查询时直接在这块内存上取 ReadOnlySpan<char>。
|
||||
///
|
||||
/// 偏移编码:<c>(chunkIndex << 20) | offsetInChunk</c>,单个 int 即可寻址 2G 字符。
|
||||
/// 因为块大小固定为 1<<20,而 NTFS 单个文件名最长 255 字符,
|
||||
/// 所以一个名字永远不会跨越两个块 —— 取值时无需拼接。
|
||||
///
|
||||
/// 线程安全:块数组一次性预分配(只写指针不扩容),块本身写入后用 <see cref="Volatile.Write{T}(ref T, T)"/> 发布。
|
||||
/// 查询线程只会读到“已经完整写入”的块;配合索引条数的发布顺序(先写数据、后写 Count),读侧无需加锁。
|
||||
/// </summary>
|
||||
internal sealed class NamePool
|
||||
{
|
||||
internal const int ChunkShift = 20;
|
||||
internal const int ChunkSize = 1 << ChunkShift;
|
||||
internal const int ChunkMask = ChunkSize - 1;
|
||||
/// <summary>NameRef 只用 8 位存长度,NTFS 文件名最长 255 字符,正好用满。</summary>
|
||||
internal const int MaxNameLength = 255;
|
||||
private const int MaxChunks = 2048; // 2048 * 1M = 2G 字符上限
|
||||
|
||||
private readonly char[][] _chunks = new char[MaxChunks][];
|
||||
private int _chunkCount;
|
||||
private int _fill; // 当前块已使用字符数
|
||||
private int _publishedChars;
|
||||
|
||||
internal NamePool()
|
||||
{
|
||||
AddChunk();
|
||||
}
|
||||
|
||||
/// <summary>池中已发布的字符总数(约等于所有名字长度之和)。</summary>
|
||||
internal int TotalChars => _publishedChars;
|
||||
|
||||
/// <summary>写入一个名字,返回它的池内偏移。空名字返回 0(配合 NameRef 的长度字段仍然无歧义)。</summary>
|
||||
internal int Add(ReadOnlySpan<char> name)
|
||||
{
|
||||
if (name.Length == 0) return 0;
|
||||
if (name.Length > ChunkSize) name = name[..ChunkSize];
|
||||
|
||||
if (_fill + name.Length > ChunkSize) AddChunk();
|
||||
|
||||
int chunkIndex = _chunkCount - 1;
|
||||
int offset = (chunkIndex << ChunkShift) | _fill;
|
||||
name.CopyTo(_chunks[chunkIndex].AsSpan(_fill));
|
||||
_fill += name.Length;
|
||||
_publishedChars = (chunkIndex << ChunkShift) + _fill;
|
||||
return offset;
|
||||
}
|
||||
|
||||
/// <summary>取回名字。参数非法时返回空 span(绝不抛异常,读侧可能并发读到正在更新的旧值)。</summary>
|
||||
internal ReadOnlySpan<char> Get(int offset, int length)
|
||||
{
|
||||
if (length <= 0 || offset < 0) return default;
|
||||
int chunkIndex = offset >> ChunkShift;
|
||||
int inChunk = offset & ChunkMask;
|
||||
if (chunkIndex >= _chunkCount) return default;
|
||||
var chunk = Volatile.Read(ref _chunks[chunkIndex]);
|
||||
if (chunk is null || inChunk + length > ChunkSize) return default;
|
||||
return chunk.AsSpan(inChunk, length);
|
||||
}
|
||||
|
||||
private void AddChunk()
|
||||
{
|
||||
if (_chunkCount >= MaxChunks) throw new InvalidOperationException("文件名池已达到容量上限(2G 字符)。");
|
||||
var chunk = new char[ChunkSize];
|
||||
Volatile.Write(ref _chunks[_chunkCount], chunk);
|
||||
_chunkCount++;
|
||||
_fill = 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- NameRef 打包
|
||||
|
||||
/// <summary>把 (偏移, 长度) 打包进一个 long:低 8 位是长度,高 56 位是偏移。</summary>
|
||||
internal static long Pack(int offset, int length) => ((long)offset << 8) | (uint)(length & 0xFF);
|
||||
|
||||
internal static int UnpackOffset(long nameRef) => (int)(nameRef >> 8);
|
||||
|
||||
internal static int UnpackLength(long nameRef) => (int)(nameRef & 0xFF);
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace FluidExplorer.Services.Search.Usn;
|
||||
|
||||
/// <summary>
|
||||
/// USN 日志 / NTFS 卷所需的最小 P/Invoke 与结构体集合。
|
||||
///
|
||||
/// 布局约定:所有 struct 均为 <see cref="LayoutKind.Sequential"/>,默认采用 x64 自然对齐;
|
||||
/// 字段顺序与偏移和 Windows SDK 的 winioctl.h / ntifs.h 完全一致。
|
||||
/// 每个结构体后面都标注了实测字节大小,便于对照 <see cref="Marshal.SizeOf{T}()"/> 校验。
|
||||
/// </summary>
|
||||
internal static class UsnNative
|
||||
{
|
||||
// ---------------------------------------------------------------- 访问权限 / 打开方式
|
||||
|
||||
internal const uint GENERIC_READ = 0x80000000;
|
||||
internal const uint GENERIC_WRITE = 0x40000000;
|
||||
internal const uint FILE_READ_DATA = 0x0001;
|
||||
internal const uint FILE_READ_ATTRIBUTES = 0x0080;
|
||||
internal const uint FILE_LIST_DIRECTORY = 0x0001;
|
||||
|
||||
internal const uint FILE_SHARE_READ = 0x00000001;
|
||||
internal const uint FILE_SHARE_WRITE = 0x00000002;
|
||||
internal const uint FILE_SHARE_DELETE = 0x00000004;
|
||||
|
||||
internal const uint OPEN_EXISTING = 3;
|
||||
|
||||
/// <summary>
|
||||
/// 打开 <c>\\.\C:</c> 时用的 dwFlagsAndAttributes。
|
||||
/// 参考实现(Everything)用 FILE_ATTRIBUTE_READONLY:传 FILE_ATTRIBUTE_NORMAL 在部分环境下会开不了卷句柄。
|
||||
/// </summary>
|
||||
internal const uint FILE_ATTRIBUTE_READONLY = 0x00000001;
|
||||
|
||||
/// <summary>开目录句柄必须带 FILE_FLAG_BACKUP_SEMANTICS,否则 CreateFile 会失败。</summary>
|
||||
internal const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;
|
||||
|
||||
/// <summary>顺序扫描提示:读 MFT 时对缓存友好(读一次不再复用)。</summary>
|
||||
internal const uint FILE_FLAG_SEQUENTIAL_SCAN = 0x08000000;
|
||||
|
||||
// ---------------------------------------------------------------- 控制码 CTL_CODE(FILE_DEVICE_FILE_SYSTEM=0x09, ...)
|
||||
|
||||
internal const uint FSCTL_ENUM_USN_DATA = 0x000900B3;
|
||||
internal const uint FSCTL_READ_USN_JOURNAL = 0x000900BB;
|
||||
internal const uint FSCTL_QUERY_USN_JOURNAL = 0x000900F4;
|
||||
internal const uint FSCTL_CREATE_USN_JOURNAL = 0x000900E7;
|
||||
internal const uint FSCTL_DELETE_USN_JOURNAL = 0x000900F8;
|
||||
internal const uint FSCTL_GET_NTFS_VOLUME_DATA = 0x00090064;
|
||||
internal const uint FSCTL_GET_NTFS_FILE_RECORD = 0x00090068;
|
||||
|
||||
/// <summary>FSCTL_DELETE_USN_JOURNAL 的 DeleteFlags:真正删除日志。</summary>
|
||||
internal const uint USN_DELETE_FLAG_DELETE = 0x00000001;
|
||||
internal const uint USN_DELETE_FLAG_DO_NOT_DELETE = 0x00000000;
|
||||
|
||||
// ---------------------------------------------------------------- ReadDirectoryChangesW(USN 日志不可用时的回退监听)
|
||||
|
||||
internal const uint FILE_NOTIFY_CHANGE_FILE_NAME = 0x00000001;
|
||||
internal const uint FILE_NOTIFY_CHANGE_DIR_NAME = 0x00000002;
|
||||
internal const uint FILE_NOTIFY_CHANGE_ATTRIBUTES = 0x00000004;
|
||||
internal const uint FILE_NOTIFY_CHANGE_SIZE = 0x00000008;
|
||||
internal const uint FILE_NOTIFY_CHANGE_LAST_WRITE = 0x00000010;
|
||||
|
||||
internal const uint FILE_ACTION_ADDED = 0x00000001;
|
||||
internal const uint FILE_ACTION_REMOVED = 0x00000002;
|
||||
internal const uint FILE_ACTION_MODIFIED = 0x00000003;
|
||||
internal const uint FILE_ACTION_RENAMED_OLD_NAME = 0x00000004;
|
||||
internal const uint FILE_ACTION_RENAMED_NEW_NAME = 0x00000005;
|
||||
|
||||
// ---------------------------------------------------------------- Win32 错误码
|
||||
|
||||
internal const int ERROR_INVALID_FUNCTION = 1;
|
||||
internal const int ERROR_ACCESS_DENIED = 5;
|
||||
internal const int ERROR_INVALID_HANDLE = 6;
|
||||
internal const int ERROR_NOT_READY = 21;
|
||||
internal const int ERROR_HANDLE_EOF = 38;
|
||||
internal const int ERROR_NOT_SUPPORTED = 50;
|
||||
internal const int ERROR_INVALID_PARAMETER = 87;
|
||||
internal const int ERROR_MORE_DATA = 234;
|
||||
internal const int ERROR_OPERATION_ABORTED = 995;
|
||||
internal const int ERROR_NOTIFY_ENUM_DIR = 1022;
|
||||
internal const int ERROR_JOURNAL_DELETE_IN_PROGRESS = 1178;
|
||||
internal const int ERROR_JOURNAL_NOT_ACTIVE = 1179;
|
||||
internal const int ERROR_JOURNAL_ENTRY_DELETED = 1181;
|
||||
internal const int ERROR_CANCELLED = 1223;
|
||||
|
||||
// ---------------------------------------------------------------- 文件属性
|
||||
|
||||
internal const uint FILE_ATTRIBUTE_DIRECTORY = 0x00000010;
|
||||
|
||||
// ---------------------------------------------------------------- USN 变更原因
|
||||
|
||||
internal const uint USN_REASON_DATA_OVERWRITE = 0x00000001;
|
||||
internal const uint USN_REASON_DATA_EXTEND = 0x00000002;
|
||||
internal const uint USN_REASON_DATA_TRUNCATION = 0x00000004;
|
||||
internal const uint USN_REASON_NAMED_DATA_OVERWRITE = 0x00000010;
|
||||
internal const uint USN_REASON_NAMED_DATA_EXTEND = 0x00000020;
|
||||
internal const uint USN_REASON_NAMED_DATA_TRUNCATION = 0x00000040;
|
||||
internal const uint USN_REASON_FILE_CREATE = 0x00000100;
|
||||
internal const uint USN_REASON_FILE_DELETE = 0x00000200;
|
||||
internal const uint USN_REASON_EA_CHANGE = 0x00000400;
|
||||
internal const uint USN_REASON_SECURITY_CHANGE = 0x00000800;
|
||||
internal const uint USN_REASON_RENAME_OLD_NAME = 0x00001000;
|
||||
internal const uint USN_REASON_RENAME_NEW_NAME = 0x00002000;
|
||||
internal const uint USN_REASON_INDEXABLE_CHANGE = 0x00004000;
|
||||
internal const uint USN_REASON_BASIC_INFO_CHANGE = 0x00008000;
|
||||
internal const uint USN_REASON_HARD_LINK_CHANGE = 0x00010000;
|
||||
internal const uint USN_REASON_COMPRESSION_CHANGE = 0x00020000;
|
||||
internal const uint USN_REASON_ENCRYPTION_CHANGE = 0x00040000;
|
||||
internal const uint USN_REASON_OBJECT_ID_CHANGE = 0x00080000;
|
||||
internal const uint USN_REASON_REPARSE_POINT_CHANGE = 0x00100000;
|
||||
internal const uint USN_REASON_STREAM_CHANGE = 0x00200000;
|
||||
internal const uint USN_REASON_CLOSE = 0x80000000;
|
||||
|
||||
internal const uint USN_REASON_ANY = 0xFFFFFFFF;
|
||||
|
||||
// ---------------------------------------------------------------- 线程访问权限(CancelSynchronousIo 需要 THREAD_TERMINATE)
|
||||
|
||||
internal const uint THREAD_TERMINATE = 0x0001;
|
||||
internal static readonly IntPtr INVALID_HANDLE_VALUE = new(-1);
|
||||
|
||||
// ================================================================ 结构体
|
||||
|
||||
/// <summary>MFT_ENUM_DATA_V0 —— FSCTL_ENUM_USN_DATA 的输入。x64 大小 24。</summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct MftEnumDataV0
|
||||
{
|
||||
internal ulong StartFileReferenceNumber; // 0
|
||||
internal long LowUsn; // 8 枚举时用 0
|
||||
internal long HighUsn; // 16 枚举时用 long.MaxValue
|
||||
}
|
||||
|
||||
/// <summary>USN_JOURNAL_DATA_V0 —— FSCTL_QUERY_USN_JOURNAL 的输出。x64 大小 56。</summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct UsnJournalDataV0
|
||||
{
|
||||
internal ulong UsnJournalID; // 0
|
||||
internal long FirstUsn; // 8
|
||||
internal long NextUsn; // 16
|
||||
internal long LowestValidUsn; // 24
|
||||
internal long MaxUsn; // 32
|
||||
internal ulong MaximumSize; // 40
|
||||
internal ulong AllocationDelta; // 48
|
||||
}
|
||||
|
||||
/// <summary>READ_USN_JOURNAL_DATA_V0 —— FSCTL_READ_USN_JOURNAL 的输入。x64 大小 40。</summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct ReadUsnJournalDataV0
|
||||
{
|
||||
internal long StartUsn; // 0
|
||||
internal uint ReasonMask; // 8
|
||||
internal uint ReturnOnlyOnClose; // 12
|
||||
internal ulong Timeout; // 16 100ns 单位;0 = 无限等待
|
||||
internal ulong BytesToWaitFor; // 24 攒够这么多字节再返回(减少唤醒次数)
|
||||
internal ulong UsnJournalID; // 32
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// USN_RECORD_V2 的固定头部(不含变长文件名)。
|
||||
///
|
||||
/// 字段偏移与 Windows SDK 完全一致(x64):RecordLength@0、MajorVersion@4、FRN@8、
|
||||
/// ParentFrn@16、Usn@24、TimeStamp@32、Reason@40、SourceInfo@44、SecurityId@48、
|
||||
/// FileAttributes@52、FileNameLength@56、FileNameOffset@58;变长文件名紧跟在第 60 字节之后。
|
||||
///
|
||||
/// 显式 Pack=4 的原因:默认 8 字节对齐会把 sizeof 从 60 凑成 64(尾部补 4 字节),
|
||||
/// 字段偏移虽然不变,但用 sizeof(T) 做缓冲边界判断会凭空多要求 4 字节;
|
||||
/// Pack=4 下偏移完全不变、sizeof 恰好 60,与磁盘上的紧凑布局严格一致。
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 4)]
|
||||
internal struct UsnRecordV2
|
||||
{
|
||||
internal uint RecordLength; // 0
|
||||
internal ushort MajorVersion; // 4 必须 == 2
|
||||
internal ushort MinorVersion; // 6
|
||||
internal ulong FileReferenceNumber; // 8 高 16 位是序列号,低 48 位是 MFT 记录号
|
||||
internal ulong ParentFileReferenceNumber; // 16 同上
|
||||
internal long Usn; // 24
|
||||
internal long TimeStamp; // 32 FILETIME(100ns since 1601)
|
||||
internal uint Reason; // 40
|
||||
internal uint SourceInfo; // 44
|
||||
internal uint SecurityId; // 48
|
||||
internal uint FileAttributes; // 52
|
||||
internal ushort FileNameLength; // 56 字节数,非字符数
|
||||
internal ushort FileNameOffset; // 58 相对记录起始的字节偏移
|
||||
|
||||
internal const int Size = 60;
|
||||
}
|
||||
|
||||
/// <summary>NTFS_VOLUME_DATA_BUFFER —— FSCTL_GET_NTFS_VOLUME_DATA 的输出。x64 大小 96。</summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct NtfsVolumeDataBuffer
|
||||
{
|
||||
internal long VolumeSerialNumber; // 0
|
||||
internal long NumberSectors; // 8
|
||||
internal long TotalClusters; // 16
|
||||
internal long FreeClusters; // 24
|
||||
internal long TotalReserved; // 32
|
||||
internal uint BytesPerSector; // 40
|
||||
internal uint BytesPerCluster; // 44
|
||||
internal uint BytesPerFileRecordSegment; // 48
|
||||
internal uint ClustersPerFileRecordSegment; // 52
|
||||
internal long MftValidDataLength; // 56
|
||||
internal long MftStartLcn; // 64
|
||||
internal long Mft2StartLcn; // 72
|
||||
internal long MftZoneStart; // 80
|
||||
internal long MftZoneEnd; // 88
|
||||
}
|
||||
|
||||
/// <summary>NTFS_FILE_RECORD_INPUT_BUFFER —— FSCTL_GET_NTFS_FILE_RECORD 的输入。x64 大小 8。</summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct NtfsFileRecordInputBuffer
|
||||
{
|
||||
internal ulong FileReferenceNumber; // 只取低 48 位记录号
|
||||
}
|
||||
|
||||
/// <summary>NTFS_FILE_RECORD_OUTPUT_BUFFER 的固定头。x64 大小 12(后面紧跟变长记录)。</summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct NtfsFileRecordOutputBuffer
|
||||
{
|
||||
internal ulong FileReferenceNumber; // 0
|
||||
internal uint FileRecordLength; // 8
|
||||
}
|
||||
|
||||
/// <summary>FSCTL_CREATE_USN_JOURNAL 的输入。x64 大小 16;两个字段都为 0 = 使用系统默认值。</summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct CreateUsnJournalData
|
||||
{
|
||||
internal ulong MaximumSize; // 0 = 系统默认
|
||||
internal ulong AllocationDelta; // 0 = 系统默认
|
||||
}
|
||||
|
||||
/// <summary>FSCTL_DELETE_USN_JOURNAL 的输入。x64 大小 16(ulong + DWORD + 对齐填充)。</summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct DeleteUsnJournalData
|
||||
{
|
||||
internal ulong UsnJournalID;
|
||||
internal uint DeleteFlags;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FILETIME 的精确布局:两个 DWORD。
|
||||
/// 刻意不用 long —— 在 LayoutKind.Sequential 下 long 会带来 8 字节对齐,
|
||||
/// 从而把 BY_HANDLE_FILE_INFORMATION 的后续字段全部顶偏。
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct FileTimeValue
|
||||
{
|
||||
internal uint LowDateTime;
|
||||
internal uint HighDateTime;
|
||||
|
||||
internal readonly long ToInt64() => ((long)HighDateTime << 32) | LowDateTime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// BY_HANDLE_FILE_INFORMATION(GetFileInformationByHandle 的输出)。x64 大小 52。
|
||||
/// 其中 FileIndexHigh/Low 合起来就是文件的 64 位 FRN(低 48 位记录号 + 高 16 位序列号),
|
||||
/// 与 USN_RECORD_V2 里的 FileReferenceNumber 口径一致 —— 这是 RDCW 回退路径能定位索引条目的关键。
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct ByHandleFileInformation
|
||||
{
|
||||
internal uint FileAttributes; // 0
|
||||
internal FileTimeValue CreationTime; // 4
|
||||
internal FileTimeValue LastAccessTime; // 12
|
||||
internal FileTimeValue LastWriteTime; // 20
|
||||
internal uint VolumeSerialNumber; // 28
|
||||
internal uint FileSizeHigh; // 32
|
||||
internal uint FileSizeLow; // 36
|
||||
internal uint NumberOfLinks; // 40
|
||||
internal uint FileIndexHigh; // 44
|
||||
internal uint FileIndexLow; // 48
|
||||
|
||||
internal readonly ulong FileIndex => ((ulong)FileIndexHigh << 32) | FileIndexLow;
|
||||
|
||||
internal readonly long FileSize => ((long)FileSizeHigh << 32) | FileSizeLow;
|
||||
}
|
||||
|
||||
/// <summary>FILE_NOTIFY_INFORMATION 的固定头(变长文件名紧跟其后,UTF-16,不以 NUL 结尾)。</summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct FileNotifyInformation
|
||||
{
|
||||
internal uint NextEntryOffset; // 0
|
||||
internal uint Action; // 4
|
||||
internal uint FileNameLength; // 8,字节数
|
||||
// WCHAR FileName[1]; // 12
|
||||
internal const int HeaderSize = 12;
|
||||
}
|
||||
|
||||
// ================================================================ P/Invoke
|
||||
|
||||
[DllImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, CharSet = CharSet.Unicode, ExactSpelling = true)]
|
||||
internal static extern SafeFileHandle CreateFileW(
|
||||
string lpFileName,
|
||||
uint dwDesiredAccess,
|
||||
uint dwShareMode,
|
||||
IntPtr lpSecurityAttributes,
|
||||
uint dwCreationDisposition,
|
||||
uint dwFlagsAndAttributes,
|
||||
IntPtr hTemplateFile);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
internal static extern unsafe bool DeviceIoControl(
|
||||
SafeFileHandle hDevice,
|
||||
uint dwIoControlCode,
|
||||
void* lpInBuffer,
|
||||
uint nInBufferSize,
|
||||
void* lpOutBuffer,
|
||||
uint nOutBufferSize,
|
||||
out uint lpBytesReturned,
|
||||
IntPtr lpOverlapped);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
internal static extern unsafe bool ReadFile(
|
||||
SafeFileHandle hFile,
|
||||
void* lpBuffer,
|
||||
uint nNumberOfBytesToRead,
|
||||
out uint lpNumberOfBytesRead,
|
||||
IntPtr lpOverlapped);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
internal static extern unsafe bool SetFilePointerEx(
|
||||
SafeFileHandle hFile,
|
||||
long liDistanceToMove,
|
||||
out long lpNewFilePointer,
|
||||
uint dwMoveMethod);
|
||||
|
||||
internal const uint FILE_BEGIN = 0;
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
internal static extern bool CloseHandle(IntPtr hObject);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
internal static extern IntPtr OpenThread(uint dwDesiredAccess, bool bInheritHandle, uint dwThreadId);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
internal static extern uint GetCurrentThreadId();
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
internal static extern bool CancelSynchronousIo(IntPtr hThread);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
internal static extern bool GetFileInformationByHandle(SafeFileHandle hFile, out ByHandleFileInformation lpFileInformation);
|
||||
|
||||
/// <summary>
|
||||
/// 递归监听目录变化。lpOverlapped == NULL 时是同步阻塞调用(靠 CancelSynchronousIo 打断)。
|
||||
/// 返回 TRUE 且 bytesReturned == 0 表示变更缓冲区溢出(ERROR_NOTIFY_ENUM_DIR),期间的事件已丢失。
|
||||
/// </summary>
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
internal static extern unsafe bool ReadDirectoryChangesW(
|
||||
SafeFileHandle hDirectory,
|
||||
void* lpBuffer,
|
||||
uint nBufferLength,
|
||||
bool bWatchSubtree,
|
||||
uint dwNotifyFilter,
|
||||
out uint lpBytesReturned,
|
||||
IntPtr lpOverlapped,
|
||||
IntPtr lpCompletionRoutine);
|
||||
|
||||
// ================================================================ 托管包装
|
||||
|
||||
/// <summary>把读/写缓冲固定后调用 DeviceIoControl;返回 false 时用 <see cref="Marshal.GetLastWin32Error"/> 取错误码。</summary>
|
||||
internal static unsafe bool Ioctl(SafeFileHandle handle, uint code, ReadOnlySpan<byte> input, Span<byte> output, out int bytesReturned)
|
||||
{
|
||||
fixed (byte* pIn = input)
|
||||
fixed (byte* pOut = output)
|
||||
{
|
||||
var ok = DeviceIoControl(
|
||||
handle, code,
|
||||
input.Length == 0 ? null : pIn, (uint)input.Length,
|
||||
output.Length == 0 ? null : pOut, (uint)output.Length,
|
||||
out var ret, IntPtr.Zero);
|
||||
bytesReturned = (int)ret;
|
||||
return ok;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>在卷句柄上做一次带偏移的同步读取(卷句柄偏移 = 卷内绝对字节偏移)。</summary>
|
||||
internal static unsafe int ReadAt(SafeFileHandle handle, Span<byte> buffer, long offset)
|
||||
{
|
||||
if (!SetFilePointerEx(handle, offset, out _, FILE_BEGIN)) return -1;
|
||||
fixed (byte* p = buffer)
|
||||
{
|
||||
if (!ReadFile(handle, p, (uint)buffer.Length, out var read, IntPtr.Zero)) return -1;
|
||||
return (int)read;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按路径取文件的 64 位 FRN 与基本元数据(大小/属性/最后写入时间)。
|
||||
/// 这是 RDCW 回退监听能定位索引条目的基础:句柄上的 FileIndex 与 USN 的 FRN 同口径。
|
||||
/// 只要求 FILE_READ_ATTRIBUTES,普通用户也能用(目录需要 FILE_FLAG_BACKUP_SEMANTICS)。
|
||||
/// </summary>
|
||||
internal static bool TryStatPath(string path, out ByHandleFileInformation info)
|
||||
{
|
||||
info = default;
|
||||
var handle = CreateFileW(
|
||||
path,
|
||||
FILE_READ_ATTRIBUTES,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
||||
IntPtr.Zero,
|
||||
OPEN_EXISTING,
|
||||
FILE_FLAG_BACKUP_SEMANTICS,
|
||||
IntPtr.Zero);
|
||||
if (handle.IsInvalid)
|
||||
{
|
||||
handle.Dispose();
|
||||
return false;
|
||||
}
|
||||
try
|
||||
{
|
||||
return GetFileInformationByHandle(handle, out info);
|
||||
}
|
||||
finally
|
||||
{
|
||||
handle.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>把 Win32 错误码翻译成中文可读信息,供 UI 直接展示。</summary>
|
||||
internal static string DescribeError(int error, string? context = null)
|
||||
{
|
||||
var text = error switch
|
||||
{
|
||||
ERROR_ACCESS_DENIED => "访问被拒绝(需要管理员权限)",
|
||||
ERROR_INVALID_FUNCTION => "函数不正确(非 NTFS 卷、或无管理员权限调用 NTFS 专属 FSCTL 都会返回它)",
|
||||
ERROR_NOT_SUPPORTED => "该卷不支持此操作",
|
||||
ERROR_JOURNAL_NOT_ACTIVE => "该卷未启用 USN 变更日志",
|
||||
ERROR_JOURNAL_DELETE_IN_PROGRESS => "USN 变更日志正在被删除",
|
||||
ERROR_JOURNAL_ENTRY_DELETED => "请求的 USN 记录已被删除",
|
||||
ERROR_HANDLE_EOF => "已到数据末尾",
|
||||
ERROR_OPERATION_ABORTED => "操作已取消",
|
||||
ERROR_CANCELLED => "操作已取消",
|
||||
ERROR_NOT_READY => "卷未就绪",
|
||||
_ => SafeSystemMessage(error)
|
||||
};
|
||||
return context is null ? text : $"{context}:{text}(Win32 错误 {error})";
|
||||
}
|
||||
|
||||
private static string SafeSystemMessage(int error)
|
||||
{
|
||||
try
|
||||
{
|
||||
return new Win32Exception(error).Message;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "未知错误";
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================ 记录号归一化
|
||||
|
||||
/// <summary>MFT 记录号掩码:FRN 的低 48 位。</summary>
|
||||
internal const ulong RecordNumberMask = 0x0000_FFFF_FFFF_FFFFUL;
|
||||
|
||||
/// <summary>剥掉 FRN 高 16 位的序列号,只保留 MFT 记录号(全索引统一用这个做键)。</summary>
|
||||
internal static ulong NormalizeFrn(ulong frn) => frn & RecordNumberMask;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,107 @@
|
||||
namespace FluidExplorer.Services.Search.Usn;
|
||||
|
||||
/// <summary>
|
||||
/// 通配符匹配器:<c>?</c> 匹配任意单字符,<c>*</c> 匹配任意长度(含空),
|
||||
/// 大小写不敏感(用固定区域的大小写折叠,不产生任何分配、不受当前区域影响)。
|
||||
///
|
||||
/// 实现为经典的“双指针 + 最近星号回溯”,最坏 O(n*m),但对文件名这种短串是纳秒级。
|
||||
/// </summary>
|
||||
public static class WildcardMatcher
|
||||
{
|
||||
public static bool IsMatch(ReadOnlySpan<char> text, ReadOnlySpan<char> pattern)
|
||||
{
|
||||
// ---- 快路径:绝大多数真实查询是 "*.json" / "log*" / "*tmp*" 这类“只有一个/两个通配符”的模式,
|
||||
// 直接退化成 EndsWith/StartsWith/IndexOf(走的是 BCL 的高度优化实现),比通用回溯快一个数量级。
|
||||
int stars = 0, questions = 0, firstStar = -1, lastStar = -1;
|
||||
for (int i = 0; i < pattern.Length; i++)
|
||||
{
|
||||
var c = pattern[i];
|
||||
if (c == '*')
|
||||
{
|
||||
stars++;
|
||||
if (firstStar < 0) firstStar = i;
|
||||
lastStar = i;
|
||||
}
|
||||
else if (c == '?')
|
||||
{
|
||||
questions++;
|
||||
}
|
||||
}
|
||||
|
||||
if (questions == 0)
|
||||
{
|
||||
switch (stars)
|
||||
{
|
||||
case 0:
|
||||
return text.Equals(pattern, StringComparison.OrdinalIgnoreCase);
|
||||
case 1 when firstStar == 0:
|
||||
return text.EndsWith(pattern[1..], StringComparison.OrdinalIgnoreCase);
|
||||
case 1 when firstStar == pattern.Length - 1:
|
||||
return text.StartsWith(pattern[..^1], StringComparison.OrdinalIgnoreCase);
|
||||
case 2 when firstStar == 0 && lastStar == pattern.Length - 1:
|
||||
return text.IndexOf(pattern[1..^1], StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 通用路径:双指针 + 最近星号回溯 ----
|
||||
int t = 0, p = 0;
|
||||
int starPattern = -1;
|
||||
int starText = 0;
|
||||
|
||||
while (t < text.Length)
|
||||
{
|
||||
if (p < pattern.Length && (pattern[p] == '?' || FoldEquals(pattern[p], text[t])))
|
||||
{
|
||||
t++;
|
||||
p++;
|
||||
}
|
||||
else if (p < pattern.Length && pattern[p] == '*')
|
||||
{
|
||||
// 记下最近的星号位置,先当它匹配空串继续往前走
|
||||
starPattern = p++;
|
||||
starText = t;
|
||||
}
|
||||
else if (starPattern >= 0)
|
||||
{
|
||||
// 回溯:让最近的星号多吃一个字符
|
||||
p = starPattern + 1;
|
||||
t = ++starText;
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
while (p < pattern.Length && pattern[p] == '*') p++;
|
||||
return p == pattern.Length;
|
||||
}
|
||||
|
||||
/// <summary>是否包含通配符。</summary>
|
||||
public static bool HasWildcard(ReadOnlySpan<char> pattern) =>
|
||||
pattern.IndexOfAny('*', '?') >= 0;
|
||||
|
||||
/// <summary>
|
||||
/// 返回模式开头连续的字面量前缀(遇到 * 或 ? 为止),用于给通配符命中排优先级。
|
||||
/// </summary>
|
||||
public static ReadOnlySpan<char> LiteralPrefix(ReadOnlySpan<char> pattern)
|
||||
{
|
||||
int i = 0;
|
||||
while (i < pattern.Length && pattern[i] is not ('*' or '?')) i++;
|
||||
return pattern[..i];
|
||||
}
|
||||
|
||||
/// <summary>区域无关的大小写折叠比较(只处理 ASCII 与 BMP 常见情形,足够文件名使用)。</summary>
|
||||
internal static bool FoldEquals(char a, char b) =>
|
||||
a == b || char.ToUpperInvariant(a) == char.ToUpperInvariant(b);
|
||||
|
||||
/// <summary>模式里是否只剩星号(即 "*" 或 "**" 这类恒真模式)。</summary>
|
||||
internal static bool IsMatchAll(ReadOnlySpan<char> pattern)
|
||||
{
|
||||
foreach (var c in pattern)
|
||||
{
|
||||
if (c != '*') return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user