Rust实现Windows本地搜索工具

发布时间:2026/7/11 21:30:37
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打开卷句柄letpathformat!(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这些信息会被转换成项目内部的NtfsRecordpubstructNtfsRecord{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项目里使用HashMapu64, (u64, String)保存原始关系然后用递归加 memo 缓存解析路径file_ref - (parent_ref, name)重建路径时需要处理几个边界情况根目录的 parent 可能指向自己父节点可能缺失关系异常时要避免递归环路径拼接要处理卷根目录的反斜杠这部分是整个索引器里非常核心的一步。因为只有把 NTFS 的底层记录转换成完整路径后面搜索、打开、定位文件才有意义。第六步建立应用层索引底层的NtfsRecord还不能直接用于搜索。项目会把它转换成更适合应用层使用的FileEntrypubstructFileEntry{pubid:u64,pubvolume:String,pubfile_ref:u64,pubparent_ref:u64,pubname:String,pubpath:String,pubextension:OptionString,pubsize:Optionu64,pubmodified:u64,pubis_dir:bool,publast_usn:i64,pubattributes:u32,}内存索引里主要维护entries: VecFileEntry所有可搜索条目searchable: VecString预处理后的搜索文本by_file_ref: HashMapu64, usize按文件引用快速定位children_by_parent: HashMapu64, Vecu64按父目录找到子节点搜索逻辑当前比较克制只做文件名匹配大小写不敏感完全匹配优先前缀匹配其次目录排在文件前面默认最多返回 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:VecFileEntry,}保存方式是IndexSnapshot - bincode serialize - 写入临时文件 - rename 成正式快照文件用临时文件再 rename是为了降低写坏快照的概率。如果程序在写入过程中退出至少不会轻易破坏旧快照。快照里还带了version字段。以后如果索引结构变化可以通过版本号判断旧快照是否还能使用。第十步后台监听和并发控制Windows API 调用会阻塞线程所以项目里把建索引和读 USN 的操作放进spawn_blocking。索引状态使用RwLockSearchIndex这样搜索可以读锁访问建索引或同步变更时再拿写锁。同时建索引、同步、保存快照之间不能随意并发执行否则容易出现状态互相覆盖。因此项目里还有一个操作门禁tokio::sync::Mutex()后台监听每隔一段时间读取 USN Journalidle - 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 本地效率工具的可行性。