Commit ·
1c4658f
0
Parent(s):
initial commit
Browse files- .gitignore +47 -0
- FastSeek.Cli/FastSeek.Cli.csproj +12 -0
- FastSeek.Cli/Program.cs +102 -0
- FastSeek.Core/Drives.cs +38 -0
- FastSeek.Core/FastSeek.Core.csproj +8 -0
- FastSeek.Core/FastSeekEngine.cs +301 -0
- FastSeek.Core/IndexStore.cs +124 -0
- FastSeek.Core/MftReader.cs +239 -0
- FastSeek.Core/NativeMethods.cs +136 -0
- FastSeek.Core/SearchEngine.cs +103 -0
- FastSeek.Core/Types.cs +56 -0
- FastSeek.Core/UsnWatcher.cs +130 -0
- FastSeek.WinUI/App.xaml +15 -0
- FastSeek.WinUI/App.xaml.cs +19 -0
- FastSeek.WinUI/FastSeek.WinUI.csproj +27 -0
- FastSeek.WinUI/MainWindow.xaml +35 -0
- FastSeek.WinUI/MainWindow.xaml.cs +147 -0
- FastSeek.WinUI/app.manifest +9 -0
- global.json +5 -0
.gitignore
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# .NET build artifacts
|
| 2 |
+
**/bin/
|
| 3 |
+
**/obj/
|
| 4 |
+
**/out/
|
| 5 |
+
|
| 6 |
+
# Visual Studio files
|
| 7 |
+
**/.vs/
|
| 8 |
+
**/*.user
|
| 9 |
+
**/*.suo
|
| 10 |
+
**/*.userprefs
|
| 11 |
+
**/*.sln.docstates
|
| 12 |
+
|
| 13 |
+
# Rust build artifacts
|
| 14 |
+
**/target/
|
| 15 |
+
|
| 16 |
+
# IDE files
|
| 17 |
+
**/.idea/
|
| 18 |
+
**/.vscode/
|
| 19 |
+
**/*.swp
|
| 20 |
+
**/*.swo
|
| 21 |
+
|
| 22 |
+
# Logs
|
| 23 |
+
**/*.log
|
| 24 |
+
**/logs/
|
| 25 |
+
|
| 26 |
+
# Temporary files
|
| 27 |
+
**/*.tmp
|
| 28 |
+
**/*.temp
|
| 29 |
+
**/temp/
|
| 30 |
+
**/tmp/
|
| 31 |
+
|
| 32 |
+
# OS files
|
| 33 |
+
**/.DS_Store
|
| 34 |
+
**/Thumbs.db
|
| 35 |
+
|
| 36 |
+
# NuGet
|
| 37 |
+
**/packages/
|
| 38 |
+
**/*.nupkg
|
| 39 |
+
|
| 40 |
+
# Test results
|
| 41 |
+
**/TestResults/
|
| 42 |
+
**/*.trx
|
| 43 |
+
|
| 44 |
+
# Coverage
|
| 45 |
+
**/coverage/
|
| 46 |
+
**/*.coverage
|
| 47 |
+
**/*.cobertura.xml
|
FastSeek.Cli/FastSeek.Cli.csproj
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<Project Sdk="Microsoft.NET.Sdk">
|
| 2 |
+
<PropertyGroup>
|
| 3 |
+
<OutputType>Exe</OutputType>
|
| 4 |
+
<TargetFramework>net8.0-windows</TargetFramework>
|
| 5 |
+
<ImplicitUsings>enable</ImplicitUsings>
|
| 6 |
+
<Nullable>enable</Nullable>
|
| 7 |
+
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
| 8 |
+
</PropertyGroup>
|
| 9 |
+
<ItemGroup>
|
| 10 |
+
<ProjectReference Include="..\FastSeek.Core\FastSeek.Core.csproj" />
|
| 11 |
+
</ItemGroup>
|
| 12 |
+
</Project>
|
FastSeek.Cli/Program.cs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using System.Diagnostics;
|
| 2 |
+
using FastSeek.Core;
|
| 3 |
+
|
| 4 |
+
namespace FastSeek.Cli;
|
| 5 |
+
|
| 6 |
+
internal static class Program
|
| 7 |
+
{
|
| 8 |
+
private static void Main()
|
| 9 |
+
{
|
| 10 |
+
Console.WriteLine("FastSeek - starting...");
|
| 11 |
+
using var engine = new FastSeekEngine();
|
| 12 |
+
engine.InitializeAsync(msg => Console.WriteLine($"[startup] {msg}")).GetAwaiter().GetResult();
|
| 13 |
+
|
| 14 |
+
var configPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "fastsearch", "config.txt");
|
| 15 |
+
Directory.CreateDirectory(Path.GetDirectoryName(configPath)!);
|
| 16 |
+
engine.ExcludedDirs.AddRange(LoadExclusions(configPath));
|
| 17 |
+
|
| 18 |
+
var resultLimit = 200;
|
| 19 |
+
|
| 20 |
+
Console.WriteLine("FastSeek - File Search");
|
| 21 |
+
PrintMetrics(engine);
|
| 22 |
+
Console.WriteLine("Commands: case, count, metrics, options, limit <n>, clearcache, exclusions, exclude <path>, unexclude <path>, quit");
|
| 23 |
+
|
| 24 |
+
while (true)
|
| 25 |
+
{
|
| 26 |
+
Console.Write("search> ");
|
| 27 |
+
var input = Console.ReadLine()?.Trim() ?? string.Empty;
|
| 28 |
+
if (string.IsNullOrEmpty(input)) continue;
|
| 29 |
+
if (input is "quit" or "exit" or "q") break;
|
| 30 |
+
if (input == "case") { engine.CaseSensitive = !engine.CaseSensitive; Console.WriteLine(engine.CaseSensitive ? "case sensitivity: ON" : "case sensitivity: OFF"); continue; }
|
| 31 |
+
if (input == "count") { Console.WriteLine($"{engine.Count():N0} files in index"); continue; }
|
| 32 |
+
if (input == "metrics") { PrintMetrics(engine); continue; }
|
| 33 |
+
if (input == "options") { Console.WriteLine($"case={(engine.CaseSensitive ? "on" : "off")}, limit={resultLimit}, exclusions={engine.ExcludedDirs.Count}, cache={engine.CachePath}"); continue; }
|
| 34 |
+
if (input == "clearcache")
|
| 35 |
+
{
|
| 36 |
+
try
|
| 37 |
+
{
|
| 38 |
+
if (File.Exists(engine.CachePath)) File.Delete(engine.CachePath);
|
| 39 |
+
Console.WriteLine($"cache cleared: {engine.CachePath}");
|
| 40 |
+
Console.WriteLine("restart FastSeek to force full rescan");
|
| 41 |
+
}
|
| 42 |
+
catch (Exception ex)
|
| 43 |
+
{
|
| 44 |
+
Console.WriteLine($"failed to clear cache: {ex.Message}");
|
| 45 |
+
}
|
| 46 |
+
continue;
|
| 47 |
+
}
|
| 48 |
+
if (input.StartsWith("limit ", StringComparison.OrdinalIgnoreCase))
|
| 49 |
+
{
|
| 50 |
+
if (int.TryParse(input[6..].Trim(), out var parsed) && parsed > 0 && parsed <= 5000)
|
| 51 |
+
{
|
| 52 |
+
resultLimit = parsed;
|
| 53 |
+
Console.WriteLine($"result limit set to {resultLimit}");
|
| 54 |
+
}
|
| 55 |
+
else Console.WriteLine("usage: limit <1..5000>");
|
| 56 |
+
continue;
|
| 57 |
+
}
|
| 58 |
+
if (input.StartsWith("exclude ", StringComparison.OrdinalIgnoreCase)) { var p = NormalizeExclude(input[8..]); if (!engine.ExcludedDirs.Contains(p)) engine.ExcludedDirs.Add(p); SaveExclusions(configPath, engine.ExcludedDirs); Console.WriteLine($"excluded: {p}"); continue; }
|
| 59 |
+
if (input.StartsWith("unexclude ", StringComparison.OrdinalIgnoreCase)) { var p = NormalizeExclude(input[10..]); engine.ExcludedDirs.RemoveAll(x => x == p); SaveExclusions(configPath, engine.ExcludedDirs); Console.WriteLine($"removed: {p}"); continue; }
|
| 60 |
+
if (input == "exclusions") { Console.WriteLine(engine.ExcludedDirs.Count == 0 ? "no excluded directories" : string.Join(Environment.NewLine, engine.ExcludedDirs.Select(x => $"- {x}"))); continue; }
|
| 61 |
+
|
| 62 |
+
var sw = Stopwatch.StartNew();
|
| 63 |
+
var results = engine.Search(input, resultLimit);
|
| 64 |
+
sw.Stop();
|
| 65 |
+
|
| 66 |
+
if (results.Count == 0) Console.WriteLine($"no results for \"{input}\"");
|
| 67 |
+
else
|
| 68 |
+
{
|
| 69 |
+
for (var i = 0; i < results.Count; i++) Console.WriteLine($"[{i + 1,4}] [{(results[i].IsDir ? "DIR " : "FILE")}] {results[i].FullPath}");
|
| 70 |
+
Console.WriteLine($"\nResults: {results.Count:N0} Time: {sw.Elapsed.TotalMilliseconds:F2} ms Limit: {resultLimit:N0}\n");
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
private static void PrintMetrics(FastSeekEngine engine)
|
| 76 |
+
{
|
| 77 |
+
var m = engine.LastStartupMetrics;
|
| 78 |
+
if (m is null) return;
|
| 79 |
+
|
| 80 |
+
Console.WriteLine();
|
| 81 |
+
Console.WriteLine("Startup Metrics");
|
| 82 |
+
Console.WriteLine("---------------");
|
| 83 |
+
Console.WriteLine($"Cache : {(m.CacheLoaded ? "HIT" : "MISS")}");
|
| 84 |
+
Console.WriteLine($"Total Files : {m.TotalFiles:N0}");
|
| 85 |
+
Console.WriteLine($"Drives : {m.DriveDiscoveryMs,10:F2} ms");
|
| 86 |
+
Console.WriteLine($"Cache Load : {m.CacheLoadMs,10:F2} ms");
|
| 87 |
+
Console.WriteLine($"Scan : {m.ScanMs,10:F2} ms");
|
| 88 |
+
Console.WriteLine($"Index : {m.IndexMs,10:F2} ms");
|
| 89 |
+
Console.WriteLine($"Cache Save : {m.CacheSaveMs,10:F2} ms");
|
| 90 |
+
Console.WriteLine($"Startup Total: {m.TotalStartupMs,10:F2} ms");
|
| 91 |
+
Console.WriteLine($"Cache Path : {engine.CachePath}");
|
| 92 |
+
Console.WriteLine();
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
private static List<string> LoadExclusions(string path) => File.Exists(path) ? File.ReadAllLines(path).Select(l => l.Trim().ToLowerInvariant()).Where(l => l.Length > 0).ToList() : [];
|
| 96 |
+
private static void SaveExclusions(string path, List<string> dirs) => File.WriteAllText(path, string.Join(Environment.NewLine, dirs));
|
| 97 |
+
private static string NormalizeExclude(string path)
|
| 98 |
+
{
|
| 99 |
+
var p = path.Trim().ToLowerInvariant();
|
| 100 |
+
return p.EndsWith("\\") || p.EndsWith("/") ? p : p + "\\";
|
| 101 |
+
}
|
| 102 |
+
}
|
FastSeek.Core/Drives.cs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using System.Text;
|
| 2 |
+
using FastSeek.Core.Mft;
|
| 3 |
+
|
| 4 |
+
namespace FastSeek.Core.Utils;
|
| 5 |
+
|
| 6 |
+
public static class Drives
|
| 7 |
+
{
|
| 8 |
+
public static List<NtfsDrive> GetNtfsDrives()
|
| 9 |
+
{
|
| 10 |
+
var drives = new List<NtfsDrive>();
|
| 11 |
+
var buffer = new char[256];
|
| 12 |
+
var len = NativeMethods.GetLogicalDriveStrings((uint)buffer.Length, buffer);
|
| 13 |
+
if (len == 0) return drives;
|
| 14 |
+
|
| 15 |
+
var all = new string(buffer, 0, (int)len).Split('\0', StringSplitOptions.RemoveEmptyEntries);
|
| 16 |
+
foreach (var root in all)
|
| 17 |
+
{
|
| 18 |
+
if (!IsNtfs(root)) continue;
|
| 19 |
+
var letter = root[0];
|
| 20 |
+
drives.Add(new NtfsDrive
|
| 21 |
+
{
|
| 22 |
+
Letter = letter,
|
| 23 |
+
Root = root,
|
| 24 |
+
DevicePath = $"\\\\.\\{letter}:"
|
| 25 |
+
});
|
| 26 |
+
}
|
| 27 |
+
return drives;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
private static bool IsNtfs(string root)
|
| 31 |
+
{
|
| 32 |
+
var fs = new char[32];
|
| 33 |
+
var ok = NativeMethods.GetVolumeInformation(root, null, 0, out _, out _, out _, fs, (uint)fs.Length);
|
| 34 |
+
if (!ok) return false;
|
| 35 |
+
var fsName = new string(fs).TrimEnd('\0');
|
| 36 |
+
return fsName.StartsWith("NTFS", StringComparison.OrdinalIgnoreCase);
|
| 37 |
+
}
|
| 38 |
+
}
|
FastSeek.Core/FastSeek.Core.csproj
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<Project Sdk="Microsoft.NET.Sdk">
|
| 2 |
+
<PropertyGroup>
|
| 3 |
+
<TargetFramework>net8.0-windows</TargetFramework>
|
| 4 |
+
<ImplicitUsings>enable</ImplicitUsings>
|
| 5 |
+
<Nullable>enable</Nullable>
|
| 6 |
+
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
| 7 |
+
</PropertyGroup>
|
| 8 |
+
</Project>
|
FastSeek.Core/FastSeekEngine.cs
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using System.Collections.Concurrent;
|
| 2 |
+
using System.Diagnostics;
|
| 3 |
+
using FastSeek.Core.Index;
|
| 4 |
+
using FastSeek.Core.Mft;
|
| 5 |
+
using FastSeek.Core.Search;
|
| 6 |
+
using FastSeek.Core.Utils;
|
| 7 |
+
|
| 8 |
+
namespace FastSeek.Core;
|
| 9 |
+
|
| 10 |
+
public sealed class StartupMetrics
|
| 11 |
+
{
|
| 12 |
+
public bool CacheLoaded { get; init; }
|
| 13 |
+
public int TotalFiles { get; init; }
|
| 14 |
+
public double DriveDiscoveryMs { get; init; }
|
| 15 |
+
public double ScanMs { get; init; }
|
| 16 |
+
public double IndexMs { get; init; }
|
| 17 |
+
public double CacheLoadMs { get; init; }
|
| 18 |
+
public double CacheSaveMs { get; init; }
|
| 19 |
+
public double TotalStartupMs { get; init; }
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
public sealed class FastSeekEngine : IDisposable
|
| 23 |
+
{
|
| 24 |
+
private readonly ReaderWriterLockSlim _storeLock = new();
|
| 25 |
+
private readonly BlockingCollection<IndexEvent> _queue = new();
|
| 26 |
+
private readonly CancellationTokenSource _cts = new();
|
| 27 |
+
private readonly object _checkpointLock = new();
|
| 28 |
+
private readonly string _cachePath = Path.Combine(Path.GetTempPath(), "fastseek_cache.bin");
|
| 29 |
+
|
| 30 |
+
private readonly List<JournalCheckpoint> _checkpoints = new();
|
| 31 |
+
private Task? _eventTask;
|
| 32 |
+
private readonly List<Task> _watcherTasks = new();
|
| 33 |
+
|
| 34 |
+
public IndexStore Store { get; } = new();
|
| 35 |
+
public bool CaseSensitive { get; set; }
|
| 36 |
+
public List<string> ExcludedDirs { get; } = new();
|
| 37 |
+
public StartupMetrics? LastStartupMetrics { get; private set; }
|
| 38 |
+
public string CachePath => _cachePath;
|
| 39 |
+
|
| 40 |
+
public async Task InitializeAsync(Action<string>? log = null)
|
| 41 |
+
{
|
| 42 |
+
log?.Invoke("Initializing FastSeek engine...");
|
| 43 |
+
var swTotal = Stopwatch.StartNew();
|
| 44 |
+
|
| 45 |
+
var swDrives = Stopwatch.StartNew();
|
| 46 |
+
var drives = Drives.GetNtfsDrives();
|
| 47 |
+
swDrives.Stop();
|
| 48 |
+
log?.Invoke($"Detected NTFS drives: {string.Join(", ", drives.Select(d => $"{d.Letter}:"))}");
|
| 49 |
+
if (drives.Count == 0) throw new InvalidOperationException("No NTFS drives found.");
|
| 50 |
+
|
| 51 |
+
var swCacheLoad = Stopwatch.StartNew();
|
| 52 |
+
log?.Invoke($"Loading cache: {_cachePath}");
|
| 53 |
+
var loaded = TryLoadCache(_cachePath);
|
| 54 |
+
swCacheLoad.Stop();
|
| 55 |
+
log?.Invoke(loaded ? "Cache hit. Loaded index from cache." : "Cache miss. Running full MFT scan...");
|
| 56 |
+
|
| 57 |
+
double scanMs = 0;
|
| 58 |
+
double indexMs = 0;
|
| 59 |
+
double cacheSaveMs = 0;
|
| 60 |
+
|
| 61 |
+
if (!loaded)
|
| 62 |
+
{
|
| 63 |
+
var swScan = Stopwatch.StartNew();
|
| 64 |
+
foreach (var d in drives)
|
| 65 |
+
{
|
| 66 |
+
log?.Invoke($"Scanning drive {d.Letter}:");
|
| 67 |
+
using var reader = MftReader.Open(d);
|
| 68 |
+
var direct = reader.ScanDirect(TimeSpan.FromSeconds(6), m => log?.Invoke($"[{d.Letter}:direct] {m}"));
|
| 69 |
+
var scan = direct ?? reader.Scan(m => log?.Invoke($"[{d.Letter}:ioctl] {m}"));
|
| 70 |
+
var method = direct is null ? "ioctl (fallback)" : "direct";
|
| 71 |
+
_storeLock.EnterWriteLock();
|
| 72 |
+
try { Store.PopulateFromScan(scan, d.Root); } finally { _storeLock.ExitWriteLock(); }
|
| 73 |
+
log?.Invoke($"Finished drive {d.Letter}: {scan.Records.Count:N0} records ({method})");
|
| 74 |
+
}
|
| 75 |
+
swScan.Stop();
|
| 76 |
+
scanMs = swScan.Elapsed.TotalMilliseconds;
|
| 77 |
+
|
| 78 |
+
var swIndex = Stopwatch.StartNew();
|
| 79 |
+
_storeLock.EnterWriteLock();
|
| 80 |
+
try { Store.FinalizeStore(); } finally { _storeLock.ExitWriteLock(); }
|
| 81 |
+
swIndex.Stop();
|
| 82 |
+
indexMs = swIndex.Elapsed.TotalMilliseconds;
|
| 83 |
+
|
| 84 |
+
var swCacheSave = Stopwatch.StartNew();
|
| 85 |
+
log?.Invoke("Saving cache...");
|
| 86 |
+
SaveCache(_cachePath);
|
| 87 |
+
swCacheSave.Stop();
|
| 88 |
+
cacheSaveMs = swCacheSave.Elapsed.TotalMilliseconds;
|
| 89 |
+
log?.Invoke("Cache saved.");
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
_storeLock.EnterReadLock();
|
| 93 |
+
try { _checkpoints.AddRange(Store.Checkpoints); }
|
| 94 |
+
finally { _storeLock.ExitReadLock(); }
|
| 95 |
+
|
| 96 |
+
foreach (var d in drives)
|
| 97 |
+
{
|
| 98 |
+
var drive = d;
|
| 99 |
+
_watcherTasks.Add(Task.Run(() =>
|
| 100 |
+
{
|
| 101 |
+
try
|
| 102 |
+
{
|
| 103 |
+
using var watcher = UsnWatcher.New(drive, _queue);
|
| 104 |
+
watcher.RunShared(_checkpoints, _checkpointLock, _cts.Token);
|
| 105 |
+
}
|
| 106 |
+
catch { }
|
| 107 |
+
}, _cts.Token));
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
_eventTask = Task.Run(() =>
|
| 111 |
+
{
|
| 112 |
+
foreach (var ev in _queue.GetConsumingEnumerable(_cts.Token))
|
| 113 |
+
{
|
| 114 |
+
_storeLock.EnterWriteLock();
|
| 115 |
+
try
|
| 116 |
+
{
|
| 117 |
+
switch (ev)
|
| 118 |
+
{
|
| 119 |
+
case IndexEvent.Created c: Store.Insert(c.Record); break;
|
| 120 |
+
case IndexEvent.Deleted d: Store.Remove(d.FileRef); break;
|
| 121 |
+
case IndexEvent.Renamed r: Store.Rename(r.OldRef, r.NewRecord); break;
|
| 122 |
+
case IndexEvent.Moved m: Store.ApplyMove(m.FileRef, m.NewParentRef, m.Name, m.Kind); break;
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
finally { _storeLock.ExitWriteLock(); }
|
| 126 |
+
}
|
| 127 |
+
}, _cts.Token);
|
| 128 |
+
|
| 129 |
+
swTotal.Stop();
|
| 130 |
+
LastStartupMetrics = new StartupMetrics
|
| 131 |
+
{
|
| 132 |
+
CacheLoaded = loaded,
|
| 133 |
+
TotalFiles = Count(),
|
| 134 |
+
DriveDiscoveryMs = swDrives.Elapsed.TotalMilliseconds,
|
| 135 |
+
ScanMs = scanMs,
|
| 136 |
+
IndexMs = indexMs,
|
| 137 |
+
CacheLoadMs = swCacheLoad.Elapsed.TotalMilliseconds,
|
| 138 |
+
CacheSaveMs = cacheSaveMs,
|
| 139 |
+
TotalStartupMs = swTotal.Elapsed.TotalMilliseconds,
|
| 140 |
+
};
|
| 141 |
+
log?.Invoke($"Startup complete in {LastStartupMetrics.TotalStartupMs:F2} ms");
|
| 142 |
+
|
| 143 |
+
await Task.CompletedTask;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
public List<SearchResult> Search(string input, int limit = 50)
|
| 147 |
+
{
|
| 148 |
+
if (string.IsNullOrWhiteSpace(input)) return [];
|
| 149 |
+
var parsed = ParseQuery(input.Trim());
|
| 150 |
+
|
| 151 |
+
_storeLock.EnterReadLock();
|
| 152 |
+
try
|
| 153 |
+
{
|
| 154 |
+
if (parsed.ExtFilter is not null)
|
| 155 |
+
{
|
| 156 |
+
var dot = "." + parsed.ExtFilter;
|
| 157 |
+
return Store.Entries.Where(e => e.NameLower.EndsWith(dot, StringComparison.Ordinal))
|
| 158 |
+
.Where(e => parsed.Filter == Filter.All || (parsed.Filter == Filter.Dirs && e.IsDir) || (parsed.Filter == Filter.Files && !e.IsDir))
|
| 159 |
+
.Select(e => new SearchResult { FullPath = SearchEngine.BuildPath(e.FileRef, Store), Name = e.Name, Rank = 0, IsDir = e.IsDir })
|
| 160 |
+
.Where(r => ExcludedDirs.Count == 0 || !ExcludedDirs.Any(ex => r.FullPath.ToLowerInvariant().StartsWith(ex, StringComparison.Ordinal)))
|
| 161 |
+
.Take(limit)
|
| 162 |
+
.ToList();
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
var overshootLimit = Math.Max(limit * 4, 400);
|
| 166 |
+
return SearchEngine.Search(Store, parsed.Query, overshootLimit, CaseSensitive, ExcludedDirs)
|
| 167 |
+
.Where(r => parsed.Filter == Filter.All || (parsed.Filter == Filter.Dirs && r.IsDir) || (parsed.Filter == Filter.Files && !r.IsDir))
|
| 168 |
+
.Take(limit)
|
| 169 |
+
.ToList();
|
| 170 |
+
}
|
| 171 |
+
finally { _storeLock.ExitReadLock(); }
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
public int Count()
|
| 175 |
+
{
|
| 176 |
+
_storeLock.EnterReadLock();
|
| 177 |
+
try { return Store.Len(); }
|
| 178 |
+
finally { _storeLock.ExitReadLock(); }
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
private bool TryLoadCache(string path)
|
| 182 |
+
{
|
| 183 |
+
if (!File.Exists(path)) return false;
|
| 184 |
+
try
|
| 185 |
+
{
|
| 186 |
+
using var fs = File.OpenRead(path);
|
| 187 |
+
using var br = new BinaryReader(fs, System.Text.Encoding.UTF8, leaveOpen: false);
|
| 188 |
+
var magic = br.ReadUInt32();
|
| 189 |
+
if (magic != 0x4B534146) return false; // FASK
|
| 190 |
+
var version = br.ReadInt32();
|
| 191 |
+
if (version != 1) return false;
|
| 192 |
+
|
| 193 |
+
var driveRoot = br.ReadString();
|
| 194 |
+
var checkpointCount = br.ReadInt32();
|
| 195 |
+
var checkpoints = new List<JournalCheckpoint>(checkpointCount);
|
| 196 |
+
for (var i = 0; i < checkpointCount; i++)
|
| 197 |
+
{
|
| 198 |
+
checkpoints.Add(new JournalCheckpoint
|
| 199 |
+
{
|
| 200 |
+
NextUsn = br.ReadInt64(),
|
| 201 |
+
JournalId = br.ReadUInt64(),
|
| 202 |
+
DriveLetter = br.ReadChar()
|
| 203 |
+
});
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
var entryCount = br.ReadInt32();
|
| 207 |
+
var cacheData = new CacheData
|
| 208 |
+
{
|
| 209 |
+
DriveRoot = driveRoot,
|
| 210 |
+
Checkpoints = checkpoints,
|
| 211 |
+
Entries = new List<CachedEntry>(entryCount)
|
| 212 |
+
};
|
| 213 |
+
for (var i = 0; i < entryCount; i++)
|
| 214 |
+
{
|
| 215 |
+
cacheData.Entries.Add(new CachedEntry
|
| 216 |
+
{
|
| 217 |
+
FileRef = br.ReadUInt64(),
|
| 218 |
+
ParentRef = br.ReadUInt64(),
|
| 219 |
+
Kind = br.ReadByte() == 1 ? FileKind.Directory : FileKind.File,
|
| 220 |
+
Name = br.ReadString()
|
| 221 |
+
});
|
| 222 |
+
}
|
| 223 |
+
var restored = IndexStore.FromCache(cacheData);
|
| 224 |
+
|
| 225 |
+
_storeLock.EnterWriteLock();
|
| 226 |
+
try
|
| 227 |
+
{
|
| 228 |
+
Store.Entries.Clear();
|
| 229 |
+
Store.RefLookup.Clear();
|
| 230 |
+
Store.DriveRoot = restored.DriveRoot;
|
| 231 |
+
Store.Checkpoints = restored.Checkpoints;
|
| 232 |
+
Store.Entries.AddRange(restored.Entries);
|
| 233 |
+
Store.RefLookup.AddRange(restored.RefLookup);
|
| 234 |
+
}
|
| 235 |
+
finally { _storeLock.ExitWriteLock(); }
|
| 236 |
+
return true;
|
| 237 |
+
}
|
| 238 |
+
catch { return false; }
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
private void SaveCache(string path)
|
| 242 |
+
{
|
| 243 |
+
try
|
| 244 |
+
{
|
| 245 |
+
_storeLock.EnterReadLock();
|
| 246 |
+
using var fs = File.Create(path);
|
| 247 |
+
using var bw = new BinaryWriter(fs, System.Text.Encoding.UTF8, leaveOpen: false);
|
| 248 |
+
bw.Write(0x4B534146u); // FASK
|
| 249 |
+
bw.Write(1); // version
|
| 250 |
+
bw.Write(Store.DriveRoot ?? string.Empty);
|
| 251 |
+
bw.Write(Store.Checkpoints.Count);
|
| 252 |
+
for (var i = 0; i < Store.Checkpoints.Count; i++)
|
| 253 |
+
{
|
| 254 |
+
var c = Store.Checkpoints[i];
|
| 255 |
+
bw.Write(c.NextUsn);
|
| 256 |
+
bw.Write(c.JournalId);
|
| 257 |
+
bw.Write(c.DriveLetter);
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
bw.Write(Store.Entries.Count);
|
| 261 |
+
for (var i = 0; i < Store.Entries.Count; i++)
|
| 262 |
+
{
|
| 263 |
+
var e = Store.Entries[i];
|
| 264 |
+
bw.Write(e.FileRef);
|
| 265 |
+
bw.Write(e.ParentRef);
|
| 266 |
+
bw.Write((byte)(e.IsDir ? 1 : 0));
|
| 267 |
+
bw.Write(e.Name);
|
| 268 |
+
}
|
| 269 |
+
}
|
| 270 |
+
catch { }
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
public void Dispose()
|
| 274 |
+
{
|
| 275 |
+
_cts.Cancel();
|
| 276 |
+
_queue.CompleteAdding();
|
| 277 |
+
lock (_checkpointLock)
|
| 278 |
+
{
|
| 279 |
+
_storeLock.EnterWriteLock();
|
| 280 |
+
try { Store.Checkpoints = _checkpoints.ToList(); } finally { _storeLock.ExitWriteLock(); }
|
| 281 |
+
}
|
| 282 |
+
SaveCache(_cachePath);
|
| 283 |
+
_storeLock.Dispose();
|
| 284 |
+
_queue.Dispose();
|
| 285 |
+
_cts.Dispose();
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
private enum Filter { All, Dirs, Files }
|
| 289 |
+
private sealed class ParsedQuery { public required string Query; public Filter Filter; public string? ExtFilter; }
|
| 290 |
+
|
| 291 |
+
private static ParsedQuery ParseQuery(string input)
|
| 292 |
+
{
|
| 293 |
+
if (input.StartsWith("ext:", StringComparison.OrdinalIgnoreCase)) return new ParsedQuery { Query = string.Empty, Filter = Filter.Files, ExtFilter = input[4..].ToLowerInvariant() };
|
| 294 |
+
if (input.StartsWith("*.", StringComparison.Ordinal)) return new ParsedQuery { Query = string.Empty, Filter = Filter.All, ExtFilter = input[2..].ToLowerInvariant() };
|
| 295 |
+
if (input.StartsWith("folder:", StringComparison.OrdinalIgnoreCase)) return new ParsedQuery { Query = input[7..].Trim(), Filter = Filter.Dirs };
|
| 296 |
+
if (input.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) return new ParsedQuery { Query = input[5..].Trim(), Filter = Filter.Files };
|
| 297 |
+
if (input.StartsWith(':')) return new ParsedQuery { Query = input[1..], Filter = Filter.Dirs };
|
| 298 |
+
if (input.StartsWith('!')) return new ParsedQuery { Query = input[1..], Filter = Filter.Files };
|
| 299 |
+
return new ParsedQuery { Query = input, Filter = Filter.All };
|
| 300 |
+
}
|
| 301 |
+
}
|
FastSeek.Core/IndexStore.cs
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using FastSeek.Core.Mft;
|
| 2 |
+
|
| 3 |
+
namespace FastSeek.Core.Index;
|
| 4 |
+
|
| 5 |
+
public sealed class IndexEntry
|
| 6 |
+
{
|
| 7 |
+
public ulong FileRef;
|
| 8 |
+
public ulong ParentRef;
|
| 9 |
+
public required string Name;
|
| 10 |
+
public required string NameLower;
|
| 11 |
+
public byte Flags;
|
| 12 |
+
public bool IsDir => (Flags & 1) != 0;
|
| 13 |
+
public FileKind Kind => IsDir ? FileKind.Directory : FileKind.File;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
public sealed class CachedEntry
|
| 17 |
+
{
|
| 18 |
+
public ulong FileRef { get; set; }
|
| 19 |
+
public ulong ParentRef { get; set; }
|
| 20 |
+
public required string Name { get; set; }
|
| 21 |
+
public FileKind Kind { get; set; }
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
public sealed class CacheData
|
| 25 |
+
{
|
| 26 |
+
public List<CachedEntry> Entries { get; set; } = new();
|
| 27 |
+
public string DriveRoot { get; set; } = string.Empty;
|
| 28 |
+
public List<JournalCheckpoint> Checkpoints { get; set; } = new();
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
public sealed class IndexStore
|
| 32 |
+
{
|
| 33 |
+
public List<IndexEntry> Entries { get; } = new();
|
| 34 |
+
public List<(ulong fileRef, int idx)> RefLookup { get; } = new();
|
| 35 |
+
public string DriveRoot { get; set; } = string.Empty;
|
| 36 |
+
public List<JournalCheckpoint> Checkpoints { get; set; } = new();
|
| 37 |
+
|
| 38 |
+
public string Name(IndexEntry e) => e.Name;
|
| 39 |
+
public string NameLower(IndexEntry e) => e.NameLower;
|
| 40 |
+
|
| 41 |
+
public int? LookupIdx(ulong fileRef)
|
| 42 |
+
{
|
| 43 |
+
var lo = 0;
|
| 44 |
+
var hi = RefLookup.Count - 1;
|
| 45 |
+
while (lo <= hi)
|
| 46 |
+
{
|
| 47 |
+
var mid = lo + ((hi - lo) >> 1);
|
| 48 |
+
var cmp = RefLookup[mid].fileRef.CompareTo(fileRef);
|
| 49 |
+
if (cmp == 0) return RefLookup[mid].idx;
|
| 50 |
+
if (cmp < 0) lo = mid + 1; else hi = mid - 1;
|
| 51 |
+
}
|
| 52 |
+
return null;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
internal void PopulateFromScan(ScanResult scan, string driveRoot)
|
| 56 |
+
{
|
| 57 |
+
DriveRoot = driveRoot;
|
| 58 |
+
var span = System.Runtime.InteropServices.CollectionsMarshal.AsSpan(scan.NameData);
|
| 59 |
+
Entries.Capacity = Math.Max(Entries.Capacity, Entries.Count + scan.Records.Count);
|
| 60 |
+
foreach (var r in scan.Records)
|
| 61 |
+
{
|
| 62 |
+
var name = new string(span.Slice((int)r.NameOff, r.NameLen));
|
| 63 |
+
AddEntry(r.FileRef, r.ParentRef, name, r.IsDir ? FileKind.Directory : FileKind.File);
|
| 64 |
+
}
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
public void FinalizeStore()
|
| 68 |
+
{
|
| 69 |
+
Entries.Sort(static (a, b) => string.CompareOrdinal(a.NameLower, b.NameLower));
|
| 70 |
+
RebuildRefLookup();
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
public void Insert(FileRecord record) => AddSorted(record.FileRef, record.ParentRef, record.Name, record.Kind);
|
| 74 |
+
public void Remove(ulong fileRef) { Entries.RemoveAll(e => e.FileRef == fileRef); RebuildRefLookup(); }
|
| 75 |
+
public void Rename(ulong oldRef, FileRecord r) { Remove(oldRef); Insert(r); }
|
| 76 |
+
public void ApplyMove(ulong fileRef, ulong newParentRef, string name, FileKind kind) { Remove(fileRef); Insert(new FileRecord { FileRef = fileRef, ParentRef = newParentRef, Name = name, Kind = kind }); }
|
| 77 |
+
public int Len() => Entries.Count;
|
| 78 |
+
|
| 79 |
+
public CacheData ToCache() => new()
|
| 80 |
+
{
|
| 81 |
+
DriveRoot = DriveRoot,
|
| 82 |
+
Checkpoints = Checkpoints,
|
| 83 |
+
Entries = Entries.Select(e => new CachedEntry { FileRef = e.FileRef, ParentRef = e.ParentRef, Name = e.Name, Kind = e.Kind }).ToList()
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
public static IndexStore FromCache(CacheData cache)
|
| 87 |
+
{
|
| 88 |
+
var s = new IndexStore { DriveRoot = cache.DriveRoot, Checkpoints = cache.Checkpoints };
|
| 89 |
+
s.Entries.Capacity = cache.Entries.Count;
|
| 90 |
+
foreach (var c in cache.Entries) s.AddEntry(c.FileRef, c.ParentRef, c.Name, c.Kind);
|
| 91 |
+
s.RebuildRefLookup();
|
| 92 |
+
return s;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
private void AddSorted(ulong fileRef, ulong parentRef, string name, FileKind kind)
|
| 96 |
+
{
|
| 97 |
+
var lower = name.ToLowerInvariant();
|
| 98 |
+
var pos = Entries.BinarySearch(new IndexEntry { FileRef = fileRef, ParentRef = parentRef, Name = name, NameLower = lower, Flags = kind == FileKind.Directory ? (byte)1 : (byte)0 },
|
| 99 |
+
Comparer<IndexEntry>.Create(static (a, b) => string.CompareOrdinal(a.NameLower, b.NameLower)));
|
| 100 |
+
if (pos < 0) pos = ~pos;
|
| 101 |
+
Entries.Insert(pos, new IndexEntry { FileRef = fileRef, ParentRef = parentRef, Name = name, NameLower = lower, Flags = kind == FileKind.Directory ? (byte)1 : (byte)0 });
|
| 102 |
+
RebuildRefLookup();
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
private void AddEntry(ulong fileRef, ulong parentRef, string name, FileKind kind)
|
| 106 |
+
{
|
| 107 |
+
Entries.Add(new IndexEntry
|
| 108 |
+
{
|
| 109 |
+
FileRef = fileRef,
|
| 110 |
+
ParentRef = parentRef,
|
| 111 |
+
Name = name,
|
| 112 |
+
NameLower = name.ToLowerInvariant(),
|
| 113 |
+
Flags = kind == FileKind.Directory ? (byte)1 : (byte)0
|
| 114 |
+
});
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
private void RebuildRefLookup()
|
| 118 |
+
{
|
| 119 |
+
RefLookup.Clear();
|
| 120 |
+
RefLookup.Capacity = Math.Max(RefLookup.Capacity, Entries.Count);
|
| 121 |
+
for (var i = 0; i < Entries.Count; i++) RefLookup.Add((Entries[i].FileRef, i));
|
| 122 |
+
RefLookup.Sort(static (a, b) => a.fileRef.CompareTo(b.fileRef));
|
| 123 |
+
}
|
| 124 |
+
}
|
FastSeek.Core/MftReader.cs
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using System.Runtime.InteropServices;
|
| 2 |
+
using Microsoft.Win32.SafeHandles;
|
| 3 |
+
|
| 4 |
+
namespace FastSeek.Core.Mft;
|
| 5 |
+
|
| 6 |
+
internal sealed class MftReader : IDisposable
|
| 7 |
+
{
|
| 8 |
+
private const int FallbackBuf = 4 * 1024 * 1024;
|
| 9 |
+
private const int DirectBuf = 4 * 1024 * 1024;
|
| 10 |
+
|
| 11 |
+
private readonly SafeFileHandle _handle;
|
| 12 |
+
public NtfsDrive Drive { get; }
|
| 13 |
+
|
| 14 |
+
private MftReader(SafeFileHandle handle, NtfsDrive drive)
|
| 15 |
+
{
|
| 16 |
+
_handle = handle;
|
| 17 |
+
Drive = drive;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
public static MftReader Open(NtfsDrive drive)
|
| 21 |
+
{
|
| 22 |
+
var h = NativeMethods.CreateFile(drive.DevicePath, NativeMethods.GENERIC_READ, NativeMethods.FILE_SHARE_READ | NativeMethods.FILE_SHARE_WRITE | NativeMethods.FILE_SHARE_DELETE, IntPtr.Zero, NativeMethods.OPEN_EXISTING, NativeMethods.FILE_FLAG_BACKUP_SEMANTICS, IntPtr.Zero);
|
| 23 |
+
if (h.IsInvalid) throw new IOException($"Open drive failed: {drive.DevicePath}");
|
| 24 |
+
return new MftReader(h, drive);
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
public ScanResult? ScanDirect(TimeSpan? maxDuration = null, Action<string>? log = null)
|
| 28 |
+
{
|
| 29 |
+
var started = DateTime.UtcNow;
|
| 30 |
+
var recordSize = ReadMftRecordSize();
|
| 31 |
+
if (recordSize is null)
|
| 32 |
+
{
|
| 33 |
+
log?.Invoke("direct: failed to read NTFS record size");
|
| 34 |
+
return null;
|
| 35 |
+
}
|
| 36 |
+
log?.Invoke($"direct: record size={recordSize.Value} bytes");
|
| 37 |
+
|
| 38 |
+
var mftPath = Path.Combine(Drive.Root, "$MFT");
|
| 39 |
+
var mft = NativeMethods.CreateFile(mftPath, NativeMethods.GENERIC_READ, NativeMethods.FILE_SHARE_READ | NativeMethods.FILE_SHARE_WRITE | NativeMethods.FILE_SHARE_DELETE, IntPtr.Zero, NativeMethods.OPEN_EXISTING, NativeMethods.FILE_FLAG_BACKUP_SEMANTICS | NativeMethods.FILE_FLAG_SEQUENTIAL_SCAN, IntPtr.Zero);
|
| 40 |
+
if (mft.IsInvalid)
|
| 41 |
+
{
|
| 42 |
+
log?.Invoke("direct: could not open $MFT");
|
| 43 |
+
return null;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
try
|
| 47 |
+
{
|
| 48 |
+
var records = new List<CompactRecord>(3_000_000);
|
| 49 |
+
var names = new List<char>(40_000_000);
|
| 50 |
+
var buffer = new byte[DirectBuf];
|
| 51 |
+
var readChunk = new byte[DirectBuf];
|
| 52 |
+
ulong mftIndex = 0;
|
| 53 |
+
var leftover = 0;
|
| 54 |
+
long totalRead = 0;
|
| 55 |
+
var lastLog = DateTime.UtcNow;
|
| 56 |
+
|
| 57 |
+
while (NativeMethods.ReadFile(mft, readChunk, (uint)(buffer.Length - leftover), out var read, IntPtr.Zero) && read > 0)
|
| 58 |
+
{
|
| 59 |
+
if (maxDuration.HasValue && (DateTime.UtcNow - started) > maxDuration.Value)
|
| 60 |
+
{
|
| 61 |
+
log?.Invoke($"direct: timed out after {(DateTime.UtcNow - started).TotalSeconds:F1}s, fallback to ioctl");
|
| 62 |
+
return null;
|
| 63 |
+
}
|
| 64 |
+
totalRead += read;
|
| 65 |
+
Buffer.BlockCopy(readChunk, 0, buffer, leftover, (int)read);
|
| 66 |
+
var total = leftover + (int)read;
|
| 67 |
+
var offset = 0;
|
| 68 |
+
while (offset + recordSize.Value <= total)
|
| 69 |
+
{
|
| 70 |
+
var slice = new Span<byte>(buffer, offset, recordSize.Value);
|
| 71 |
+
if (ApplyFixup(slice)) ParseFileRecord(slice, mftIndex, records, names);
|
| 72 |
+
mftIndex++;
|
| 73 |
+
offset += recordSize.Value;
|
| 74 |
+
}
|
| 75 |
+
offset = total - (total % recordSize.Value);
|
| 76 |
+
leftover = total - offset;
|
| 77 |
+
if (leftover > 0) Buffer.BlockCopy(buffer, offset, buffer, 0, leftover);
|
| 78 |
+
|
| 79 |
+
var now = DateTime.UtcNow;
|
| 80 |
+
if ((now - lastLog).TotalSeconds >= 2.0)
|
| 81 |
+
{
|
| 82 |
+
log?.Invoke($"direct: read={totalRead / (1024 * 1024)}MB parsed={records.Count:N0}");
|
| 83 |
+
lastLog = now;
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
log?.Invoke($"direct: completed read={totalRead / (1024 * 1024)}MB parsed={records.Count:N0}");
|
| 88 |
+
return new ScanResult { Records = records, NameData = names };
|
| 89 |
+
}
|
| 90 |
+
finally { mft.Dispose(); }
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
public ScanResult Scan(Action<string>? log = null)
|
| 94 |
+
{
|
| 95 |
+
var records = new List<CompactRecord>(3_000_000);
|
| 96 |
+
var names = new List<char>(40_000_000);
|
| 97 |
+
var enumData = new MftEnumDataV0 { StartFileReferenceNumber = 0, LowUsn = 0, HighUsn = long.MaxValue };
|
| 98 |
+
var outBuf = new byte[FallbackBuf];
|
| 99 |
+
var loops = 0;
|
| 100 |
+
var lastLog = DateTime.UtcNow;
|
| 101 |
+
|
| 102 |
+
while (true)
|
| 103 |
+
{
|
| 104 |
+
var pIn = Marshal.AllocHGlobal(Marshal.SizeOf<MftEnumDataV0>());
|
| 105 |
+
try
|
| 106 |
+
{
|
| 107 |
+
Marshal.StructureToPtr(enumData, pIn, false);
|
| 108 |
+
if (!NativeMethods.DeviceIoControl(_handle, NativeMethods.FSCTL_ENUM_USN_DATA, pIn, (uint)Marshal.SizeOf<MftEnumDataV0>(), outBuf, (uint)outBuf.Length, out var returned, IntPtr.Zero))
|
| 109 |
+
{
|
| 110 |
+
var err = Marshal.GetLastWin32Error();
|
| 111 |
+
log?.Invoke($"ioctl: DeviceIoControl stop err=0x{err:X8}");
|
| 112 |
+
break;
|
| 113 |
+
}
|
| 114 |
+
if (returned <= 8)
|
| 115 |
+
{
|
| 116 |
+
log?.Invoke("ioctl: no more records");
|
| 117 |
+
break;
|
| 118 |
+
}
|
| 119 |
+
loops++;
|
| 120 |
+
|
| 121 |
+
enumData.StartFileReferenceNumber = BitConverter.ToUInt64(outBuf, 0);
|
| 122 |
+
var offset = 8;
|
| 123 |
+
while (offset + Marshal.SizeOf<UsnRecordV2>() <= returned)
|
| 124 |
+
{
|
| 125 |
+
var rec = MemoryMarshal.Read<UsnRecordV2>(new ReadOnlySpan<byte>(outBuf, offset, Marshal.SizeOf<UsnRecordV2>()));
|
| 126 |
+
if (rec.RecordLength == 0 || offset + rec.RecordLength > returned) break;
|
| 127 |
+
var nameLen = (ushort)(rec.FileNameLength / 2);
|
| 128 |
+
var nameStart = offset + rec.FileNameOffset;
|
| 129 |
+
var chars = MemoryMarshal.Cast<byte, char>(new ReadOnlySpan<byte>(outBuf, nameStart, nameLen * 2));
|
| 130 |
+
var arenaOff = (uint)names.Count;
|
| 131 |
+
AppendChars(names, chars);
|
| 132 |
+
records.Add(new CompactRecord { FileRef = rec.FileReferenceNumber, ParentRef = rec.ParentFileReferenceNumber, NameOff = arenaOff, NameLen = nameLen, IsDir = (rec.FileAttributes & 0x10) != 0 });
|
| 133 |
+
offset += (int)rec.RecordLength;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
var now = DateTime.UtcNow;
|
| 137 |
+
if ((now - lastLog).TotalSeconds >= 2.0)
|
| 138 |
+
{
|
| 139 |
+
log?.Invoke($"ioctl: loops={loops:N0} parsed={records.Count:N0} nextRef={enumData.StartFileReferenceNumber}");
|
| 140 |
+
lastLog = now;
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
finally { Marshal.FreeHGlobal(pIn); }
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
log?.Invoke($"ioctl: completed loops={loops:N0} parsed={records.Count:N0}");
|
| 147 |
+
return new ScanResult { Records = records, NameData = names };
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
private int? ReadMftRecordSize()
|
| 151 |
+
{
|
| 152 |
+
if (!NativeMethods.SetFilePointerEx(_handle, 0, out _, NativeMethods.FILE_BEGIN)) return null;
|
| 153 |
+
var boot = new byte[512];
|
| 154 |
+
if (!NativeMethods.ReadFile(_handle, boot, 512, out var read, IntPtr.Zero) || read < 512) return null;
|
| 155 |
+
if (boot[3] != (byte)'N' || boot[4] != (byte)'T' || boot[5] != (byte)'F' || boot[6] != (byte)'S') return null;
|
| 156 |
+
var bps = BitConverter.ToUInt16(boot, 0x0B);
|
| 157 |
+
var spc = boot[0x0D];
|
| 158 |
+
var cluster = bps * spc;
|
| 159 |
+
var raw = unchecked((sbyte)boot[0x40]);
|
| 160 |
+
return raw > 0 ? raw * cluster : 1 << -raw;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
private static bool ApplyFixup(Span<byte> rec)
|
| 164 |
+
{
|
| 165 |
+
if (rec.Length < 48 || rec[0] != (byte)'F' || rec[1] != (byte)'I' || rec[2] != (byte)'L' || rec[3] != (byte)'E') return false;
|
| 166 |
+
var off = BitConverter.ToUInt16(rec.Slice(4, 2));
|
| 167 |
+
var cnt = BitConverter.ToUInt16(rec.Slice(6, 2));
|
| 168 |
+
if (cnt < 2 || off + cnt * 2 > rec.Length) return false;
|
| 169 |
+
var c0 = rec[off]; var c1 = rec[off + 1];
|
| 170 |
+
for (var i = 1; i < cnt; i++)
|
| 171 |
+
{
|
| 172 |
+
var end = i * 512 - 2;
|
| 173 |
+
if (end + 1 >= rec.Length) break;
|
| 174 |
+
if (rec[end] != c0 || rec[end + 1] != c1) return false;
|
| 175 |
+
rec[end] = rec[off + i * 2];
|
| 176 |
+
rec[end + 1] = rec[off + i * 2 + 1];
|
| 177 |
+
}
|
| 178 |
+
return true;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
private static void ParseFileRecord(ReadOnlySpan<byte> rec, ulong mftIndex, List<CompactRecord> outRecords, List<char> outNames)
|
| 182 |
+
{
|
| 183 |
+
var flags = BitConverter.ToUInt16(rec.Slice(0x16, 2));
|
| 184 |
+
if ((flags & 0x01) == 0) return;
|
| 185 |
+
var isDir = (flags & 0x02) != 0;
|
| 186 |
+
var seq = BitConverter.ToUInt16(rec.Slice(0x10, 2));
|
| 187 |
+
var fileRef = mftIndex | ((ulong)seq << 48);
|
| 188 |
+
var firstAttr = BitConverter.ToUInt16(rec.Slice(0x14, 2));
|
| 189 |
+
var aoff = (int)firstAttr;
|
| 190 |
+
byte bestNs = 255;
|
| 191 |
+
(int pos, int len, ulong parent)? best = null;
|
| 192 |
+
|
| 193 |
+
while (aoff + 8 <= rec.Length)
|
| 194 |
+
{
|
| 195 |
+
var atype = BitConverter.ToUInt32(rec.Slice(aoff, 4));
|
| 196 |
+
if (atype == 0xFFFFFFFF) break;
|
| 197 |
+
var alen = (int)BitConverter.ToUInt32(rec.Slice(aoff + 4, 4));
|
| 198 |
+
if (alen == 0 || aoff + alen > rec.Length) break;
|
| 199 |
+
if (atype == 0x30 && rec[aoff + 8] == 0)
|
| 200 |
+
{
|
| 201 |
+
var voff = (int)BitConverter.ToUInt16(rec.Slice(aoff + 20, 2));
|
| 202 |
+
var vs = aoff + voff;
|
| 203 |
+
if (vs + 66 <= rec.Length)
|
| 204 |
+
{
|
| 205 |
+
var parent = BitConverter.ToUInt64(rec.Slice(vs, 8));
|
| 206 |
+
var nlen = rec[vs + 64];
|
| 207 |
+
var ns = rec[vs + 65];
|
| 208 |
+
if (vs + 66 + nlen * 2 <= rec.Length && ns != 2)
|
| 209 |
+
{
|
| 210 |
+
var pr = ns == 1 ? 0 : ns == 3 ? 1 : ns == 0 ? 2 : 3;
|
| 211 |
+
if (pr < bestNs)
|
| 212 |
+
{
|
| 213 |
+
bestNs = (byte)pr;
|
| 214 |
+
best = (vs + 66, nlen, parent);
|
| 215 |
+
if (pr == 0) break;
|
| 216 |
+
}
|
| 217 |
+
}
|
| 218 |
+
}
|
| 219 |
+
}
|
| 220 |
+
aoff += alen;
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
if (best is { } b)
|
| 224 |
+
{
|
| 225 |
+
var arenaOff = (uint)outNames.Count;
|
| 226 |
+
var chars = MemoryMarshal.Cast<byte, char>(rec.Slice(b.pos, b.len * 2));
|
| 227 |
+
AppendChars(outNames, chars);
|
| 228 |
+
outRecords.Add(new CompactRecord { FileRef = fileRef, ParentRef = b.parent, NameOff = arenaOff, NameLen = (ushort)b.len, IsDir = isDir });
|
| 229 |
+
}
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
private static void AppendChars(List<char> target, ReadOnlySpan<char> source)
|
| 233 |
+
{
|
| 234 |
+
target.Capacity = Math.Max(target.Capacity, target.Count + source.Length);
|
| 235 |
+
for (var i = 0; i < source.Length; i++) target.Add(source[i]);
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
public void Dispose() => _handle.Dispose();
|
| 239 |
+
}
|
FastSeek.Core/NativeMethods.cs
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using System.Runtime.InteropServices;
|
| 2 |
+
using Microsoft.Win32.SafeHandles;
|
| 3 |
+
|
| 4 |
+
namespace FastSeek.Core.Mft;
|
| 5 |
+
|
| 6 |
+
internal static class NativeMethods
|
| 7 |
+
{
|
| 8 |
+
internal const uint FILE_SHARE_READ = 0x00000001;
|
| 9 |
+
internal const uint FILE_SHARE_WRITE = 0x00000002;
|
| 10 |
+
internal const uint FILE_SHARE_DELETE = 0x00000004;
|
| 11 |
+
internal const uint OPEN_EXISTING = 3;
|
| 12 |
+
internal const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;
|
| 13 |
+
internal const uint FILE_FLAG_SEQUENTIAL_SCAN = 0x08000000;
|
| 14 |
+
internal const uint GENERIC_READ = 0x80000000;
|
| 15 |
+
internal const uint FILE_BEGIN = 0;
|
| 16 |
+
|
| 17 |
+
internal const uint FSCTL_ENUM_USN_DATA = 0x000900b3;
|
| 18 |
+
internal const uint FSCTL_QUERY_USN_JOURNAL = 0x000900f4;
|
| 19 |
+
internal const uint FSCTL_READ_USN_JOURNAL = 0x000900bb;
|
| 20 |
+
|
| 21 |
+
internal const uint USN_REASON_FILE_CREATE = 0x00000100;
|
| 22 |
+
internal const uint USN_REASON_FILE_DELETE = 0x00000200;
|
| 23 |
+
internal const uint USN_REASON_RENAME_OLD_NAME = 0x00001000;
|
| 24 |
+
internal const uint USN_REASON_RENAME_NEW_NAME = 0x00002000;
|
| 25 |
+
|
| 26 |
+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
| 27 |
+
internal static extern uint GetLogicalDriveStrings(uint nBufferLength, [Out] char[] lpBuffer);
|
| 28 |
+
|
| 29 |
+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
| 30 |
+
[return: MarshalAs(UnmanagedType.Bool)]
|
| 31 |
+
internal static extern bool GetVolumeInformation(
|
| 32 |
+
string lpRootPathName,
|
| 33 |
+
System.Text.StringBuilder? lpVolumeNameBuffer,
|
| 34 |
+
uint nVolumeNameSize,
|
| 35 |
+
out uint lpVolumeSerialNumber,
|
| 36 |
+
out uint lpMaximumComponentLength,
|
| 37 |
+
out uint lpFileSystemFlags,
|
| 38 |
+
[Out] char[] lpFileSystemNameBuffer,
|
| 39 |
+
uint nFileSystemNameSize);
|
| 40 |
+
|
| 41 |
+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
| 42 |
+
internal static extern SafeFileHandle CreateFile(
|
| 43 |
+
string lpFileName,
|
| 44 |
+
uint dwDesiredAccess,
|
| 45 |
+
uint dwShareMode,
|
| 46 |
+
IntPtr lpSecurityAttributes,
|
| 47 |
+
uint dwCreationDisposition,
|
| 48 |
+
uint dwFlagsAndAttributes,
|
| 49 |
+
IntPtr hTemplateFile);
|
| 50 |
+
|
| 51 |
+
[DllImport("kernel32.dll", SetLastError = true)]
|
| 52 |
+
[return: MarshalAs(UnmanagedType.Bool)]
|
| 53 |
+
internal static extern bool ReadFile(
|
| 54 |
+
SafeFileHandle hFile,
|
| 55 |
+
[Out] byte[] lpBuffer,
|
| 56 |
+
uint nNumberOfBytesToRead,
|
| 57 |
+
out uint lpNumberOfBytesRead,
|
| 58 |
+
IntPtr lpOverlapped);
|
| 59 |
+
|
| 60 |
+
[DllImport("kernel32.dll", SetLastError = true)]
|
| 61 |
+
[return: MarshalAs(UnmanagedType.Bool)]
|
| 62 |
+
internal static extern bool SetFilePointerEx(SafeFileHandle hFile, long liDistanceToMove, out long lpNewFilePointer, uint dwMoveMethod);
|
| 63 |
+
|
| 64 |
+
[DllImport("kernel32.dll", SetLastError = true)]
|
| 65 |
+
[return: MarshalAs(UnmanagedType.Bool)]
|
| 66 |
+
internal static extern bool DeviceIoControl(
|
| 67 |
+
SafeFileHandle hDevice,
|
| 68 |
+
uint dwIoControlCode,
|
| 69 |
+
IntPtr lpInBuffer,
|
| 70 |
+
uint nInBufferSize,
|
| 71 |
+
[Out] byte[] lpOutBuffer,
|
| 72 |
+
uint nOutBufferSize,
|
| 73 |
+
out uint lpBytesReturned,
|
| 74 |
+
IntPtr lpOverlapped);
|
| 75 |
+
|
| 76 |
+
[DllImport("kernel32.dll", SetLastError = true)]
|
| 77 |
+
[return: MarshalAs(UnmanagedType.Bool)]
|
| 78 |
+
internal static extern bool DeviceIoControl(
|
| 79 |
+
SafeFileHandle hDevice,
|
| 80 |
+
uint dwIoControlCode,
|
| 81 |
+
IntPtr lpInBuffer,
|
| 82 |
+
uint nInBufferSize,
|
| 83 |
+
IntPtr lpOutBuffer,
|
| 84 |
+
uint nOutBufferSize,
|
| 85 |
+
out uint lpBytesReturned,
|
| 86 |
+
IntPtr lpOverlapped);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
[StructLayout(LayoutKind.Sequential)]
|
| 90 |
+
internal struct MftEnumDataV0
|
| 91 |
+
{
|
| 92 |
+
public ulong StartFileReferenceNumber;
|
| 93 |
+
public long LowUsn;
|
| 94 |
+
public long HighUsn;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
[StructLayout(LayoutKind.Sequential)]
|
| 98 |
+
internal struct UsnJournalDataV0
|
| 99 |
+
{
|
| 100 |
+
public ulong UsnJournalID;
|
| 101 |
+
public long FirstUsn;
|
| 102 |
+
public long NextUsn;
|
| 103 |
+
public long LowestValidUsn;
|
| 104 |
+
public long MaxUsn;
|
| 105 |
+
public ulong MaximumSize;
|
| 106 |
+
public ulong AllocationDelta;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
[StructLayout(LayoutKind.Sequential)]
|
| 110 |
+
internal struct ReadUsnJournalDataV0
|
| 111 |
+
{
|
| 112 |
+
public long StartUsn;
|
| 113 |
+
public uint ReasonMask;
|
| 114 |
+
public uint ReturnOnlyOnClose;
|
| 115 |
+
public ulong Timeout;
|
| 116 |
+
public ulong BytesToWaitFor;
|
| 117 |
+
public ulong UsnJournalID;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
[StructLayout(LayoutKind.Sequential)]
|
| 121 |
+
internal struct UsnRecordV2
|
| 122 |
+
{
|
| 123 |
+
public uint RecordLength;
|
| 124 |
+
public ushort MajorVersion;
|
| 125 |
+
public ushort MinorVersion;
|
| 126 |
+
public ulong FileReferenceNumber;
|
| 127 |
+
public ulong ParentFileReferenceNumber;
|
| 128 |
+
public long Usn;
|
| 129 |
+
public long TimeStamp;
|
| 130 |
+
public uint Reason;
|
| 131 |
+
public uint SourceInfo;
|
| 132 |
+
public uint SecurityId;
|
| 133 |
+
public uint FileAttributes;
|
| 134 |
+
public ushort FileNameLength;
|
| 135 |
+
public ushort FileNameOffset;
|
| 136 |
+
}
|
FastSeek.Core/SearchEngine.cs
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using FastSeek.Core.Index;
|
| 2 |
+
|
| 3 |
+
namespace FastSeek.Core.Search;
|
| 4 |
+
|
| 5 |
+
public sealed class SearchResult
|
| 6 |
+
{
|
| 7 |
+
public required string FullPath { get; init; }
|
| 8 |
+
public required string Name { get; init; }
|
| 9 |
+
public byte Rank { get; init; }
|
| 10 |
+
public bool IsDir { get; init; }
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
public static class SearchEngine
|
| 14 |
+
{
|
| 15 |
+
private static readonly HashSet<string> AppExtensions = new(StringComparer.OrdinalIgnoreCase) { "exe", "lnk", "msi", "appx", "msix" };
|
| 16 |
+
private static readonly string[] AppPathMarkers = ["\\program files\\", "\\program files (x86)\\", "\\start menu\\", "\\desktop\\", "\\appdata\\"];
|
| 17 |
+
|
| 18 |
+
public static List<SearchResult> Search(IndexStore store, string query, int limit, bool caseSensitive, IReadOnlyList<string> excludedDirs)
|
| 19 |
+
{
|
| 20 |
+
if (string.IsNullOrEmpty(query)) return [];
|
| 21 |
+
var q = caseSensitive ? query : query.ToLowerInvariant();
|
| 22 |
+
|
| 23 |
+
var bag = new System.Collections.Concurrent.ConcurrentBag<(int idx, byte rank)>();
|
| 24 |
+
Parallel.For(0, store.Entries.Count, i =>
|
| 25 |
+
{
|
| 26 |
+
var e = store.Entries[i];
|
| 27 |
+
var nameCmp = caseSensitive ? e.Name : e.NameLower;
|
| 28 |
+
byte rank;
|
| 29 |
+
if (nameCmp == q) rank = 1;
|
| 30 |
+
else if (nameCmp.StartsWith(q, StringComparison.Ordinal)) rank = 2;
|
| 31 |
+
else if (nameCmp.Contains(q, StringComparison.Ordinal)) rank = 3;
|
| 32 |
+
else return;
|
| 33 |
+
bag.Add((i, rank));
|
| 34 |
+
});
|
| 35 |
+
|
| 36 |
+
var candidates = bag.ToList();
|
| 37 |
+
candidates.Sort(static (a, b) => a.rank.CompareTo(b.rank));
|
| 38 |
+
if (candidates.Count > Math.Max(limit * 5, 1000)) candidates.RemoveRange(Math.Max(limit * 5, 1000), candidates.Count - Math.Max(limit * 5, 1000));
|
| 39 |
+
|
| 40 |
+
var results = new List<SearchResult>(limit);
|
| 41 |
+
foreach (var (idx, baseRank) in candidates)
|
| 42 |
+
{
|
| 43 |
+
var entry = store.Entries[idx];
|
| 44 |
+
var path = BuildPath(entry.FileRef, store);
|
| 45 |
+
if (excludedDirs.Count > 0)
|
| 46 |
+
{
|
| 47 |
+
var lowerPath = path.ToLowerInvariant();
|
| 48 |
+
var excluded = false;
|
| 49 |
+
for (var i = 0; i < excludedDirs.Count; i++)
|
| 50 |
+
{
|
| 51 |
+
if (lowerPath.StartsWith(excludedDirs[i], StringComparison.Ordinal)) { excluded = true; break; }
|
| 52 |
+
}
|
| 53 |
+
if (excluded) continue;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
var rank = baseRank;
|
| 57 |
+
if (baseRank <= 2)
|
| 58 |
+
{
|
| 59 |
+
var extIdx = entry.NameLower.LastIndexOf('.');
|
| 60 |
+
if (extIdx >= 0 && extIdx + 1 < entry.NameLower.Length)
|
| 61 |
+
{
|
| 62 |
+
var ext = entry.NameLower[(extIdx + 1)..];
|
| 63 |
+
if (AppExtensions.Contains(ext))
|
| 64 |
+
{
|
| 65 |
+
var pathLower = path.ToLowerInvariant();
|
| 66 |
+
for (var i = 0; i < AppPathMarkers.Length; i++)
|
| 67 |
+
{
|
| 68 |
+
if (pathLower.Contains(AppPathMarkers[i], StringComparison.Ordinal)) { rank = 0; break; }
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
results.Add(new SearchResult { FullPath = path, Name = entry.Name, Rank = rank, IsDir = entry.IsDir });
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
results.Sort(static (a, b) => a.Rank.CompareTo(b.Rank));
|
| 78 |
+
if (results.Count > limit) results.RemoveRange(limit, results.Count - limit);
|
| 79 |
+
return results;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
public static string BuildPath(ulong fileRef, IndexStore store)
|
| 83 |
+
{
|
| 84 |
+
var components = new List<string>(16);
|
| 85 |
+
var current = fileRef;
|
| 86 |
+
for (var i = 0; i < 64; i++)
|
| 87 |
+
{
|
| 88 |
+
var idx = store.LookupIdx(current);
|
| 89 |
+
if (idx is null) break;
|
| 90 |
+
var e = store.Entries[idx.Value];
|
| 91 |
+
components.Add(e.Name);
|
| 92 |
+
if (e.ParentRef == current) break;
|
| 93 |
+
current = e.ParentRef;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
components.Reverse();
|
| 97 |
+
if (components.Count == 0) return store.DriveRoot;
|
| 98 |
+
|
| 99 |
+
var path = store.DriveRoot;
|
| 100 |
+
for (var i = 0; i < components.Count; i++) path = Path.Combine(path, components[i]);
|
| 101 |
+
return path;
|
| 102 |
+
}
|
| 103 |
+
}
|
FastSeek.Core/Types.cs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using System.Runtime.InteropServices;
|
| 2 |
+
|
| 3 |
+
namespace FastSeek.Core.Mft;
|
| 4 |
+
|
| 5 |
+
public enum FileKind
|
| 6 |
+
{
|
| 7 |
+
File,
|
| 8 |
+
Directory
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
public sealed class FileRecord
|
| 12 |
+
{
|
| 13 |
+
public ulong FileRef { get; init; }
|
| 14 |
+
public ulong ParentRef { get; init; }
|
| 15 |
+
public required string Name { get; init; }
|
| 16 |
+
public FileKind Kind { get; init; }
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
public sealed class NtfsDrive
|
| 20 |
+
{
|
| 21 |
+
public required char Letter { get; init; }
|
| 22 |
+
public required string Root { get; init; }
|
| 23 |
+
public required string DevicePath { get; init; }
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
public abstract record IndexEvent
|
| 27 |
+
{
|
| 28 |
+
public sealed record Created(FileRecord Record) : IndexEvent;
|
| 29 |
+
public sealed record Deleted(ulong FileRef) : IndexEvent;
|
| 30 |
+
public sealed record Renamed(ulong OldRef, FileRecord NewRecord) : IndexEvent;
|
| 31 |
+
public sealed record Moved(ulong FileRef, ulong NewParentRef, string Name, FileKind Kind) : IndexEvent;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
public sealed class JournalCheckpoint
|
| 35 |
+
{
|
| 36 |
+
public long NextUsn { get; set; }
|
| 37 |
+
public ulong JournalId { get; set; }
|
| 38 |
+
public char DriveLetter { get; set; }
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
[StructLayout(LayoutKind.Sequential)]
|
| 42 |
+
internal struct CompactRecord
|
| 43 |
+
{
|
| 44 |
+
public ulong FileRef;
|
| 45 |
+
public ulong ParentRef;
|
| 46 |
+
public uint NameOff;
|
| 47 |
+
public ushort NameLen;
|
| 48 |
+
[MarshalAs(UnmanagedType.U1)]
|
| 49 |
+
public bool IsDir;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
internal sealed class ScanResult
|
| 53 |
+
{
|
| 54 |
+
public required List<CompactRecord> Records { get; init; }
|
| 55 |
+
public required List<char> NameData { get; init; }
|
| 56 |
+
}
|
FastSeek.Core/UsnWatcher.cs
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using System.Collections.Concurrent;
|
| 2 |
+
using System.Runtime.InteropServices;
|
| 3 |
+
using Microsoft.Win32.SafeHandles;
|
| 4 |
+
|
| 5 |
+
namespace FastSeek.Core.Mft;
|
| 6 |
+
|
| 7 |
+
public sealed class UsnWatcher : IDisposable
|
| 8 |
+
{
|
| 9 |
+
private const int BufferSize = 64 * 1024;
|
| 10 |
+
private readonly SafeFileHandle _handle;
|
| 11 |
+
private readonly NtfsDrive _drive;
|
| 12 |
+
private readonly BlockingCollection<IndexEvent> _sender;
|
| 13 |
+
|
| 14 |
+
public long NextUsn { get; private set; }
|
| 15 |
+
public ulong JournalId { get; private set; }
|
| 16 |
+
|
| 17 |
+
private UsnWatcher(SafeFileHandle handle, NtfsDrive drive, BlockingCollection<IndexEvent> sender, long nextUsn, ulong journalId)
|
| 18 |
+
{
|
| 19 |
+
_handle = handle;
|
| 20 |
+
_drive = drive;
|
| 21 |
+
_sender = sender;
|
| 22 |
+
NextUsn = nextUsn;
|
| 23 |
+
JournalId = journalId;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
public static UsnWatcher New(NtfsDrive drive, BlockingCollection<IndexEvent> sender) => NewFrom(drive, sender, null);
|
| 27 |
+
|
| 28 |
+
public static UsnWatcher NewFrom(NtfsDrive drive, BlockingCollection<IndexEvent> sender, JournalCheckpoint? checkpoint)
|
| 29 |
+
{
|
| 30 |
+
var h = NativeMethods.CreateFile(drive.DevicePath, 0, NativeMethods.FILE_SHARE_READ | NativeMethods.FILE_SHARE_WRITE | NativeMethods.FILE_SHARE_DELETE, IntPtr.Zero, NativeMethods.OPEN_EXISTING, NativeMethods.FILE_FLAG_BACKUP_SEMANTICS, IntPtr.Zero);
|
| 31 |
+
if (h.IsInvalid) throw new IOException("USN open failed");
|
| 32 |
+
|
| 33 |
+
var outPtr = Marshal.AllocHGlobal(Marshal.SizeOf<UsnJournalDataV0>());
|
| 34 |
+
try
|
| 35 |
+
{
|
| 36 |
+
if (!NativeMethods.DeviceIoControl(h, NativeMethods.FSCTL_QUERY_USN_JOURNAL, IntPtr.Zero, 0, outPtr, (uint)Marshal.SizeOf<UsnJournalDataV0>(), out _, IntPtr.Zero))
|
| 37 |
+
throw new IOException("USN query failed");
|
| 38 |
+
|
| 39 |
+
var data = Marshal.PtrToStructure<UsnJournalDataV0>(outPtr);
|
| 40 |
+
long nextUsn;
|
| 41 |
+
if (checkpoint is not null)
|
| 42 |
+
{
|
| 43 |
+
if (checkpoint.JournalId != data.UsnJournalID || checkpoint.NextUsn < data.FirstUsn || checkpoint.NextUsn > data.NextUsn)
|
| 44 |
+
throw new IOException("Journal mismatch - rescan needed");
|
| 45 |
+
nextUsn = checkpoint.NextUsn;
|
| 46 |
+
}
|
| 47 |
+
else nextUsn = data.NextUsn;
|
| 48 |
+
|
| 49 |
+
return new UsnWatcher(h, drive, sender, nextUsn, data.UsnJournalID);
|
| 50 |
+
}
|
| 51 |
+
finally { Marshal.FreeHGlobal(outPtr); }
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
public JournalCheckpoint Checkpoint() => new() { NextUsn = NextUsn, JournalId = JournalId, DriveLetter = _drive.Letter };
|
| 55 |
+
|
| 56 |
+
public void RunShared(List<JournalCheckpoint> checkpoints, object checkpointLock, CancellationToken token)
|
| 57 |
+
{
|
| 58 |
+
var buffer = new byte[BufferSize];
|
| 59 |
+
while (!token.IsCancellationRequested)
|
| 60 |
+
{
|
| 61 |
+
Thread.Sleep(500);
|
| 62 |
+
Poll(buffer);
|
| 63 |
+
lock (checkpointLock)
|
| 64 |
+
{
|
| 65 |
+
checkpoints.RemoveAll(c => c.DriveLetter == _drive.Letter);
|
| 66 |
+
checkpoints.Add(Checkpoint());
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
public int Drain()
|
| 72 |
+
{
|
| 73 |
+
var buffer = new byte[BufferSize];
|
| 74 |
+
var count = 0;
|
| 75 |
+
while (true)
|
| 76 |
+
{
|
| 77 |
+
var before = NextUsn;
|
| 78 |
+
Poll(buffer);
|
| 79 |
+
if (NextUsn == before) break;
|
| 80 |
+
count++;
|
| 81 |
+
}
|
| 82 |
+
return count;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
private void Poll(byte[] buffer)
|
| 86 |
+
{
|
| 87 |
+
var read = new ReadUsnJournalDataV0
|
| 88 |
+
{
|
| 89 |
+
StartUsn = NextUsn,
|
| 90 |
+
ReasonMask = NativeMethods.USN_REASON_FILE_CREATE | NativeMethods.USN_REASON_FILE_DELETE | NativeMethods.USN_REASON_RENAME_NEW_NAME | NativeMethods.USN_REASON_RENAME_OLD_NAME,
|
| 91 |
+
ReturnOnlyOnClose = 0,
|
| 92 |
+
Timeout = 0,
|
| 93 |
+
BytesToWaitFor = 0,
|
| 94 |
+
UsnJournalID = JournalId,
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
+
var inPtr = Marshal.AllocHGlobal(Marshal.SizeOf<ReadUsnJournalDataV0>());
|
| 98 |
+
try
|
| 99 |
+
{
|
| 100 |
+
Marshal.StructureToPtr(read, inPtr, false);
|
| 101 |
+
if (!NativeMethods.DeviceIoControl(_handle, NativeMethods.FSCTL_READ_USN_JOURNAL, inPtr, (uint)Marshal.SizeOf<ReadUsnJournalDataV0>(), buffer, (uint)buffer.Length, out var returned, IntPtr.Zero) || returned <= 8) return;
|
| 102 |
+
NextUsn = BitConverter.ToInt64(buffer, 0);
|
| 103 |
+
|
| 104 |
+
var offset = 8;
|
| 105 |
+
while (offset + Marshal.SizeOf<UsnRecordV2>() <= returned)
|
| 106 |
+
{
|
| 107 |
+
var rec = MemoryMarshal.Read<UsnRecordV2>(new ReadOnlySpan<byte>(buffer, offset, Marshal.SizeOf<UsnRecordV2>()));
|
| 108 |
+
if (rec.RecordLength == 0) break;
|
| 109 |
+
ProcessRecord(rec, buffer, offset);
|
| 110 |
+
offset += (int)rec.RecordLength;
|
| 111 |
+
}
|
| 112 |
+
}
|
| 113 |
+
finally { Marshal.FreeHGlobal(inPtr); }
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
private void ProcessRecord(UsnRecordV2 record, byte[] buffer, int offset)
|
| 117 |
+
{
|
| 118 |
+
var nameLen = record.FileNameLength / 2;
|
| 119 |
+
var nameStart = offset + record.FileNameOffset;
|
| 120 |
+
var name = new string(MemoryMarshal.Cast<byte, char>(new ReadOnlySpan<byte>(buffer, nameStart, nameLen * 2)));
|
| 121 |
+
var isDir = (record.FileAttributes & 0x10) != 0;
|
| 122 |
+
var kind = isDir ? FileKind.Directory : FileKind.File;
|
| 123 |
+
|
| 124 |
+
if ((record.Reason & NativeMethods.USN_REASON_FILE_DELETE) != 0) { _sender.Add(new IndexEvent.Deleted(record.FileReferenceNumber)); return; }
|
| 125 |
+
if ((record.Reason & NativeMethods.USN_REASON_RENAME_NEW_NAME) != 0) { _sender.Add(new IndexEvent.Moved(record.FileReferenceNumber, record.ParentFileReferenceNumber, name, kind)); return; }
|
| 126 |
+
if ((record.Reason & NativeMethods.USN_REASON_FILE_CREATE) != 0) _sender.Add(new IndexEvent.Created(new FileRecord { FileRef = record.FileReferenceNumber, ParentRef = record.ParentFileReferenceNumber, Name = name, Kind = kind }));
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
public void Dispose() => _handle.Dispose();
|
| 130 |
+
}
|
FastSeek.WinUI/App.xaml
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<Application
|
| 2 |
+
x:Class="FastSeek.WinUI.App"
|
| 3 |
+
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
| 4 |
+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
| 5 |
+
<Application.Resources>
|
| 6 |
+
<ResourceDictionary>
|
| 7 |
+
<Color x:Key="Bg">#161A1D</Color>
|
| 8 |
+
<Color x:Key="Panel">#20262B</Color>
|
| 9 |
+
<Color x:Key="Accent">#4FC3F7</Color>
|
| 10 |
+
<SolidColorBrush x:Key="BgBrush" Color="{StaticResource Bg}" />
|
| 11 |
+
<SolidColorBrush x:Key="PanelBrush" Color="{StaticResource Panel}" />
|
| 12 |
+
<SolidColorBrush x:Key="AccentBrush" Color="{StaticResource Accent}" />
|
| 13 |
+
</ResourceDictionary>
|
| 14 |
+
</Application.Resources>
|
| 15 |
+
</Application>
|
FastSeek.WinUI/App.xaml.cs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using Microsoft.UI.Xaml;
|
| 2 |
+
|
| 3 |
+
namespace FastSeek.WinUI;
|
| 4 |
+
|
| 5 |
+
public partial class App : Application
|
| 6 |
+
{
|
| 7 |
+
private Window? _window;
|
| 8 |
+
|
| 9 |
+
public App()
|
| 10 |
+
{
|
| 11 |
+
this.InitializeComponent();
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
protected override void OnLaunched(LaunchActivatedEventArgs args)
|
| 15 |
+
{
|
| 16 |
+
_window = new MainWindow();
|
| 17 |
+
_window.Activate();
|
| 18 |
+
}
|
| 19 |
+
}
|
FastSeek.WinUI/FastSeek.WinUI.csproj
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<Project Sdk="Microsoft.NET.Sdk">
|
| 2 |
+
<PropertyGroup>
|
| 3 |
+
<OutputType>WinExe</OutputType>
|
| 4 |
+
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
| 5 |
+
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
|
| 6 |
+
<RootNamespace>FastSeek.WinUI</RootNamespace>
|
| 7 |
+
<ApplicationManifest>app.manifest</ApplicationManifest>
|
| 8 |
+
<Platforms>x64</Platforms>
|
| 9 |
+
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
| 10 |
+
<UseWinUI>true</UseWinUI>
|
| 11 |
+
<Nullable>enable</Nullable>
|
| 12 |
+
<ImplicitUsings>enable</ImplicitUsings>
|
| 13 |
+
|
| 14 |
+
<WindowsPackageType>None</WindowsPackageType>
|
| 15 |
+
<AppxPackage>false</AppxPackage>
|
| 16 |
+
<EnableMsixTooling>false</EnableMsixTooling>
|
| 17 |
+
<WindowsAppSdkSelfContained>true</WindowsAppSdkSelfContained>
|
| 18 |
+
<GenerateAppxPackageOnBuild>false</GenerateAppxPackageOnBuild>
|
| 19 |
+
<EnablePreviewMsixTooling>false</EnablePreviewMsixTooling>
|
| 20 |
+
</PropertyGroup>
|
| 21 |
+
<ItemGroup>
|
| 22 |
+
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.6.250205002" />
|
| 23 |
+
</ItemGroup>
|
| 24 |
+
<ItemGroup>
|
| 25 |
+
<ProjectReference Include="..\FastSeek.Core\FastSeek.Core.csproj" />
|
| 26 |
+
</ItemGroup>
|
| 27 |
+
</Project>
|
FastSeek.WinUI/MainWindow.xaml
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<Window
|
| 2 |
+
x:Class="FastSeek.WinUI.MainWindow"
|
| 3 |
+
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
| 4 |
+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
| 5 |
+
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
| 6 |
+
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
| 7 |
+
mc:Ignorable="d"
|
| 8 |
+
Title="FastSeek">
|
| 9 |
+
<Grid Background="{StaticResource BgBrush}">
|
| 10 |
+
<Border Width="860" CornerRadius="18" Background="{StaticResource PanelBrush}" BorderBrush="#2F3B44" BorderThickness="1" Padding="14" HorizontalAlignment="Center" VerticalAlignment="Center">
|
| 11 |
+
<Grid RowDefinitions="Auto,*">
|
| 12 |
+
<AutoSuggestBox x:Name="SearchBox" PlaceholderText="Search files and folders..." QueryIcon="Find" TextChanged="SearchBox_TextChanged" KeyDown="SearchBox_KeyDown" FontSize="20" Background="#14191D" BorderBrush="#2A333A" Foreground="White" />
|
| 13 |
+
<ListView x:Name="ResultsList" Grid.Row="1" Margin="0,12,0,0" IsItemClickEnabled="True" ItemClick="ResultsList_ItemClick" SelectionMode="Single" Background="Transparent" Foreground="White">
|
| 14 |
+
<ListView.ItemTemplate>
|
| 15 |
+
<DataTemplate>
|
| 16 |
+
<Grid Padding="8,6">
|
| 17 |
+
<Grid.ColumnDefinitions>
|
| 18 |
+
<ColumnDefinition Width="58"/>
|
| 19 |
+
<ColumnDefinition Width="*"/>
|
| 20 |
+
</Grid.ColumnDefinitions>
|
| 21 |
+
<Border Grid.Column="0" Background="#132028" CornerRadius="8" Padding="8,3">
|
| 22 |
+
<TextBlock Text="{Binding Kind}" Foreground="{StaticResource AccentBrush}" FontWeight="SemiBold"/>
|
| 23 |
+
</Border>
|
| 24 |
+
<StackPanel Grid.Column="1" Margin="10,0,0,0">
|
| 25 |
+
<TextBlock Text="{Binding Name}" FontSize="15" TextTrimming="CharacterEllipsis"/>
|
| 26 |
+
<TextBlock Text="{Binding FullPath}" FontSize="12" Foreground="#B9C2CC" TextTrimming="CharacterEllipsis"/>
|
| 27 |
+
</StackPanel>
|
| 28 |
+
</Grid>
|
| 29 |
+
</DataTemplate>
|
| 30 |
+
</ListView.ItemTemplate>
|
| 31 |
+
</ListView>
|
| 32 |
+
</Grid>
|
| 33 |
+
</Border>
|
| 34 |
+
</Grid>
|
| 35 |
+
</Window>
|
FastSeek.WinUI/MainWindow.xaml.cs
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using System.Collections.ObjectModel;
|
| 2 |
+
using System.Diagnostics;
|
| 3 |
+
using FastSeek.Core;
|
| 4 |
+
using FastSeek.Core.Search;
|
| 5 |
+
using Microsoft.UI.Input;
|
| 6 |
+
using Microsoft.UI.Windowing;
|
| 7 |
+
using Microsoft.UI.Xaml;
|
| 8 |
+
using Microsoft.UI.Xaml.Controls;
|
| 9 |
+
using Microsoft.UI.Xaml.Input;
|
| 10 |
+
using Windows.System;
|
| 11 |
+
|
| 12 |
+
namespace FastSeek.WinUI;
|
| 13 |
+
|
| 14 |
+
public sealed partial class MainWindow : Window
|
| 15 |
+
{
|
| 16 |
+
private readonly FastSeekEngine _engine = new();
|
| 17 |
+
private readonly ObservableCollection<ResultItem> _items = new();
|
| 18 |
+
|
| 19 |
+
public MainWindow()
|
| 20 |
+
{
|
| 21 |
+
this.InitializeComponent();
|
| 22 |
+
ResultsList.ItemsSource = _items;
|
| 23 |
+
ExtendsContentIntoTitleBar = true;
|
| 24 |
+
this.Closed += (_, _) => _engine.Dispose();
|
| 25 |
+
SetSpotlightSizing();
|
| 26 |
+
_ = InitializeEngineAsync();
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
private async Task InitializeEngineAsync()
|
| 30 |
+
{
|
| 31 |
+
SearchBox.PlaceholderText = "Indexing NTFS drives...";
|
| 32 |
+
try
|
| 33 |
+
{
|
| 34 |
+
await _engine.InitializeAsync();
|
| 35 |
+
SearchBox.PlaceholderText = "Search files and folders...";
|
| 36 |
+
}
|
| 37 |
+
catch (Exception ex)
|
| 38 |
+
{
|
| 39 |
+
SearchBox.PlaceholderText = "Initialization failed";
|
| 40 |
+
_items.Clear();
|
| 41 |
+
_items.Add(new ResultItem { Kind = "ERR", Name = "Could not initialize index", FullPath = ex.Message, Raw = null });
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
private void SetSpotlightSizing()
|
| 46 |
+
{
|
| 47 |
+
var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
|
| 48 |
+
var id = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(hwnd);
|
| 49 |
+
var appWindow = AppWindow.GetFromWindowId(id);
|
| 50 |
+
appWindow.Resize(new Windows.Graphics.SizeInt32(900, 560));
|
| 51 |
+
if (appWindow.Presenter is OverlappedPresenter p)
|
| 52 |
+
{
|
| 53 |
+
p.IsMaximizable = false;
|
| 54 |
+
p.IsMinimizable = true;
|
| 55 |
+
p.IsResizable = true;
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
private async void SearchBox_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args)
|
| 60 |
+
{
|
| 61 |
+
if (args.Reason != AutoSuggestionBoxTextChangeReason.UserInput) return;
|
| 62 |
+
var query = sender.Text;
|
| 63 |
+
var results = await Task.Run(() => _engine.Search(query, 50));
|
| 64 |
+
RenderResults(results);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
private void RenderResults(List<SearchResult> results)
|
| 68 |
+
{
|
| 69 |
+
_items.Clear();
|
| 70 |
+
foreach (var r in results)
|
| 71 |
+
{
|
| 72 |
+
_items.Add(new ResultItem
|
| 73 |
+
{
|
| 74 |
+
Kind = r.IsDir ? "DIR" : "FILE",
|
| 75 |
+
Name = r.Name,
|
| 76 |
+
FullPath = r.FullPath,
|
| 77 |
+
Raw = r,
|
| 78 |
+
});
|
| 79 |
+
}
|
| 80 |
+
if (_items.Count > 0) ResultsList.SelectedIndex = 0;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
private void SearchBox_KeyDown(object sender, KeyRoutedEventArgs e)
|
| 84 |
+
{
|
| 85 |
+
if (e.Key == VirtualKey.Down)
|
| 86 |
+
{
|
| 87 |
+
if (_items.Count == 0) return;
|
| 88 |
+
var i = Math.Min(ResultsList.SelectedIndex + 1, _items.Count - 1);
|
| 89 |
+
ResultsList.SelectedIndex = i;
|
| 90 |
+
ResultsList.ScrollIntoView(_items[i]);
|
| 91 |
+
e.Handled = true;
|
| 92 |
+
}
|
| 93 |
+
else if (e.Key == VirtualKey.Up)
|
| 94 |
+
{
|
| 95 |
+
if (_items.Count == 0) return;
|
| 96 |
+
var i = Math.Max(ResultsList.SelectedIndex - 1, 0);
|
| 97 |
+
ResultsList.SelectedIndex = i;
|
| 98 |
+
ResultsList.ScrollIntoView(_items[i]);
|
| 99 |
+
e.Handled = true;
|
| 100 |
+
}
|
| 101 |
+
else if (e.Key == VirtualKey.Enter)
|
| 102 |
+
{
|
| 103 |
+
OpenSelected();
|
| 104 |
+
e.Handled = true;
|
| 105 |
+
}
|
| 106 |
+
else if (e.Key == VirtualKey.Escape)
|
| 107 |
+
{
|
| 108 |
+
this.Close();
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
private void ResultsList_ItemClick(object sender, ItemClickEventArgs e) => OpenSelected();
|
| 113 |
+
|
| 114 |
+
private void OpenSelected()
|
| 115 |
+
{
|
| 116 |
+
if (ResultsList.SelectedItem is not ResultItem item) return;
|
| 117 |
+
try
|
| 118 |
+
{
|
| 119 |
+
Process.Start(new ProcessStartInfo
|
| 120 |
+
{
|
| 121 |
+
FileName = item.FullPath,
|
| 122 |
+
UseShellExecute = true,
|
| 123 |
+
});
|
| 124 |
+
}
|
| 125 |
+
catch
|
| 126 |
+
{
|
| 127 |
+
try
|
| 128 |
+
{
|
| 129 |
+
Process.Start(new ProcessStartInfo
|
| 130 |
+
{
|
| 131 |
+
FileName = "explorer.exe",
|
| 132 |
+
Arguments = $"/select,\"{item.FullPath}\"",
|
| 133 |
+
UseShellExecute = true,
|
| 134 |
+
});
|
| 135 |
+
}
|
| 136 |
+
catch { }
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
private sealed class ResultItem
|
| 141 |
+
{
|
| 142 |
+
public required string Kind { get; init; }
|
| 143 |
+
public required string Name { get; init; }
|
| 144 |
+
public required string FullPath { get; init; }
|
| 145 |
+
public SearchResult? Raw { get; init; }
|
| 146 |
+
}
|
| 147 |
+
}
|
FastSeek.WinUI/app.manifest
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="utf-8"?>
|
| 2 |
+
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
| 3 |
+
<assemblyIdentity version="1.0.0.0" name="FastSeek.WinUI.app"/>
|
| 4 |
+
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
| 5 |
+
<application>
|
| 6 |
+
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
| 7 |
+
</application>
|
| 8 |
+
</compatibility>
|
| 9 |
+
</assembly>
|
global.json
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"sdk": {
|
| 3 |
+
"version": "10.0.203"
|
| 4 |
+
}
|
| 5 |
+
}
|