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 通常会这样写:

text 复制代码
选择目录 -> 递归遍历 -> 收集文件路径 -> 放进 Vec -> 搜索

这种方案简单、跨平台,也容易实现。但它有几个明显问题:

  • 首次扫描慢,尤其是文件数量很多时
  • 每次启动都重新扫描,体验不稳定
  • 文件变化后很难高效增量更新
  • 扫描过程依赖目录树遍历,本质上没有利用文件系统已经维护好的元数据

FilePulse 采用的是另一条路线:

text 复制代码
选择 NTFS 卷
-> 打开卷句柄
-> 查询 USN Journal 状态
-> 使用 FSCTL_ENUM_USN_DATA 枚举 MFT/USN 记录
-> 根据 file_ref / parent_ref 重建路径
-> 建立内存索引
-> 保存索引快照
-> 使用 FSCTL_READ_USN_JOURNAL 读取增量变化

也就是说,我不是从目录树一层一层走下去,而是直接读取 NTFS 暴露出来的文件记录,再在应用层把它们整理成可搜索的数据结构。

技术栈

Rust 侧主要使用:

  • windows-sys:调用 Windows API
  • serde:序列化索引结构
  • bincode:保存二进制索引快照
  • tokio:处理后台同步任务
  • anyhow / thiserror:错误处理

项目的 Rust 核心模块大致如下:

text 复制代码
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 文件系统。

核心流程是:

text 复制代码
GetLogicalDrives
-> GetDriveTypeW
-> GetVolumeInformationW
-> 判断是否为本地固定 NTFS 卷

这样可以排除移动盘、网络盘、光驱,以及非 NTFS 文件系统。

这个限制是有意为之。MFT 和 USN Journal 都是 NTFS 相关能力,项目当前阶段只聚焦 Windows 本地 NTFS 卷,避免为了兼容性过早复杂化。

第二步:打开卷句柄

普通文件可以用路径打开,比如:

text 复制代码
C:\Users\example.txt

但如果要读取整个卷的底层信息,需要打开类似这样的卷路径:

text 复制代码
\\.\F:

Rust 里通过 CreateFileW 打开卷句柄:

rust 复制代码
let path = format!(r"\\.\{}", volume.trim_end_matches('\\'));

这里有几个关键点:

  • 路径需要转成 UTF-16,并以 0 结尾
  • 通常需要管理员权限
  • 需要允许读、写、删除共享,避免和系统或其他程序冲突
  • 句柄使用完必须关闭,因此我封装了 VolumeHandle,在 Drop 中调用 CloseHandle

这也是 Rust 比较舒服的地方:Windows API 本身是偏 C 风格的,但可以把不安全操作收拢在一个小模块里,再用 RAII 管理资源释放。

第三步:查询 USN Journal

打开卷句柄后,需要先查询当前 USN Journal 的状态。

项目里调用:

text 复制代码
FSCTL_QUERY_USN_JOURNAL

拿到的信息里最重要的是:

  • UsnJournalID:当前 Journal 的 ID
  • FirstUsn:当前还保留的最早 USN
  • NextUsn:下一条 USN 位置

这些字段会被保存到索引状态里:

rust 复制代码
pub struct VolumeIndexState {
    pub volume: String,
    pub journal_id: u64,
    pub first_usn: i64,
    pub next_usn: i64,
    pub indexed_at: u64,
    pub last_synced_at: u64,
    pub source: String,
}

后续增量同步时,就可以从上次保存的 next_usn 开始继续读取变化。

第四步:枚举 MFT/USN 记录

首次建立索引时,项目使用:

text 复制代码
FSCTL_ENUM_USN_DATA

它可以枚举卷中的文件记录。每条记录里包含一些非常关键的信息:

  • 文件引用号 FileReferenceNumber
  • 父目录引用号 ParentFileReferenceNumber
  • 文件名
  • 文件属性
  • 时间戳
  • 当前 USN

这些信息会被转换成项目内部的 NtfsRecord

rust 复制代码
pub struct NtfsRecord {
    pub file_ref: u64,
    pub parent_ref: u64,
    pub name: String,
    pub attributes: u32,
    pub usn: i64,
    pub timestamp: i64,
    pub reason: u32,
}

这里最需要小心的是 buffer 解析。Windows API 返回的是一段字节缓冲区,里面连续存放多条 USN_RECORD。Rust 侧需要按 RecordLength 一条一条读取,同时把 UTF-16 文件名转换成 String

这部分代码会不可避免地用到 unsafe,但原则是让 unsafe 尽量局部化:只在解析 Windows 原始结构体时使用,解析完成后立刻转成普通 Rust 结构体。

第五步:根据 file_ref / parent_ref 重建完整路径

MFT/USN 记录本身不会直接给出完整路径。它给的是:

text 复制代码
当前文件的 file_ref
父目录的 parent_ref
当前文件名 name

也就是说,每条记录更像是一个节点。要得到完整路径,需要根据父子关系往上回溯。

例如:

text 复制代码
5   -> .       -> F:\
10  -> src     -> parent = 5
11  -> main.rs -> parent = 10

最终重建出:

text 复制代码
F:\src\main.rs

项目里使用 HashMap<u64, (u64, String)> 保存原始关系,然后用递归加 memo 缓存解析路径:

text 复制代码
file_ref -> (parent_ref, name)

重建路径时需要处理几个边界情况:

  • 根目录的 parent 可能指向自己
  • 父节点可能缺失
  • 关系异常时要避免递归环
  • 路径拼接要处理卷根目录的反斜杠

这部分是整个索引器里非常核心的一步。因为只有把 NTFS 的底层记录转换成完整路径,后面搜索、打开、定位文件才有意义。

第六步:建立应用层索引

底层的 NtfsRecord 还不能直接用于搜索。项目会把它转换成更适合应用层使用的 FileEntry

rust 复制代码
pub struct FileEntry {
    pub id: u64,
    pub volume: String,
    pub file_ref: u64,
    pub parent_ref: u64,
    pub name: String,
    pub path: String,
    pub extension: Option<String>,
    pub size: Option<u64>,
    pub modified: u64,
    pub is_dir: bool,
    pub last_usn: i64,
    pub attributes: 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 做增量同步。

同步流程是:

text 复制代码
读取上次保存的 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 这类结构,删除目录时会收集整棵子树:

text 复制代码
folder
├─ a.txt
├─ b.txt
└─ sub
   └─ c.txt

如果 folder 被删除,索引里要一起删除 a.txtb.txtsubc.txt

第九步:快照持久化

索引建立完成后,如果下次启动还要重新读一遍 MFT,体验会很差。因此项目会把索引状态保存成快照:

rust 复制代码
pub struct IndexSnapshot {
    pub version: u32,
    pub volume_state: VolumeIndexState,
    pub entries: Vec<FileEntry>,
}

保存方式是:

text 复制代码
IndexSnapshot
-> bincode serialize
-> 写入临时文件
-> rename 成正式快照文件

用临时文件再 rename,是为了降低写坏快照的概率。如果程序在写入过程中退出,至少不会轻易破坏旧快照。

快照里还带了 version 字段。以后如果索引结构变化,可以通过版本号判断旧快照是否还能使用。

第十步:后台监听和并发控制

Windows API 调用会阻塞线程,所以项目里把建索引和读 USN 的操作放进 spawn_blocking

索引状态使用:

rust 复制代码
RwLock<SearchIndex>

这样搜索可以读锁访问,建索引或同步变更时再拿写锁。

同时,建索引、同步、保存快照之间不能随意并发执行,否则容易出现状态互相覆盖。因此项目里还有一个操作门禁:

rust 复制代码
tokio::sync::Mutex<()>

后台监听每隔一段时间读取 USN Journal:

text 复制代码
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 就不再可靠。这时继续同步会导致索引悄悄不一致。

正确做法是明确标记:

text 复制代码
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 文件索引路线:

text 复制代码
NTFS MFT
-> USN Journal
-> Rust 数据结构
-> 内存索引
-> 增量同步
-> 本地快照

相比递归遍历目录,这条路线更贴近文件系统本身,也更能体现 Rust 在系统工具开发中的优势:性能可控、内存安全、结构清晰,并且可以把危险的 FFI 边界封装在很小的范围内。

FilePulse 现在还只是一个实验型 MVP,但它已经验证了一个方向:用 Rust 做 Windows 本地效率工具的可行性。

相关推荐
Roc-xb1 小时前
你似乎已在另一个帐户上设置了 Windows Hello。请将人脸识别从其他帐户删除,然后重试。
windows·人脸识别
HAPPY酷2 小时前
【ROS2】16G 内存笔记本跑 ROS2 仿真?VMware 虚拟机“保姆级”配置指南 (R9 7940HX + RTX 4060)
java·linux·ide·windows·pycharm
程序员爱钓鱼2 小时前
Rust 变量与不可变性:为什么默认不能修改变量?
前端·后端·rust
夜雪一千11 小时前
Python enumerate() 函数完整详解:遍历同时获取索引,告别手动计数
服务器·windows·python
Elastic 中国社区官方博客13 小时前
如何比较两个 Elasticsearch 索引并找出缺失的文档
大数据·运维·数据库·elasticsearch·搜索引擎
第一程序员13 小时前
Rust Agent 子进程执行:Command 之前,先定义输入和超时
python·rust·github
IT笔记15 小时前
【Rust】Rust Match 模式匹配详解
java·开发语言·rust
取地址符19 小时前
Rust学习笔记(基于Rustlings)(2):数据结构与集合
笔记·学习·rust
取地址符20 小时前
Rust语法学习 (基于Rustlings)(1):基础语法
经验分享·笔记·学习·rust