Skip to main content

omy_core/
scan.rs

1//! 目录扫描:找出 .omy 文件并用已缓存的凭据自动匹配。
2//!
3//! # 只读文件头,不读整个文件
4//!
5//! 这是本模块最重要的性质。识别一个 .omy 文件并尝试解锁,**只需读取开头的
6//! 若干百字节**:
7//!
8//! ```text
9//! 读 480 B → 比对 magic (8 B) → 解析固定头 (96 B) → 逐凭据试解 slot 区 (384 B)
10//!         → 若需文件名,补读 TLV 区(通常再几十到几百字节)
11//! ```
12//!
13//! 载荷完全不碰。一个 4 GiB 的加密视频与一个 1 KiB 的加密文本,扫描成本相同。
14//!
15//! ⚠️ 注意 [`MIN_PROBE_SIZE`](480)与 `file::PEEK_SIZE`(96)的区别:
16//! 后者只够解析固定头,**不足以尝试解锁**。见 [`MIN_PROBE_SIZE`] 的文档。
17//!
18//! # 扫描一个文件要花多久
19//!
20//! 每个文件每个凭据只做一次 HKDF + 一次 AEAD 解包尝试:
21//!
22//! | 操作 | 耗时 |
23//! |---|---|
24//! | HKDF-SHA256 | 13.2 µs |
25//! | Argon2id(**已被会话缓存,扫描时不执行**) | 180 ms |
26//!
27//! 实测 500 文件 × 5 密码 = 171 ms。若每个文件都跑 Argon2 则需 450 s(慢 2,627×)。
28//!
29//! 因此扫描器**必须**接收已解锁的 [`SessionKeys`],而不是密码。
30//!
31//! # 不做全盘扫描
32//!
33//! 按用户要求与平台限制(iOS 根本不允许、Android 的全盘权限过审困难、
34//! macOS 需要完全磁盘访问授权),本模块只扫描**调用方明确给定的目录**。
35
36use std::fs::File;
37use std::io::Read as _;
38use std::path::{Path, PathBuf};
39
40use crate::error::{Error, Result};
41use crate::header::{
42    FIXED_HEADER_LEN, FixedHeader, HEADER_LEN_FIELD_OFFSET, HEADER_MAC_LEN, MAGIC_FILE,
43    SLOT_AREA_OFFSET, TLV_AREA_OFFSET, parse_limits,
44};
45use crate::session::{CredentialKind, SessionKeys};
46use crate::slot::unwrap_fek;
47use crate::tlv::{TlvSet, types};
48
49/// 识别一个文件至少需要读取的字节数:固定头 + slot 区 = 480。
50///
51/// # 为什么不是 `file::PEEK_SIZE`
52///
53/// `file::PEEK_SIZE` 等于 [`FIXED_HEADER_LEN`](96),只够解析固定头。
54/// 而**判断能否解锁**必须读到 slot 区(偏移 96..480)。若只读 96 字节就去解包
55/// slot,会得到 `Truncated` 而被误判为「文件损坏」——实际文件完好无损。
56pub const MIN_PROBE_SIZE: usize = TLV_AREA_OFFSET;
57
58/// omy 文件的最小可能大小:完整头部(含空 TLV 与 MAC)。
59pub const MIN_FILE_SIZE: usize = TLV_AREA_OFFSET + HEADER_MAC_LEN;
60
61/// 单个已识别文件的扫描结果。
62#[derive(Debug)]
63pub struct ScanHit {
64    /// 文件路径。
65    pub path: PathBuf,
66    /// 文件总大小(来自文件系统,未读取内容)。
67    pub file_size: u64,
68    /// 解析出的固定头。
69    pub header: FixedHeader,
70    /// 匹配结果。
71    pub unlock: UnlockOutcome,
72}
73
74/// 解锁尝试的结果。
75#[derive(Debug)]
76pub enum UnlockOutcome {
77    /// 某个凭据成功解开。
78    Unlocked {
79        /// 命中的凭据名称。
80        credential: String,
81        /// 凭据类型。
82        kind: CredentialKind,
83        /// 命中的 slot 序号。
84        slot_index: usize,
85        /// 解出的原始文件名(若文件加密了文件名且 TLV 可读)。
86        filename: Option<String>,
87        /// 明文大小。
88        plaintext_size: u64,
89    },
90    /// 是 .omy 文件,但当前所有凭据都打不开。
91    ///
92    /// 注意:这**不代表**文件损坏或密码错误——它可能属于另一个密码集。
93    /// UI 应表述为「不属于当前密码」而非「密码错误」。
94    Locked,
95}
96
97impl UnlockOutcome {
98    /// 是否成功解锁。
99    #[must_use]
100    pub const fn is_unlocked(&self) -> bool {
101        matches!(self, Self::Unlocked { .. })
102    }
103}
104
105/// 扫描配置。
106#[derive(Debug, Clone)]
107pub struct ScanOptions {
108    /// 是否递归子目录。
109    pub recursive: bool,
110    /// 最大递归深度(`recursive` 为真时生效),防止符号链接环导致无限递归。
111    pub max_depth: usize,
112    /// 是否跟随符号链接。默认 `false`——跟随会带来环路与越权读取风险。
113    pub follow_symlinks: bool,
114    /// 只检查扩展名匹配的文件。为 `None` 时检查所有文件(伪装文件没有 .omy 扩展名)。
115    pub extension_filter: Option<String>,
116    /// 单次扫描的文件数上限,防止误选巨大目录导致界面卡死。
117    pub max_files: usize,
118}
119
120impl Default for ScanOptions {
121    fn default() -> Self {
122        Self {
123            recursive: true,
124            max_depth: 32,
125            follow_symlinks: false,
126            // 默认不按扩展名过滤:伪装模式下文件可能叫 .jpg
127            extension_filter: None,
128            max_files: 100_000,
129        }
130    }
131}
132
133impl ScanOptions {
134    /// 只扫描 `.omy` 扩展名,速度更快但会漏掉伪装文件。
135    #[must_use]
136    pub fn omy_only() -> Self {
137        Self {
138            extension_filter: Some(crate::FILE_EXTENSION.to_owned()),
139            ..Self::default()
140        }
141    }
142}
143
144/// 扫描统计,用于向用户展示进度与结果。
145#[derive(Debug, Default, Clone, Copy)]
146pub struct ScanStats {
147    /// 检查过的文件总数。
148    pub files_examined: usize,
149    /// 识别为 .omy 的文件数。
150    pub omy_found: usize,
151    /// 成功解锁的文件数。
152    pub unlocked: usize,
153    /// 因 I/O 错误跳过的文件数。
154    pub io_errors: usize,
155    /// 头部损坏或版本不支持的文件数。
156    pub malformed: usize,
157}
158
159/// 扫描结果集合。
160#[derive(Debug, Default)]
161pub struct ScanResult {
162    /// 所有识别为 .omy 的文件。
163    pub hits: Vec<ScanHit>,
164    /// 统计信息。
165    pub stats: ScanStats,
166}
167
168impl ScanResult {
169    /// 仅返回成功解锁的条目。
170    #[must_use]
171    pub fn unlocked(&self) -> Vec<&ScanHit> {
172        self.hits.iter().filter(|h| h.unlock.is_unlocked()).collect()
173    }
174}
175
176/// 从已打开的文件中读取恰好 `n` 字节(允许短读到 EOF)。
177///
178/// 用 take + read_to_end 而不是单次 `read`:`read` 只保证读到至少 1 字节,
179/// 网络盘与 Android FUSE 上常被拆段。也不按 `n` 预分配——`n` 可能来自
180/// 未验证的文件头,按它分配会让一个声称超大头部的坏文件吃掉内存。
181fn read_exact_upto(f: &mut File, n: usize) -> std::io::Result<Vec<u8>> {
182    let mut buf = Vec::new();
183    f.take(n as u64).read_to_end(&mut buf)?;
184    Ok(buf)
185}
186
187/// 读文件开头至多 `n` 字节(文件更短时读到末尾为止)。
188///
189/// 只为认文件头时用它,不要把整个文件读进来:几 GB 的视频读全文件
190/// 只为取前几百字节,是纯粹的 IO 浪费。各 crate 曾各写一份,且多数只调
191/// 一次 `read`,短读时会把加密文件误判成普通文件。
192///
193/// # Errors
194///
195/// 文件打不开或读失败时返回。
196pub fn read_prefix(path: &Path, n: usize) -> std::io::Result<Vec<u8>> {
197    let mut f = File::open(path)?;
198    read_exact_upto(&mut f, n)
199}
200
201///
202/// 补读量同时受 [`parse_limits::MAX_HEADER_LEN`] 与文件实际长度约束:
203/// `header_len` 来自尚未验证的文件内容,头部不可能比文件本身长。
204///
205/// # Errors
206///
207/// 文件打不开或读失败时返回。
208pub fn read_header_area(path: &Path) -> std::io::Result<Vec<u8>> {
209    let mut f = File::open(path)?;
210    let mut buf = read_exact_upto(&mut f, MIN_PROBE_SIZE)?;
211
212    // 太短,不可能是 omy 文件
213    if buf.len() < FIXED_HEADER_LEN {
214        return Ok(buf);
215    }
216    // magic 不符时直接返回,省掉后续读取
217    if buf.get(..MAGIC_FILE.len()) != Some(&MAGIC_FILE[..]) {
218        return Ok(buf);
219    }
220
221    // 从固定头取 header_len。
222    // 这里直接按偏移读而不调用 FixedHeader::parse,是因为 parse 会做完整校验、
223    // 需要 slot 区之后的数据;此刻我们只想知道「还要再读多少」。
224    let Some(raw) = buf.get(HEADER_LEN_FIELD_OFFSET..HEADER_LEN_FIELD_OFFSET + 4) else {
225        return Ok(buf);
226    };
227    let mut le = [0u8; 4];
228    le.copy_from_slice(raw);
229    let header_len = u32::from_le_bytes(le) as usize;
230
231    // 上界保护:不可信输入不得驱动大额分配
232    if header_len > parse_limits::MAX_HEADER_LEN as usize || header_len <= buf.len() {
233        return Ok(buf);
234    }
235
236    // 补读 TLV 区与 MAC
237    let extra = header_len.saturating_sub(buf.len());
238    let mut rest = read_exact_upto(&mut f, extra)?;
239    buf.append(&mut rest);
240    Ok(buf)
241}
242
243/// 对单个文件尝试识别与解锁。
244///
245/// 返回 `Ok(None)` 表示这不是 .omy 文件(最常见情况,应静默跳过)。
246///
247/// # Errors
248///
249/// 文件可识别为 .omy 但头部损坏、版本不支持时返回相应错误。
250pub fn probe_file(path: &Path, session: &SessionKeys) -> Result<Option<ScanHit>> {
251    let prefix = match read_header_area(path) {
252        Ok(p) => p,
253        Err(e) => {
254            return Err(Error::Io(std::io::Error::new(
255                e.kind(),
256                format!("{}: {e}", path.display()),
257            )));
258        }
259    };
260
261    // 最快的排除路径:magic 不匹配直接走
262    if prefix.len() < MAGIC_FILE.len() || prefix.get(..MAGIC_FILE.len()) != Some(&MAGIC_FILE[..]) {
263        return Ok(None);
264    }
265
266    let header = FixedHeader::parse(&prefix)?;
267    let file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
268
269    // 用会话中适用于本文件 vault_salt 的凭据逐一尝试。
270    // 注意:这里只取 scannable 的(vault/device),portable/recovery 成本太高。
271    let creds = session.scannable_for(&header.vault_salt);
272    let slot_area =
273        prefix.get(SLOT_AREA_OFFSET..TLV_AREA_OFFSET).ok_or(Error::Truncated {
274            context: "slot area",
275            need: TLV_AREA_OFFSET,
276            got: prefix.len(),
277        })?;
278
279    for cred in &creds {
280        let keks = [cred.kek.duplicate()];
281        if let Ok((fek, _, slot_index)) =
282            unwrap_fek(slot_area, &keks, &header.file_uuid, header.cipher_id)
283        {
284            // 解锁成功,顺便取出文件名(在同一段已读的前缀里,无需再读盘)
285            let filename = read_filename(&prefix, &header, &fek);
286            return Ok(Some(ScanHit {
287                path: path.to_path_buf(),
288                file_size,
289                unlock: UnlockOutcome::Unlocked {
290                    credential: cred.label.clone(),
291                    kind: cred.kind,
292                    slot_index: usize::from(slot_index),
293                    filename,
294                    plaintext_size: header.plaintext_size,
295                },
296                header,
297            }));
298        }
299    }
300
301    Ok(Some(ScanHit { path: path.to_path_buf(), file_size, header, unlock: UnlockOutcome::Locked }))
302}
303
304/// 从已读取的前缀中解出文件名。
305///
306/// 失败返回 `None`——文件名不是必需项,取不到不应让整个扫描失败。
307fn read_filename(prefix: &[u8], header: &FixedHeader, fek: &crate::crypto::Fek) -> Option<String> {
308    let tlv_end = (header.header_len as usize).checked_sub(HEADER_MAC_LEN)?;
309    let blob = prefix.get(TLV_AREA_OFFSET..tlv_end)?;
310    let set = TlvSet::parse(blob).ok()?;
311    let plain = set.decrypt_value(types::FILENAME, fek, header.cipher_id).ok()?;
312    crate::tlv::unpad_filename(&plain).ok()
313}
314
315/// 扫描一个目录。
316///
317/// # 性能
318///
319/// 每个文件只读 [`PEEK_SIZE`] 字节。`session` 中的 KEK 已派生完毕,
320/// 扫描过程**不执行 Argon2**。
321///
322/// # Errors
323///
324/// 目录不存在或不可读时返回 [`Error::Io`]。单个文件的错误会被计入
325/// [`ScanStats`] 并跳过,不中断整体扫描——一个坏文件不该让整次扫描失败。
326pub fn scan_dir(root: &Path, session: &SessionKeys, opts: &ScanOptions) -> Result<ScanResult> {
327    if !root.is_dir() {
328        return Err(Error::Io(std::io::Error::new(
329            std::io::ErrorKind::NotADirectory,
330            format!("not a directory: {}", root.display()),
331        )));
332    }
333    let mut out = ScanResult::default();
334    let mut stack = vec![(root.to_path_buf(), 0usize)];
335
336    while let Some((dir, depth)) = stack.pop() {
337        if out.stats.files_examined >= opts.max_files {
338            break;
339        }
340        let rd = match std::fs::read_dir(&dir) {
341            Ok(rd) => rd,
342            Err(_) => {
343                // 无权限的目录直接跳过,不中断扫描
344                out.stats.io_errors = out.stats.io_errors.saturating_add(1);
345                continue;
346            }
347        };
348
349        for entry in rd.flatten() {
350            if out.stats.files_examined >= opts.max_files {
351                break;
352            }
353            let path = entry.path();
354
355            // 用 symlink_metadata 判断类型,避免跟随链接
356            let Ok(meta) = std::fs::symlink_metadata(&path) else {
357                out.stats.io_errors = out.stats.io_errors.saturating_add(1);
358                continue;
359            };
360
361            if meta.is_symlink() && !opts.follow_symlinks {
362                continue;
363            }
364
365            let is_dir = if meta.is_symlink() {
366                path.is_dir() // 已确认允许跟随
367            } else {
368                meta.is_dir()
369            };
370
371            if is_dir {
372                if opts.recursive && depth < opts.max_depth {
373                    stack.push((path, depth.saturating_add(1)));
374                }
375                continue;
376            }
377
378            // 扩展名过滤(可选)
379            if let Some(want) = opts.extension_filter.as_deref() {
380                let matches = path
381                    .extension()
382                    .and_then(|e| e.to_str())
383                    .is_some_and(|e| e.eq_ignore_ascii_case(want));
384                if !matches {
385                    continue;
386                }
387            }
388
389            // 小于最小文件大小的直接跳过,省一次 open
390            if meta.len() < MIN_FILE_SIZE as u64 {
391                continue;
392            }
393
394            out.stats.files_examined = out.stats.files_examined.saturating_add(1);
395
396            match probe_file(&path, session) {
397                Ok(Some(hit)) => {
398                    out.stats.omy_found = out.stats.omy_found.saturating_add(1);
399                    if hit.unlock.is_unlocked() {
400                        out.stats.unlocked = out.stats.unlocked.saturating_add(1);
401                    }
402                    out.hits.push(hit);
403                }
404                Ok(None) => {} // 不是 omy 文件,正常跳过
405                Err(Error::Io { .. }) => {
406                    out.stats.io_errors = out.stats.io_errors.saturating_add(1);
407                }
408                Err(_) => {
409                    // magic 对但头部有问题——可能是损坏或更新版本
410                    out.stats.malformed = out.stats.malformed.saturating_add(1);
411                }
412            }
413        }
414    }
415
416    Ok(out)
417}
418
419/// 扫描多个目录。
420///
421/// # Errors
422///
423/// 所有目录都不可访问时返回最后一个错误;只要有一个成功即返回合并结果。
424pub fn scan_dirs(
425    roots: &[PathBuf],
426    session: &SessionKeys,
427    opts: &ScanOptions,
428) -> Result<ScanResult> {
429    let mut merged = ScanResult::default();
430    let mut last_err = None;
431    let mut any_ok = false;
432
433    for root in roots {
434        match scan_dir(root, session, opts) {
435            Ok(r) => {
436                any_ok = true;
437                merged.hits.extend(r.hits);
438                let s = &mut merged.stats;
439                s.files_examined = s.files_examined.saturating_add(r.stats.files_examined);
440                s.omy_found = s.omy_found.saturating_add(r.stats.omy_found);
441                s.unlocked = s.unlocked.saturating_add(r.stats.unlocked);
442                s.io_errors = s.io_errors.saturating_add(r.stats.io_errors);
443                s.malformed = s.malformed.saturating_add(r.stats.malformed);
444            }
445            Err(e) => last_err = Some(e),
446        }
447    }
448
449    if any_ok {
450        Ok(merged)
451    } else {
452        Err(last_err.unwrap_or_else(|| {
453            Error::Io(std::io::Error::other("no directories scanned"))
454        }))
455    }
456}