Rust实现Windows本地搜索工具
用 Rust 读取 NTFS MFT/USN Journal本地文件索引器
GitHub 仓库地址:
https://github.com/ning696/FilePulse
工具展示:
前言
Windows 上的文件搜索工具有很多,最有代表性的当然是 Everything。它之所以快,很重要的原因是它没有像普通程序那样递归遍历每一个目录,而是利用了 NTFS 文件系统本身维护的底层数据。
我最近写了一个实验型项目 FilePulse,目标不是一开始就完整替代 Everything,而是用 Rust 实现一个更接近系统底层的本地文件索引核心:
- 读取本地 NTFS 卷信息
- 通过 MFT 枚举文件记录
- 根据文件引用关系重建完整路径
- 使用 USN Journal 增量同步文件变化
- 将索引快照保存到本地,下次启动时直接恢复
这篇文章只聊 Rust 后端部分,不展开前端 UI。Tauri 和 React 在这个项目里主要负责桌面界面,真正有意思的部分在 Rust 对 Windows NTFS API 的封装和索引逻辑。
为什么不是递归遍历目录
一个最直接的文件搜索 MVP 通常会这样写:
选择目录 -> 递归遍历 -> 收集文件路径 -> 放进 Vec -> 搜索这种方案简单、跨平台,也容易实现。但它有几个明显问题:
- 首次扫描慢,尤其是文件数量很多时
- 每次启动都重新扫描,体验不稳定
- 文件变化后很难高效增量更新
- 扫描过程依赖目录树遍历,本质上没有利用文件系统已经维护好的元数据
FilePulse 采用的是另一条路线:
选择 NTFS 卷 -> 打开卷句柄 -> 查询 USN Journal 状态 -> 使用 FSCTL_ENUM_USN_DATA 枚举 MFT/USN 记录 -> 根据 file_ref / parent_ref 重建路径 -> 建立内存索引 -> 保存索引快照 -> 使用 FSCTL_READ_USN_JOURNAL 读取增量变化也就是说,我不是从目录树一层一层走下去,而是直接读取 NTFS 暴露出来的文件记录,再在应用层把它们整理成可搜索的数据结构。
技术栈
Rust 侧主要使用:
windows-sys:调用 Windows APIserde:序列化索引结构bincode:保存二进制索引快照tokio:处理后台同步任务anyhow/thiserror:错误处理
项目的 Rust 核心模块大致如下:
src-tauri/src/core/windows_ntfs.rs # Windows NTFS API 封装 src-tauri/src/core/index.rs # 内存索引、路径重建、增量合并 src-tauri/src/core/model.rs # 核心数据结构 src-tauri/src/core/storage.rs # 索引快照保存和加载 src-tauri/src/core/search.rs # 搜索匹配和排序 src-tauri/src/monitor.rs # 后台 USN Journal 监听 src-tauri/src/commands.rs # Tauri 命令入口第一步:枚举本地 NTFS 卷
要读取 MFT/USN,首先需要知道当前机器上有哪些可用卷。项目里通过 Windows API 枚举逻辑驱动器,然后筛选本地固定磁盘和 NTFS 文件系统。
核心流程是:
GetLogicalDrives -> GetDriveTypeW -> GetVolumeInformationW -> 判断是否为本地固定 NTFS 卷这样可以排除移动盘、网络盘、光驱,以及非 NTFS 文件系统。
这个限制是有意为之。MFT 和 USN Journal 都是 NTFS 相关能力,项目当前阶段只聚焦 Windows 本地 NTFS 卷,避免为了兼容性过早复杂化。
第二步:打开卷句柄
普通文件可以用路径打开,比如:
C:\Users\example.txt但如果要读取整个卷的底层信息,需要打开类似这样的卷路径:
\\.\F:Rust 里通过CreateFileW打开卷句柄:
letpath=format!(r"\\.\{}",volume.trim_end_matches('\\'));这里有几个关键点:
- 路径需要转成 UTF-16,并以
0结尾 - 通常需要管理员权限
- 需要允许读、写、删除共享,避免和系统或其他程序冲突
- 句柄使用完必须关闭,因此我封装了
VolumeHandle,在Drop中调用CloseHandle
这也是 Rust 比较舒服的地方:Windows API 本身是偏 C 风格的,但可以把不安全操作收拢在一个小模块里,再用 RAII 管理资源释放。
第三步:查询 USN Journal
打开卷句柄后,需要先查询当前 USN Journal 的状态。
项目里调用:
FSCTL_QUERY_USN_JOURNAL拿到的信息里最重要的是:
UsnJournalID:当前 Journal 的 IDFirstUsn:当前还保留的最早 USNNextUsn:下一条 USN 位置
这些字段会被保存到索引状态里:
pubstructVolumeIndexState{pubvolume:String,pubjournal_id:u64,pubfirst_usn:i64,pubnext_usn:i64,pubindexed_at:u64,publast_synced_at:u64,pubsource:String,}后续增量同步时,就可以从上次保存的next_usn开始继续读取变化。
第四步:枚举 MFT/USN 记录
首次建立索引时,项目使用:
FSCTL_ENUM_USN_DATA它可以枚举卷中的文件记录。每条记录里包含一些非常关键的信息:
- 文件引用号
FileReferenceNumber - 父目录引用号
ParentFileReferenceNumber - 文件名
- 文件属性
- 时间戳
- 当前 USN
这些信息会被转换成项目内部的NtfsRecord:
pubstructNtfsRecord{pubfile_ref:u64,pubparent_ref:u64,pubname:String,pubattributes:u32,pubusn:i64,pubtimestamp:i64,pubreason:u32,}这里最需要小心的是 buffer 解析。Windows API 返回的是一段字节缓冲区,里面连续存放多条USN_RECORD。Rust 侧需要按RecordLength一条一条读取,同时把 UTF-16 文件名转换成String。
这部分代码会不可避免地用到unsafe,但原则是让unsafe尽量局部化:只在解析 Windows 原始结构体时使用,解析完成后立刻转成普通 Rust 结构体。
第五步:根据 file_ref / parent_ref 重建完整路径
MFT/USN 记录本身不会直接给出完整路径。它给的是:
当前文件的 file_ref 父目录的 parent_ref 当前文件名 name也就是说,每条记录更像是一个节点。要得到完整路径,需要根据父子关系往上回溯。
例如:
5 -> . -> F:\ 10 -> src -> parent = 5 11 -> main.rs -> parent = 10最终重建出:
F:\src\main.rs项目里使用HashMap<u64, (u64, String)>保存原始关系,然后用递归加 memo 缓存解析路径:
file_ref -> (parent_ref, name)重建路径时需要处理几个边界情况:
- 根目录的 parent 可能指向自己
- 父节点可能缺失
- 关系异常时要避免递归环
- 路径拼接要处理卷根目录的反斜杠
这部分是整个索引器里非常核心的一步。因为只有把 NTFS 的底层记录转换成完整路径,后面搜索、打开、定位文件才有意义。
第六步:建立应用层索引
底层的NtfsRecord还不能直接用于搜索。项目会把它转换成更适合应用层使用的FileEntry:
pubstructFileEntry{pubid:u64,pubvolume:String,pubfile_ref:u64,pubparent_ref:u64,pubname:String,pubpath:String,pubextension:Option<String>,pubsize:Option<u64>,pubmodified:u64,pubis_dir:bool,publast_usn:i64,pubattributes:u32,}内存索引里主要维护:
entries: Vec<FileEntry>:所有可搜索条目searchable: Vec<String>:预处理后的搜索文本by_file_ref: HashMap<u64, usize>:按文件引用快速定位children_by_parent: HashMap<u64, Vec<u64>>:按父目录找到子节点
搜索逻辑当前比较克制,只做文件名匹配:
- 大小写不敏感
- 完全匹配优先
- 前缀匹配其次
- 目录排在文件前面
- 默认最多返回 500 条结果
这个阶段没有急着做复杂查询语法,因为底层索引的正确性更重要。
第七步:使用 USN Journal 增量同步
首次索引之后,如果每次文件变化都重新枚举整个 MFT,成本依然很高。真正让它像文件搜索器的地方,是通过 USN Journal 做增量同步。
同步流程是:
读取上次保存的 VolumeIndexState -> 打开卷 -> 查询当前 USN Journal -> 判断 journal_id 是否变化 -> 判断 first_usn 是否已经超过上次 next_usn -> 从 next_usn 开始读取变化记录 -> 合并到内存索引 -> 更新 next_usn如果journal_id变了,或者当前first_usn已经大于上次保存的next_usn,说明中间的记录已经无法完整读取。此时不能继续假装增量同步成功,而是要标记为需要重建索引。
这是一个很重要的工程判断:USN Journal 不是永久日志,它可能被系统清理,也可能重建。索引器必须承认这一点。
第八步:处理新增、修改、重命名和删除
USN 记录里有reason字段,用来描述变化原因。项目当前主要处理:
- 新增或修改:更新或插入对应
FileEntry - 重命名:忽略旧名称记录,使用新名称记录刷新路径
- 删除文件:从索引中移除该文件
- 删除目录:移除该目录以及所有子节点
删除目录是一个容易忽略的细节。如果只删除目录本身,目录下的文件还会残留在索引里,搜索结果就会出现不存在的路径。
因此项目里维护了children_by_parent这类结构,删除目录时会收集整棵子树:
folder ├─ a.txt ├─ b.txt └─ sub └─ c.txt如果folder被删除,索引里要一起删除a.txt、b.txt、sub、c.txt。
第九步:快照持久化
索引建立完成后,如果下次启动还要重新读一遍 MFT,体验会很差。因此项目会把索引状态保存成快照:
pubstructIndexSnapshot{pubversion:u32,pubvolume_state:VolumeIndexState,pubentries:Vec<FileEntry>,}保存方式是:
IndexSnapshot -> bincode serialize -> 写入临时文件 -> rename 成正式快照文件用临时文件再 rename,是为了降低写坏快照的概率。如果程序在写入过程中退出,至少不会轻易破坏旧快照。
快照里还带了version字段。以后如果索引结构变化,可以通过版本号判断旧快照是否还能使用。
第十步:后台监听和并发控制
Windows API 调用会阻塞线程,所以项目里把建索引和读 USN 的操作放进spawn_blocking。
索引状态使用:
RwLock<SearchIndex>这样搜索可以读锁访问,建索引或同步变更时再拿写锁。
同时,建索引、同步、保存快照之间不能随意并发执行,否则容易出现状态互相覆盖。因此项目里还有一个操作门禁:
tokio::sync::Mutex<()>后台监听每隔一段时间读取 USN Journal:
idle -> watching -> syncing -> watching如果出错,会进入 error 状态,并使用指数退避重试。快照保存也不是每次变化都立刻写磁盘,而是做了节流,避免频繁写入。
Rust 实现里的几个收获
1. unsafe 不可怕,但边界要小
调用 Windows API 时无法完全避免unsafe。关键是不要让unsafe扩散到整个项目。
我的处理方式是:
windows_ntfs.rs负责接触原始 Windows API- 解析完成后转成 Rust 自己的结构体
- 其他模块只处理安全的 Rust 类型
这样后续索引、搜索、快照逻辑都可以保持普通 Rust 代码。
2. 系统 API 返回的数据不等于应用数据
USN 记录不是可以直接展示的数据。它更像文件系统内部的变化记录。
应用真正需要的是:
- 完整路径
- 文件名
- 是否目录
- 扩展名
- 修改时间
- 文件大小
- 可搜索文本
所以中间必须有一层转换,把底层记录整理成应用层索引。
3. 增量同步必须处理失效场景
很多人第一次设计增量同步时,会默认日志永远完整。但 USN Journal 不是这样。
如果 Journal 被系统清理,或者 Journal ID 变化,上次的next_usn就不再可靠。这时继续同步会导致索引悄悄不一致。
正确做法是明确标记:
needs_rebuild = true然后提示用户重建索引。
4. 删除目录比删除文件复杂
文件删除只需要移除一个节点。目录删除则需要移除整棵子树。
这要求索引不仅能通过file_ref找到条目,还要能通过parent_ref找到所有子节点。
5. 快照比想象中重要
首次索引可以慢一点,但每次启动都慢就不行。快照让索引器从“实验代码”更接近“可用工具”。
保存快照时还要注意:
- 结构版本
- 临时文件写入
- 原子替换
- 退出前刷新
- 后台同步后的节流保存
当前限制
FilePulse 目前还是一个 MVP,限制也比较明确:
- 只支持 Windows
- 只支持本地固定 NTFS 卷
- MFT/USN 读取通常需要管理员权限
- 还没有做 Windows Service
- 当前搜索语法比较简单
- 文件大小和部分 metadata 仍需要额外补全
- 还没有针对百万级结果做虚拟列表展示
这些限制并不影响它作为一个 Rust 系统编程练习项目的价值。相反,正是这些限制让后续演进方向很清晰。
后续计划
后面我想继续完善几个方向:
- 增加 Windows Service,让索引常驻后台
- 支持更丰富的查询语法,比如
ext:、path:、is:dir - 优化大索引下的搜索性能
- 对搜索结果异步补全文件大小、修改时间等 metadata
- 做托盘、全局快捷键和开机启动
总结
这个项目最大的收获,不是做出了一个完整的文件搜索软件,而是用 Rust 走通了一条更底层的 Windows 文件索引路线:
NTFS MFT -> USN Journal -> Rust 数据结构 -> 内存索引 -> 增量同步 -> 本地快照相比递归遍历目录,这条路线更贴近文件系统本身,也更能体现 Rust 在系统工具开发中的优势:性能可控、内存安全、结构清晰,并且可以把危险的 FFI 边界封装在很小的范围内。
FilePulse 现在还只是一个实验型 MVP,但它已经验证了一个方向:用 Rust 做 Windows 本地效率工具的可行性。