Skip to main content

omy_core/
tree.rs

1//! 树形模式:逐文件加密并保持目录结构(文档 05 §3,决策 D-05)。
2//!
3//! # 与容器模式的关系
4//!
5//! 这是文档 05 §1 里两种文件夹模式中的**模式 B**。容器模式
6//! ([`crate::pack`])把整棵树塞进一个 `.omy`,树形模式在磁盘上保留结构:
7//!
8//! ```text
9//! 明文               容器模式              树形模式
10//! 工作资料/     →   a4332ff0.omy    →    MFZWIZLTOQ.omy/
11//! ├── 文档/          (单个文件)          ├── NBSWY3DPFQ.omy/
12//! │   └── 报告.docx                       │   └── ONXW2ZLUNBSGK.omy
13//! └── 照片/                               └── PB2GK3TUN5XA.omy/
14//! ```
15//!
16//! 取舍不是「哪个更好」,两个方向都有人需要(所以 D-05 决定都做):
17//!
18//! | | 容器 | 树形 |
19//! |---|---|---|
20//! | 目录结构 | 完全隐藏 | **泄露文件数、大小、树深度** |
21//! | 改一个文件 | 重写整包 | 只重写那一个 |
22//! | 云盘同步 | 每次全量 | 增量 |
23//! | 单独分享一个文件 | 不行 | 可以 |
24//! | 损坏影响面 | 头部坏了全丢 | 只影响那一个 |
25//!
26//! # 泄露的元数据必须告知用户(N6)
27//!
28//! 3.2 GB、1247 个文件的目录用树形模式加密后,旁观者能数出文件个数、看出
29//! 每个文件的大致尺寸、看出目录有几层。看不到名字和内容,但**看得到形状**。
30//! 「照片/2023/私人/」下躺着 400 个 2–5 MB 的文件,即使名字全是乱码也已经
31//! 说明了不少事。
32//!
33//! 这属于威胁模型 N6 已声明的非目标。UI 必须直说,不能让用户以为选了加密
34//! 就什么都藏住了——[`TreeReport::leak_notice`] 提供统一的告知文案。
35//!
36//! # 为什么遍历逻辑抽出来共用
37//!
38//! 打包时的几个决策——按名字稳定排序、用 `symlink_metadata` 而不是
39//! `metadata`、跳过符号链接与非普通文件、深度上限——两种模式必须完全一致。
40//! 各写一份的话,哪天给容器模式修了「跳过 socket 文件」,树形模式还在按
41//! 老样子处理,同一个目录用两种模式加密会得到不同的内容集合。
42//!
43//! 所以 [`walk_dir`] 由本模块与 [`crate::pack`] 共用,判定规则只有一处。
44
45use crate::crypto::{CipherId, Kek, NONCE_LEN};
46use crate::dirname::{DIRNAME_SIDECAR, EncryptedDirname, looks_encrypted};
47use crate::error::{Error, Result};
48use crate::file::{EncryptOptions, RandomMaterial};
49use crate::pack::{ProgressFn, SkipReason, SkippedEntry, walk_dir};
50use std::path::{Path, PathBuf};
51
52/// 树形模式加密的结果。
53#[derive(Debug, Default)]
54pub struct TreeReport {
55    /// 加密后的根目录(磁盘路径,名字已加密)。
56    pub root: PathBuf,
57    /// 成功加密的文件数。
58    pub files: usize,
59    /// 创建的目录数。
60    pub dirs: usize,
61    /// 需要边车文件才能还原的超长目录名数量。
62    ///
63    /// 单独报出来是因为它有运维含义:这些目录一旦丢了 `.omy-name`
64    /// 就再也认不出原名,用户可能想缩短目录层级重来一次。
65    pub long_names: usize,
66    /// 被跳过的条目,需如实展示。
67    pub skipped: Vec<SkippedEntry>,
68}
69
70impl TreeReport {
71    /// 树形模式泄露元数据的统一告知文案(N6)。
72    ///
73    /// 放在 core 而不是各前端:CLI 与 GUI 说法不一致会让人以为是两种不同的
74    /// 行为。措辞刻意具体——「泄露元数据」这种说法用户无法据此判断风险,
75    /// 「别人能数出你有多少文件」才能。
76    #[must_use]
77    pub const fn leak_notice() -> &'static str {
78        "逐个加密模式下,别人能看到你有多少文件、每个文件多大、目录有几层,但看不到文件名和内容。"
79    }
80}
81
82/// 逐个文件的媒体附加信息回调。
83///
84/// 入参是该文件的**明文字节**与文件名,返回 `(缩略图, 媒体元信息, moov 副本)`,
85/// 不需要就返回三个 `None`。
86///
87/// # 为什么是回调而不是 core 自己做
88///
89/// 生成缩略图要靠 `omy-media`,而它是 **LGPL**,core 是主许可——
90/// core 依赖它会把许可传染到整个下游。回调把这件事留给调用方:
91/// CLI 和 GUI 本来就已经链接了 omy-media。
92///
93/// 回调在加密循环里**同步调用**,每个文件一次。实现里会起 ffprobe 子进程,
94/// 因此大目录会明显变慢;调用方若不需要就传 `None`,那样一个子进程都不会起。
95pub type MediaExtrasFn<'a> =
96    &'a mut dyn FnMut(&[u8], &str) -> (Option<Vec<u8>>, Option<Vec<u8>>, Option<Vec<u8>>);
97
98/// 加密一棵目录树。
99///
100/// `out_parent` 是加密后根目录的**父目录**;根目录名本身会被加密。
101///
102/// # 内存
103///
104/// 与容器模式不同,这里**逐个文件**读取和加密,内存占用是单个最大文件的
105/// 大小,而不是整棵树。这是树形模式的一个实际优势:容器模式打包 50 GB
106/// 目录需要 50 GB 内存,树形模式不需要。
107///
108/// # Errors
109///
110/// - 根路径不是目录
111/// - 目录名加密失败
112/// - 写盘失败(磁盘满、权限不足)
113///
114/// 单个文件读不出来**不算错误**,记进 `skipped` 继续——与容器模式一致。
115pub fn encrypt_tree(
116    root: &Path,
117    out_parent: &Path,
118    keks: &[Kek],
119    vault_salt: &[u8; 16],
120    opts: &EncryptOptions,
121    progress: Option<ProgressFn<'_>>,
122) -> Result<TreeReport> {
123    encrypt_tree_with_media(root, out_parent, keks, vault_salt, opts, progress, None)
124}
125
126/// 与 [`encrypt_tree`] 相同,但为每个文件生成媒体附加信息。
127///
128/// 不带 `media` 的版本产出的树**没有缩略图**:列表页里这样的相册每一项都是
129/// 通用图标。回调语义见 [`MediaExtrasFn`]。
130///
131/// # Errors
132///
133/// 与 [`encrypt_tree`] 一致。
134pub fn encrypt_tree_with_media(
135    root: &Path,
136    out_parent: &Path,
137    keks: &[Kek],
138    vault_salt: &[u8; 16],
139    opts: &EncryptOptions,
140    mut progress: Option<ProgressFn<'_>>,
141    mut media: Option<MediaExtrasFn<'_>>,
142) -> Result<TreeReport> {
143    if !root.is_dir() {
144        return Err(Error::Io(std::io::Error::new(
145            std::io::ErrorKind::NotADirectory,
146            "encrypt_tree expects a directory",
147        )));
148    }
149    if keks.is_empty() {
150        return Err(Error::TooManySlots { got: 0, max: 8 });
151    }
152
153    // 整棵树共用一个随机目录密钥,它被每把 KEK 各包一份写进边车。
154    //
155    // 不再用 DirnameKey::derive(keks[0]):那样目录名只有第一把钥匙能解开,
156    // 后面的密码能打开每个文件却看不见目录结构,decrypt 报的是
157    // 「content hash mismatch」——用户看到的是「文件损坏」。多密码与恢复码
158    // 在树上用不了,根因都在这里
159    let dk = crate::dirsidecar::DirKey::generate();
160
161    let root_name = root
162        .file_name()
163        .map(|s| s.to_string_lossy().into_owned())
164        .unwrap_or_else(|| String::from("folder"));
165
166    let mut rep = TreeReport::default();
167
168    // 根目录名也要加密,否则最外层直接暴露「工作资料」
169    let enc_root =
170        crate::dirname::encrypt_dirname_with(&root_name, &dk, &random_nonce(), opts.cipher)?;
171    let out_root = out_parent.join(&enc_root.disk_name);
172    create_encrypted_dir(&out_root, &enc_root, &mut rep)?;
173    // 槽位目录只在加密时确定一次,整棵树共用。每个密文目录各写一份,
174    // 与 DK 包裹同理——任一子目录被单独拷走仍要能自洽
175    let tree_slot_dir = match &opts.slot_directory {
176        Some(raw) => Some(crate::slotdir::SlotDirectory::decode(raw)?),
177        None => None,
178    };
179    write_keys_sidecar(&out_root, keks, &dk, vault_salt, opts.cipher, tree_slot_dir.as_ref())?;
180    rep.root = out_root.clone();
181
182    // 明文相对路径 → 密文磁盘路径。目录必须先建好,子项才知道该往哪写;
183    // 而 walk_dir 保证父目录先于子项被访问,所以查表时一定已经有值
184    let mut mapping: std::collections::HashMap<Vec<String>, PathBuf> =
185        std::collections::HashMap::new();
186    mapping.insert(Vec::new(), out_root.clone());
187
188    let mut walk_skipped = Vec::new();
189    walk_dir(root, &mut walk_skipped, &mut |item| {
190        let Some(parent_rel) = item.comps.split_last().map(|(_, p)| p.to_vec()) else {
191            return Ok(());
192        };
193        let Some(parent_out) = mapping.get(&parent_rel).cloned() else {
194            // 父目录不在表里说明它被跳过了(读不出来 / 太深),
195            // 子项自然也进不去。如实记录而不是静默丢弃
196            rep.skipped.push(SkippedEntry {
197                path: item.comps.join("/"),
198                reason: SkipReason::Unreadable,
199            });
200            return Ok(());
201        };
202        let Some(name) = item.comps.last() else {
203            return Ok(());
204        };
205
206        if item.link_target.is_some() {
207            // 树形模式存不了链接:每个条目落成独立 `.omy` 文件,没有容器
208            // 索引那样的地方记录链接目标。容器模式能存(见 `pack_folder`)。
209            //
210            // 必须显式跳过,不能落到下面的分支去:那里会 `std::fs::read`,
211            // 而它跟随链接,于是**目标的内容**会被加密成一个普通文件,还原
212            // 出来内容看着对、类型却错了——静默的错误结果。
213            rep.skipped.push(SkippedEntry {
214                path: item.comps.join("/"),
215                reason: SkipReason::Symlink,
216            });
217            return Ok(());
218        }
219
220        if item.is_dir {
221            // 子目录共用同一个 DK,但**每个目录都要写一份边车**。
222            //
223            // 只在根上放一份会更省,但那样任何一个子目录被单独拷出来就
224            // 再也解不开名字了——而「把某个子目录单独发给别人」是完全
225            // 正常的用法。边车与目录同进退,是它放在目录内部的全部意义
226            let enc =
227                crate::dirname::encrypt_dirname_with(name, &dk, &random_nonce(), opts.cipher)?;
228            let dir_out = parent_out.join(&enc.disk_name);
229            create_encrypted_dir(&dir_out, &enc, &mut rep)?;
230            write_keys_sidecar(
231                &dir_out,
232                keks,
233                &dk,
234                vault_salt,
235                opts.cipher,
236                tree_slot_dir.as_ref(),
237            )?;
238            mapping.insert(item.comps.clone(), dir_out);
239        } else {
240            let Ok(data) = std::fs::read(&item.path) else {
241                rep.skipped.push(SkippedEntry {
242                    path: item.comps.join("/"),
243                    reason: SkipReason::Unreadable,
244                });
245                return Ok(());
246            };
247            let size = data.len() as u64;
248
249            // 每个文件独立加密:独立 FEK、独立 file_uuid。
250            // 文件名写进它自己的 TLV,磁盘名只保留密文哈希——
251            // 文件名不需要 base32 那套(有 TLV 可用),用哈希更短
252            let mut fopts = opts.clone();
253            fopts.filename = Some(name.clone());
254            // 媒体附加信息逐文件生成。
255            //
256            // 必须在这里覆盖,不能只依赖 `opts`:调用方传进来的那份是整棵树
257            // 共用的,里面的 thumbnail 之类要么为空,要么是上一个文件的——
258            // 后者会把 A 的缩略图写进 B。
259            if let Some(cb) = media.as_deref_mut() {
260                let (thumb, meta, moov) = cb(&data, name);
261                fopts.thumbnail = thumb;
262                fopts.media_meta = meta;
263                fopts.moov_cache = moov;
264            }
265            let rnd = RandomMaterial::generate();
266            let enc = crate::file::encrypt(&data, keks, vault_salt, &fopts, &rnd)?;
267
268            // 磁盘名取 file_uuid 的十六进制:唯一、定长、不泄露原名长度。
269            // 不用文件名的哈希:同名文件会得到同名密文,泄露「这两个文件同名」
270            let disk_name = format!("{}.omy", hex16(&rnd.file_uuid));
271            crate::fsatomic::write_atomic(&parent_out.join(&disk_name), &enc.bytes)?;
272
273            rep.files = rep.files.saturating_add(1);
274            if let Some(cb) = progress.as_deref_mut() {
275                cb(&item.comps.join("/"), size);
276            }
277        }
278        Ok(())
279    })?;
280
281    rep.skipped.extend(walk_skipped);
282    Ok(rep)
283}
284
285/// 往一个密文目录里写钥匙包裹边车。
286///
287/// 每个密文目录都有一份,内容相同(同一棵树共用一个 DK)。冗余是刻意的,
288/// 换来的是「任何一个子目录被单独拷走仍能解开名字」。
289fn write_keys_sidecar(
290    dir: &Path,
291    keks: &[Kek],
292    dk: &crate::dirsidecar::DirKey,
293    vault_salt: &[u8; 16],
294    cipher: CipherId,
295    slot_dir: Option<&crate::slotdir::SlotDirectory>,
296) -> Result<()> {
297    // 可管理模式:边车里多带一份加密的槽位目录。与单文件同一个判据——
298    // 「用了哪种模式」是产品事实,写在明文 flags 里;目录内容是用户
299    // 秘密,用 DK 加密
300    let blob = match slot_dir {
301        Some(sd) => crate::dirsidecar::build_sidecar_managed(
302            keks,
303            sd,
304            dk,
305            vault_salt,
306            &random_nonce(),
307            cipher,
308        )?,
309        None => crate::dirsidecar::build_sidecar(keks, dk, vault_salt, &random_nonce(), cipher)?,
310    };
311    crate::fsatomic::write_atomic(&dir.join(crate::dirsidecar::KEYS_SIDECAR), &blob)?;
312    Ok(())
313}
314
315/// 重写边车,保留认不出来的旧包裹。
316///
317/// 与文件槽位的 `OtherSlots::Carry` 对齐:改密码不该让挂在这棵树上的
318/// 恢复码失效。两处语义不一致的话,会出现「恢复码开得了每个文件、
319/// 却打不开目录名」这种半残状态。
320fn rewrite_keys_sidecar(
321    dir: &Path,
322    keep: &[Kek],
323    dk: &crate::dirsidecar::DirKey,
324    vault_salt: &[u8; 16],
325    cipher: CipherId,
326    others: crate::keyslot::OtherSlots,
327) -> Result<()> {
328    let path = dir.join(crate::dirsidecar::KEYS_SIDECAR);
329    // 无论 Carry 还是 Discard 都要读一次:**判断模式需要它**。
330    // 只在 Carry 时读的话,remove 会让整棵树静默退回可否认模式——
331    // 用户只是想踢掉一个人,却连带丢了「能看清有几把钥匙」这个能力
332    let raw = std::fs::read(&path).ok();
333    let was_managed = raw.as_deref().is_some_and(crate::dirsidecar::is_managed);
334    // Discard 时不搬旧包裹:remove 的语义就是不要旧的那些
335    let old = match others {
336        crate::keyslot::OtherSlots::Carry => raw,
337        crate::keyslot::OtherSlots::Discard => None,
338    };
339    // 原边车是可管理模式的话,新的也必须是——否则换一次密码整棵树就
340    // 静默退回可否认模式,用户收不到任何提示,只会在下次想精确删某个
341    // 协作者时发现能力没了。单文件的 reencrypt 犯过同样的错
342    let blob = if was_managed {
343        // 目录按 keep 重建,不能搬旧的:rekey 之后能解开边车的只有 keep
344        // 里那些,搬旧目录会让它谎称「恢复码还在」而其实已经废了
345        let mut sd = crate::slotdir::SlotDirectory::new();
346        for i in 0..keep.len().min(crate::header::SLOT_COUNT) {
347            sd.set(i, crate::slotdir::SlotEntry::of(crate::slotdir::SlotKind::Vault))?;
348        }
349        crate::dirsidecar::build_sidecar_managed(
350            keep,
351            &sd,
352            dk,
353            vault_salt,
354            &random_nonce(),
355            cipher,
356        )?
357    } else {
358        crate::dirsidecar::rebuild_sidecar_carrying(
359            old.as_deref(),
360            keep,
361            dk,
362            vault_salt,
363            &random_nonce(),
364            cipher,
365        )?
366    };
367    crate::fsatomic::write_atomic(&path, &blob)?;
368    Ok(())
369}
370
371/// 从一个密文目录读出 DK。
372///
373/// # Errors
374///
375/// - 边车文件不存在或读不出来
376/// - [`Error::NoMatchingSlot`]:手头的钥匙都打不开它
377fn read_keys_sidecar(
378    dir: &Path,
379    keks: &[Kek],
380    vault_salt: &[u8; 16],
381    cipher: CipherId,
382) -> Result<crate::dirsidecar::DirKey> {
383    let blob = std::fs::read(dir.join(crate::dirsidecar::KEYS_SIDECAR))?;
384    let (dk, _nonce) = crate::dirsidecar::open_sidecar(&blob, keks, vault_salt, cipher)?;
385    Ok(dk)
386}
387
388/// 一棵树的槽位清单。
389#[derive(Debug)]
390pub struct TreeSlots {
391    /// 是不是可管理模式。false 时 `directory` 为 `None`。
392    pub managed: bool,
393    /// 槽位目录。可否认模式下为 `None`——那不是「读取失败」,是设计如此。
394    pub directory: Option<crate::slotdir::SlotDirectory>,
395    /// 手头这把钥匙占的槽位下标。
396    pub current: usize,
397}
398
399/// 读取一棵树的槽位清单。
400///
401/// 与单文件的 `OpenedFile::slot_directory` 对应。需要能解开边车的钥匙:
402/// 槽位目录是用 DK 加密的,而 DK 本身要靠钥匙才能解出来。
403///
404/// # Errors
405///
406/// 边车不存在、读不了、或所有钥匙都解不开时返回错误。
407pub fn tree_slots(
408    root: &Path,
409    keks: &[Kek],
410    vault_salt: &[u8; 16],
411    cipher: CipherId,
412) -> Result<TreeSlots> {
413    let blob = std::fs::read(root.join(crate::dirsidecar::KEYS_SIDECAR))?;
414    let (dk, _nonce, at) =
415        crate::dirsidecar::open_sidecar_at(&blob, keks, vault_salt, cipher)?;
416    if !crate::dirsidecar::is_managed(&blob) {
417        return Ok(TreeSlots { managed: false, directory: None, current: at });
418    }
419    let dir = crate::dirsidecar::read_slot_directory(&blob, &dk, vault_salt, cipher)?;
420    Ok(TreeSlots { managed: true, directory: Some(dir), current: at })
421}
422
423/// 在可管理模式下精确清掉某一个槽位。
424///
425/// 整棵树的每个密文目录都有一份边车,所以要逐个改写——漏掉一个,那个
426/// 子目录被单独拷走时被删的钥匙仍然能解开它的名字。
427///
428/// # Errors
429///
430/// - 不是可管理模式、或手头钥匙解不开边车
431/// - `slot` 正是当前这把钥匙所在的槽(删掉自己之后就没法继续操作了)
432/// - 目标槽本来就是空的
433pub fn tree_remove_slot(
434    root: &Path,
435    keks: &[Kek],
436    slot: usize,
437    vault_salt: &[u8; 16],
438    cipher: CipherId,
439) -> Result<usize> {
440    let info = tree_slots(root, keks, vault_salt, cipher)?;
441    let Some(dir) = info.directory else {
442        return Err(Error::MalformedTlv {
443            tlv_type: 0,
444            reason: "this tree is in deniable mode; slots cannot be removed individually",
445        });
446    };
447    if slot == info.current {
448        return Err(Error::MalformedTlv {
449            tlv_type: 0,
450            reason: "refusing to remove the key currently in use",
451        });
452    }
453    if !dir.get(slot).is_some_and(|e| e.kind.is_occupied()) {
454        return Err(Error::MalformedTlv {
455            tlv_type: 0,
456            reason: "that slot is already empty",
457        });
458    }
459
460    let mut after = dir;
461    after.set(slot, crate::slotdir::SlotEntry::empty())?;
462
463    // 每个密文目录各改一份。逐个而不是只改根:任一子目录被单独拷走时
464    // 也要拒绝那把被删的钥匙,否则「删掉了」只在根目录成立
465    let mut changed = 0usize;
466    let mut stack = vec![root.to_path_buf()];
467    while let Some(cur) = stack.pop() {
468        let path = cur.join(crate::dirsidecar::KEYS_SIDECAR);
469        if let Ok(blob) = std::fs::read(&path)
470            && crate::dirsidecar::is_managed(&blob)
471            && let Ok((dk, nonce, _)) =
472                crate::dirsidecar::open_sidecar_at(&blob, keks, vault_salt, cipher)
473        {
474            let mut plans: Vec<crate::dirsidecar::SidecarPlan> =
475                (0..crate::dirsidecar::SIDECAR_SLOTS)
476                    .map(|_| crate::dirsidecar::SidecarPlan::Keep)
477                    .collect();
478            if let Some(p) = plans.get_mut(slot) {
479                *p = crate::dirsidecar::SidecarPlan::Clear;
480            }
481            let out = crate::dirsidecar::rewrite_sidecar_managed(
482                &blob, &plans, &after, &dk, vault_salt, &nonce, cipher,
483            )?;
484            crate::fsatomic::write_atomic(&path, &out)?;
485            changed = changed.saturating_add(1);
486        }
487        let Ok(rd) = std::fs::read_dir(&cur) else { continue };
488        for ent in rd.flatten() {
489            let sub = ent.path();
490            if sub.is_dir() {
491                stack.push(sub);
492            }
493        }
494    }
495    Ok(changed)
496}
497
498/// 建目录并在需要时写入边车文件。
499fn create_encrypted_dir(path: &Path, enc: &EncryptedDirname, rep: &mut TreeReport) -> Result<()> {
500    std::fs::create_dir_all(path)?;
501    rep.dirs = rep.dirs.saturating_add(1);
502    // 超长名的完整密文必须落盘,否则该目录名永远无法还原。
503    // 这一步失败要报错而不是忽略——忽略的话用户拿到一个解不开名字的目录
504    if let Some(sc) = &enc.sidecar {
505        crate::fsatomic::write_atomic(&path.join(DIRNAME_SIDECAR), sc)?;
506        rep.long_names = rep.long_names.saturating_add(1);
507    }
508    Ok(())
509}
510
511/// 还原一棵加密目录树。
512///
513/// `enc_root` 是密文根目录,`out_parent` 是还原目标的父目录。
514///
515/// # Errors
516///
517/// - `enc_root` 不是目录,或名字不像密文目录名
518/// - 目录名解密失败(密码不对)
519/// - 写盘失败
520pub fn decrypt_tree(
521    enc_root: &Path,
522    out_parent: &Path,
523    keks: &[Kek],
524    vault_salt: &[u8; 16],
525    cipher: CipherId,
526    mut progress: Option<ProgressFn<'_>>,
527) -> Result<TreeReport> {
528    if !enc_root.is_dir() {
529        return Err(Error::Io(std::io::Error::new(
530            std::io::ErrorKind::NotADirectory,
531            "decrypt_tree expects a directory",
532        )));
533    }
534    // DK 从边车里取。任一把钥匙都能解开它——这正是改造的目的:
535    // 不再只认 keks[0]
536    let dk = read_keys_sidecar(enc_root, keks, vault_salt, cipher)?;
537
538    let enc_name = enc_root
539        .file_name()
540        .map(|s| s.to_string_lossy().into_owned())
541        .unwrap_or_default();
542    let plain_root = decrypt_dir_name_at(enc_root, &enc_name, &dk, cipher)?;
543
544    let out_root = out_parent.join(&plain_root);
545    std::fs::create_dir_all(&out_root)?;
546
547    let mut rep = TreeReport { root: out_root.clone(), dirs: 1, ..TreeReport::default() };
548    decrypt_into(enc_root, &out_root, keks, &dk, cipher, &mut rep, &mut progress)?;
549    Ok(rep)
550}
551
552/// 递归还原一层。
553///
554/// 用递归而不是显式栈:还原时的深度受**密文目录树**限制,而那棵树是我们
555/// 自己按 `MAX_DEPTH` 建出来的,不会超。打包侧面对的是用户给的任意目录,
556/// 才需要显式栈防爆栈。
557fn decrypt_into(
558    enc_dir: &Path,
559    out_dir: &Path,
560    keks: &[Kek],
561    dk: &crate::dirsidecar::DirKey,
562    cipher: CipherId,
563    rep: &mut TreeReport,
564    progress: &mut Option<ProgressFn<'_>>,
565) -> Result<()> {
566    let rd = std::fs::read_dir(enc_dir)?;
567    let mut items: Vec<_> = rd.flatten().collect();
568    items.sort_by_key(std::fs::DirEntry::file_name);
569
570    for item in items {
571        let name = item.file_name().to_string_lossy().into_owned();
572        // 两个边车都是我们自己的记账,不属于用户数据。
573        // 漏掉任何一个,还原出来的目录里会多出一个乱码文件
574        if name == DIRNAME_SIDECAR || name == crate::dirsidecar::KEYS_SIDECAR {
575            continue;
576        }
577        let path = item.path();
578        let Ok(md) = path.symlink_metadata() else {
579            rep.skipped.push(SkippedEntry {
580                path: name,
581                reason: SkipReason::Unreadable,
582            });
583            continue;
584        };
585
586        if md.is_dir() {
587            let plain = decrypt_dir_name_at(&path, &name, dk, cipher)?;
588            let sub_out = out_dir.join(&plain);
589            std::fs::create_dir_all(&sub_out)?;
590            rep.dirs = rep.dirs.saturating_add(1);
591            decrypt_into(&path, &sub_out, keks, dk, cipher, rep, progress)?;
592        } else if md.is_file() {
593            let Ok(bytes) = std::fs::read(&path) else {
594                rep.skipped.push(SkippedEntry {
595                    path: name,
596                    reason: SkipReason::Unreadable,
597                });
598                continue;
599            };
600            // 不是 omy 文件就跳过:用户可能自己往密文目录里放了东西,
601            // 拿它去解密只会得到一堆认证失败的噪音
602            if !crate::file::is_omy_file(&bytes) {
603                rep.skipped.push(SkippedEntry {
604                    path: name,
605                    reason: SkipReason::NotRegular,
606                });
607                continue;
608            }
609            let opened = crate::file::open(&bytes, keks)?;
610            let plain_name = opened.filename()?;
611            let data = opened.decrypt_all(&bytes)?;
612            let size = data.len() as u64;
613
614            // 文件名来自密文内部,可能含路径分隔符或 `..`——那是攻击手法。
615            // 必须过 sanitize,与容器模式共用同一套规则
616            let safe = crate::unpack::sanitize_filename(&plain_name);
617            crate::fsatomic::write_atomic(&out_dir.join(&safe), &data)?;
618            rep.files = rep.files.saturating_add(1);
619            if let Some(cb) = progress.as_deref_mut() {
620                cb(&safe, size);
621            }
622        }
623    }
624    Ok(())
625}
626
627/// 解密一个目录的名字,必要时读它内部的长名边车文件。
628fn decrypt_dir_name_at(
629    dir: &Path,
630    disk_name: &str,
631    dk: &crate::dirsidecar::DirKey,
632    cipher: CipherId,
633) -> Result<String> {
634    if !looks_encrypted(disk_name) {
635        return Err(Error::MalformedTlv {
636            tlv_type: 0,
637            reason: "directory name is not an encrypted name",
638        });
639    }
640    // 只有截断过的名字才需要长名边车。无条件去读会让每个目录多一次
641    // 失败的 open 系统调用,大树上是可观的开销。
642    //
643    // 注意这个 .omy-name 与钥匙边车 .omy-keys 是两回事:前者存目录名密文,
644    // 后者存打开目录名的钥匙
645    let long_name = if disk_name.contains('~') {
646        Some(std::fs::read(dir.join(DIRNAME_SIDECAR))?)
647    } else {
648        None
649    };
650    crate::dirname::decrypt_dirname_with(disk_name, long_name.as_deref(), dk, cipher)
651}
652
653/// 生成随机 nonce。
654///
655/// 走 [`crate::util::fill_random`] 而不是自己碰 `rand_core`:熵源不可用时
656/// 的处置策略(快速失败而非降级)只该有一处决定。
657fn random_nonce() -> [u8; NONCE_LEN] {
658    let mut n = [0u8; NONCE_LEN];
659    crate::util::fill_random(&mut n);
660    n
661}
662
663/// 在密文树里找任意一个加密文件,用来取 vault 参数。
664///
665/// # 为什么需要它
666///
667/// 单个 `.omy` 文件的头部自带 `vault_salt` 与 KDF 参数,而**目录没有头部**。
668/// 可是解目录名又必须先有 KEK,KEK 又要靠 salt 和参数才能派生——鸡生蛋。
669/// 解法是从树里任取一个文件读它的头部:同一个 vault 内这些参数本就一致,
670/// 取哪个都一样。
671///
672/// 三个入口都要用(CLI 解目录、GUI 加密后自动解锁、GUI 浏览解目录名),
673/// 所以放在 core 里只留一份。新增判定(比如将来要跳过别的边车文件)
674/// 只需改这里。
675///
676/// 深度优先向下找:根目录下可能只有子目录而没有直接的文件。
677/// # 优先选头部能解析的
678///
679/// 树里可能混进损坏的、或压根不是本格式的 `.omy`。拿它当样本会让整个操作
680/// 报「文件已损坏」——而那棵树其余部分完好,本该点名那一个文件、照改剩下的。
681/// 更糟的是这个结果**随目录枚举顺序变化**:同一棵树、同一条命令,两次运行
682/// 可能给出完全不同的错误。实测遇到过,是最难查的一类不确定性。
683///
684/// 全都解析不过时退回第一个 `.omy`,让调用方如实报「已损坏」;压根没有
685/// `.omy` 才返回 `None`,报「不是密文树」。这三种处境对用户是三件不同的
686/// 事——「坏了」和「不是」指向完全不同的排查方向,不该压成同一个结果。
687#[must_use]
688pub fn find_any_file(root: &Path) -> Option<PathBuf> {
689    let mut damaged = None;
690    find_sample(root, &mut damaged).or(damaged)
691}
692
693/// 深度优先找一个头部能解析的密文文件,顺带记下第一个解析不过的。
694fn find_sample(dir: &Path, damaged: &mut Option<PathBuf>) -> Option<PathBuf> {
695    // 读不了这一层不代表整棵树没救:继续往别的分支找
696    let Ok(rd) = std::fs::read_dir(dir) else {
697        return None;
698    };
699    let mut dirs = Vec::new();
700    for e in rd.flatten() {
701        let p = e.path();
702        if p.is_dir() {
703            dirs.push(p);
704            continue;
705        }
706        // 边车文件叫 `.omy-name`,扩展名不是 `omy`,所以不会被误取。
707        // 但它确实以 `.omy` 开头,靠「名字含 .omy」判断就会中招
708        if !p.extension().is_some_and(|x| x == "omy") {
709            continue;
710        }
711        if header_parses(&p) {
712            return Some(p);
713        }
714        if damaged.is_none() {
715            *damaged = Some(p);
716        }
717    }
718    dirs.into_iter().find_map(|d| find_sample(&d, damaged))
719}
720
721/// 这个文件的头部能不能解析。
722///
723/// 只读前面一小段:判断是不是本格式不需要整份文件,而样本文件可能有几个 GB。
724fn header_parses(path: &Path) -> bool {
725    use std::io::Read as _;
726    let Ok(mut f) = std::fs::File::open(path) else {
727        return false;
728    };
729    let mut buf = vec![0u8; crate::scan::MIN_PROBE_SIZE];
730    let mut got = 0usize;
731    while got < buf.len() {
732        let Some(dst) = buf.get_mut(got..) else { break };
733        match f.read(dst) {
734            Ok(0) => break,
735            Ok(n) => got = got.saturating_add(n),
736            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
737            Err(_) => return false,
738        }
739    }
740    buf.truncate(got);
741    crate::file::peek_header(&buf).is_ok()
742}
743
744/// 批量改密码的结果。
745#[derive(Debug, Default)]
746pub struct RekeyReport {
747    /// 成功改写的文件数。
748    pub changed: usize,
749    /// 重新加密了名字的目录数(含根目录)。
750    pub dirs_renamed: usize,
751    /// 改密码后这棵树的根路径。
752    ///
753    /// **根目录名会变**:它由 KEK 派生的密钥加密,换密码就得换名字。调用方
754    /// 必须用这个值,原来手里的路径已经失效——不返回的话,GUI 刷新列表时
755    /// 会指向一个不存在的目录,表现为「改完密码文件夹不见了」。
756    pub root: PathBuf,
757    /// 失败的:`(路径, 原因)`。
758    ///
759    /// 逐个记而不是只留一个总的错误码:一棵树里可能只有个别文件出问题
760    /// (被别的程序占用、权限不对、单个文件损坏),用户需要知道**是哪些**
761    /// 才能去处理,而其余文件已经改好了。
762    pub failed: Vec<(PathBuf, String)>,
763    /// 是否换掉了文件密钥(轮换)。
764    pub payload_rewritten: bool,
765    /// 轮换时重写的明文总字节数。非轮换恒为 0。
766    pub bytes_rewritten: u64,
767}
768
769impl RekeyReport {
770    /// 是否整棵树都改成功了。
771    #[must_use]
772    pub const fn is_complete(&self) -> bool {
773        self.failed.is_empty()
774    }
775}
776
777/// 给整棵密文树里的每个文件改密码(增删改),载荷一字节不动。
778///
779/// `unlock` 用于解开现有文件,`keep` 是改写后应当能打开这些文件的**全部**
780/// KEK。语义与 [`crate::keyslot::rewrite_slots`] 完全一致,只是作用于整棵树。
781///
782/// 目录名也会一并用新密钥重新加密——只改文件会留下一个两个密码都用不了的
783/// 状态(新密码开得了文件但解不开目录名,旧密码反之)。所以**根目录名会变**,
784/// 新路径在 [`RekeyReport::root`] 里返回。
785///
786/// # 为什么可以只派生一次 KEK
787///
788/// 同一棵树里所有文件共用一个 `vault_salt` 与一组 KDF 参数(`encrypt_tree`
789/// 把同一个 salt 传给每个文件),所以 Argon2 只需跑一次。这很关键:Argon2
790/// 每次几百毫秒,若每个文件都要派生,1000 个文件就是好几分钟。
791///
792/// 而改写本身只动 384 字节 slot 区 + 32 字节 MAC,所以整棵树的成本约等于
793/// 「一次 Argon2 + N 次小文件读写」。
794///
795/// # 部分失败是**如实报告**,不回滚
796///
797/// 这是这个操作最需要想清楚的地方。改到第 50 个文件时断电或出错,前 49 个
798/// 已经是新密码、后面还是旧密码,于是两个密码各能开一半。
799///
800/// 三种可能的处理方式,选第三种:
801///
802/// 1. **回滚**——要先把 N 个文件的原始字节全留一份,一棵大树可能是几十 GB;
803///    而且回滚本身也会中途失败,那时状态更难描述。
804/// 2. **假装成功**——最糟。用户以为密码已经全换了,实际一半没换;等他哪天
805///    用新密码打不开另一半,早已不知道该用哪个旧密码。
806/// 3. **如实报告**(本实现)——返回改了哪些、哪些没改。**旧密码不会失效**,
807///    所以用户手上一定有一个能打开剩余文件的密码,重跑一次即可补齐。
808///
809/// 第三种之所以安全,前提是 `keep` 里通常仍包含用户当前用的密码,或者用户
810/// 知道旧密码。调用方在文案上必须讲清「哪些文件还是旧密码」,否则用户不知道
811/// 该重跑。
812///
813/// 幂等性:对已经改好的文件重跑会失败(`unlock` 里的旧密码打不开它了),
814/// 所以补跑时应当把新旧密码都放进 `unlock`。
815///
816/// # 为什么不先全部改好再统一落盘
817///
818/// 那需要把 N 个文件的新字节全部驻留内存。改写只动头部,但 `rewrite_slots`
819/// 返回的是**完整文件字节**——一棵 50 GB 的树就是 50 GB 内存。
820///
821/// # Errors
822///
823/// - [`Error::MalformedHeader`]:`keep` 为空
824/// - [`Error::MalformedHeader`]:`root` 下找不到任何加密文件
825///
826/// 单个文件的失败不会中断遍历,记进 `failed`。
827pub fn rekey_tree(
828    root: &Path,
829    unlock: &[Kek],
830    keep: &[Kek],
831    vault_salt: &[u8; 16],
832    cipher: CipherId,
833    others: crate::keyslot::OtherSlots,
834) -> Result<RekeyReport> {
835    let params = RekeyParams { vault_salt, cipher, rotate: false, others };
836    rekey_tree_with_progress(root, unlock, keep, params, None)
837}
838
839/// 进度回调:`(当前文件名, 第几个, 共几个, 该文件已处理字节, 该文件总字节)`。
840///
841/// 带上文件内的字节进度而不只是「第 i / n 个」:轮换一个 4 GB 的视频要几十
842/// 秒,只报文件序号的话进度条会长时间停在同一格,用户无从判断是在跑还是
843/// 卡死了。改密码时字节进度恒为 (0, 0),调用方据此只显示文件序号即可。
844pub type RekeyProgressFn<'a> = &'a mut dyn FnMut(&str, usize, usize, u64, u64);
845
846/// 改写整棵树的钥匙时要一起传的那几项。
847///
848/// 打成结构体而不是继续加形参:到第 8 个参数时,调用点已经是一串
849/// &salt, cipher, false, Carry, None —— 读的人根本分不出谁是谁,
850/// 而把 rotate 和 others 的位置写反不会编译报错,只会静默改变语义。
851#[derive(Debug, Clone, Copy)]
852pub struct RekeyParams<'a> {
853    /// vault 级 salt。
854    pub vault_salt: &'a [u8; 16],
855    /// AEAD 算法。
856    pub cipher: CipherId,
857    /// 是否连载荷一起重写(轮换文件密钥)。
858    pub rotate: bool,
859    /// 认不出来的槽位怎么处理:只有 remove 该用 Discard。
860    pub others: crate::keyslot::OtherSlots,
861}
862
863/// 同 [`rekey_tree`],另外支持轮换文件密钥与进度上报。
864///
865/// # `rotate` 的代价
866///
867/// `false` 时只重建每个文件 384 字节的 slot 区,一棵几万文件的树也是秒级。
868/// `true` 时要把每个文件的载荷完整读一遍、用新 FEK 写一遍,耗时与总数据量
869/// 成正比——几十 GB 的树要跑很久。
870///
871/// # 为什么轮换值得做
872///
873/// 改密码只换外层包裹,FEK 没变:**攻击者手里若有旧文件副本,仍能用旧密码
874/// 打开那个副本**。只有换掉 FEK 并重写载荷,才能让旧密码与这份数据彻底
875/// 无关(已经流出去的副本仍是独立密文,本操作对它无能为力——它永远可以
876/// 被旧密码打开,这一点必须让用户知道)。
877///
878/// # 中途失败不会毁数据
879///
880/// 每个文件各自 `write_atomic`,所以任一时刻每个文件要么是完整的旧密文、
881/// 要么是完整的新密文,不存在写坏一半的文件。若同时换了密码,部分失败会
882/// 留下「一半新密码、一半旧密码」——报告里如实列出是哪些,两个密码都提供
883/// 就能重跑补齐。
884pub fn rekey_tree_with_progress(
885    root: &Path,
886    unlock: &[Kek],
887    keep: &[Kek],
888    params: RekeyParams<'_>,
889    mut progress: Option<RekeyProgressFn<'_>>,
890) -> Result<RekeyReport> {
891    let RekeyParams { vault_salt, cipher, rotate, others } = params;
892    if keep.is_empty() {
893        return Err(Error::MalformedHeader {
894            reason: "refusing to leave files with zero key slots; they could never be opened again",
895        });
896    }
897    // 先确认这确实是一棵密文树。不查的话,对一个普通目录执行会得到
898    // 「0 个文件已改写」——看起来像成功,其实什么都没发生
899    if find_any_file(root).is_none() {
900        return Err(Error::MalformedHeader {
901            reason: "no encrypted file found under the given directory",
902        });
903    }
904
905    // DK 从边车里取。它是随机的、与密码无关——这带来一个很大的简化:
906    // **换密码不再需要给目录改名**,只要用新的钥匙集合重写边车即可。
907    //
908    // 改造之前目录名密钥由 keks[0] 派生,换密码就得把整棵树的目录名全部
909    // 重新加密、逐个改名;增量备份要重传全部目录项,用户回到文件管理器
910    // 还会发现文件夹「不见了」。现在这些都不再发生
911    let dk = read_keys_sidecar(root, unlock, vault_salt, cipher)?;
912
913    let mut rep = RekeyReport { payload_rewritten: rotate, ..RekeyReport::default() };
914    // 只有轮换才值得先数一遍:改密码是秒级的,为了显示「第 i / n 个」
915    // 多遍历一次目录反而占了操作本身可观的比例
916    let total = if rotate { count_files(root) } else { 0 };
917    let mut ctx = RekeyCtx {
918        unlock,
919        keep,
920        dk: &dk,
921        vault_salt,
922        cipher,
923        others,
924        rotate,
925        total,
926        done: 0,
927    };
928    rekey_into(root, &mut ctx, &mut progress, &mut rep);
929
930    // 根目录的边车也要重写——rekey_into 只处理子目录
931    if let Err(e) = rewrite_keys_sidecar(root, keep, &dk, vault_salt, cipher, others) {
932        rep.failed.push((root.to_path_buf(), e.to_string()));
933    }
934
935    // 目录名不变,所以 root 原样返回。调用方手里的路径依然有效
936    rep.root = root.to_path_buf();
937    Ok(rep)
938}
939
940/// 只对给定的这些文件改密码,不碰目录名,也不遍历整棵树。
941///
942/// 用于重试上一次 [`rekey_tree_with_progress`] 报告里失败的那些文件。
943///
944/// # 为什么不重走整棵树
945///
946/// 上一次已经改好的文件现在用**新**密码,重走时拿旧密码去开必然失败,于是
947/// 报告里会多出一堆假失败——用户分不清哪些是真问题。而且目录名在第一次
948/// 就已经改完了,再改一次等于把整棵树的名字又换一遍。
949///
950/// # 参数
951///
952/// `files` 是绝对路径(报告里返回的就是绝对路径)。不接受相对路径:
953/// 相对于谁很容易搞错,而调用方手里本来就有绝对路径。
954///
955/// # Errors
956///
957/// `keep` 为空时拒绝:那会留下永远打不开的文件。
958pub fn retry_files(
959    files: &[PathBuf],
960    unlock: &[Kek],
961    keep: &[Kek],
962    rotate: bool,
963    others: crate::keyslot::OtherSlots,
964    mut progress: Option<RekeyProgressFn<'_>>,
965) -> Result<RekeyReport> {
966    if keep.is_empty() {
967        return Err(Error::MalformedHeader {
968            reason: "refusing to leave files with zero key slots; they could never be opened again",
969        });
970    }
971    let mut rep = RekeyReport { payload_rewritten: rotate, ..RekeyReport::default() };
972    let total = files.len();
973    for (i, path) in files.iter().enumerate() {
974        let idx = i.saturating_add(1);
975        let shown =
976            path.file_name().map_or_else(String::new, |n| n.to_string_lossy().into_owned());
977        let mut on_bytes = |done: u64, bytes: u64| {
978            if let Some(cb) = progress.as_deref_mut() {
979                cb(&shown, idx, total, done, bytes);
980            }
981        };
982        match rekey_one(path, unlock, keep, rotate, others, &mut on_bytes) {
983            Ok(written) => {
984                rep.changed = rep.changed.saturating_add(1);
985                rep.bytes_rewritten = rep.bytes_rewritten.saturating_add(written);
986            }
987            Err(err) => rep.failed.push((path.clone(), err)),
988        }
989    }
990    // 没改目录名,所以根路径无从得知也无需变化。留空表示「路径没变」,
991    // 调用方不该拿它去刷新界面
992    Ok(rep)
993}
994
995/// 递归改写一层:先文件,再子目录名。
996///
997/// # 目录名必须一起改
998///
999/// 目录名由 `DirnameKey::derive(keep[0], vault_salt)` 加密,KEK 一换密钥就
1000/// 变了。只改文件的话会留下一个**两个密码都用不了**的状态:新密码开得了
1001/// 文件却解不开目录名(进不去),旧密码解得开目录名却开不了文件。这比
1002/// 「不能改密码」糟得多,所以两者必须在同一个操作里完成。
1003///
1004/// # 为什么自底向上
1005///
1006/// 先递归进子目录处理完,回来再重命名它。反过来做的话,重命名之后手里的
1007/// 路径就失效了,后续遍历会找不到文件。
1008/// 遍历时共享的那一组参数。
1009///
1010/// 打成一个结构体而不是继续加形参:递归函数已经有 8 个参数了,再加轮换
1011/// 开关、文件总数和进度回调会到 11 个,调用点根本读不出谁是谁。
1012struct RekeyCtx<'a> {
1013    unlock: &'a [Kek],
1014    keep: &'a [Kek],
1015    /// 整棵树共用的目录密钥。改写钥匙集合时它保持不变——
1016    /// 这正是「换密码不用改目录名」的原因
1017    dk: &'a crate::dirsidecar::DirKey,
1018    vault_salt: &'a [u8; 16],
1019    cipher: CipherId,
1020    /// 认不出来的槽位怎么处理。
1021    ///
1022    /// 文件槽位与边车必须跟随**同一个**策略。写死 Carry 的后果实测过:
1023    /// remove 承诺作废其它密码,实际第二个密码照样能解开整棵树——
1024    /// 用户以为撤销了访问权,实际没有。
1025    others: crate::keyslot::OtherSlots,
1026    rotate: bool,
1027    /// 待处理的密文文件总数,用于「第 i / n 个」。
1028    total: usize,
1029    /// 已处理到第几个。
1030    done: usize,
1031}
1032
1033fn rekey_into(
1034    dir: &Path,
1035    ctx: &mut RekeyCtx<'_>,
1036    progress: &mut Option<RekeyProgressFn<'_>>,
1037    rep: &mut RekeyReport,
1038) {
1039    let Ok(rd) = std::fs::read_dir(dir) else {
1040        rep.failed.push((dir.to_path_buf(), String::from("read_dir failed")));
1041        return;
1042    };
1043    let mut subdirs = Vec::new();
1044    for e in rd.flatten() {
1045        let p = e.path();
1046        if p.is_dir() {
1047            subdirs.push(p);
1048            continue;
1049        }
1050        // 只处理真正的密文文件。边车 `.omy-name` 的扩展名不是 `omy`,
1051        // 与 find_any_file 用同一条判据
1052        if !p.extension().is_some_and(|x| x == "omy") {
1053            continue;
1054        }
1055        ctx.done = ctx.done.saturating_add(1);
1056        let idx = ctx.done;
1057        let total = ctx.total;
1058        // 磁盘名而不是解出来的原名:这一步还没解密,为了显示一个名字去解
1059        // 整个文件头没有道理,何况用户在列表里看到的本来也是这个
1060        let shown = p.file_name().map_or_else(String::new, |n| n.to_string_lossy().into_owned());
1061        let mut on_bytes = |done: u64, bytes_total: u64| {
1062            if let Some(cb) = progress.as_deref_mut() {
1063                cb(&shown, idx, total, done, bytes_total);
1064            }
1065        };
1066        match rekey_one(&p, ctx.unlock, ctx.keep, ctx.rotate, ctx.others, &mut on_bytes) {
1067            Ok(written) => {
1068                rep.changed = rep.changed.saturating_add(1);
1069                rep.bytes_rewritten = rep.bytes_rewritten.saturating_add(written);
1070            }
1071            Err(err) => rep.failed.push((p, err)),
1072        }
1073    }
1074    // 自底向上:先把子目录内部处理完,再重写它自己的边车。
1075    //
1076    // 不再需要改名——DK 不变,目录名密文照旧有效。只有「谁能解开 DK」
1077    // 这件事变了,而那全部写在边车里
1078    for sub in subdirs {
1079        rekey_into(&sub, ctx, progress, rep);
1080        // 不是我们加密的目录就跳过:没有边车可写,也不该凭空造一个
1081        if !crate::dirname::looks_encrypted(
1082            &sub.file_name().map_or_else(String::new, |n| n.to_string_lossy().into_owned()),
1083        ) {
1084            continue;
1085        }
1086        if let Err(e) = rewrite_keys_sidecar(&sub, ctx.keep, ctx.dk, ctx.vault_salt, ctx.cipher, ctx.others) {
1087            rep.failed.push((sub, e.to_string()));
1088        }
1089    }
1090}
1091
1092/// 数一棵树里有多少个密文文件。
1093///
1094/// 轮换前先数一遍才能显示「第 i / n 个」。多走一次目录遍历的代价远小于
1095/// 轮换本身(那要把每个文件的载荷读写一遍),而没有总数的进度条只能转圈,
1096/// 用户无从判断还要等多久。
1097fn count_files(dir: &Path) -> usize {
1098    let Ok(rd) = std::fs::read_dir(dir) else {
1099        return 0;
1100    };
1101    let mut n: usize = 0;
1102    for e in rd.flatten() {
1103        let p = e.path();
1104        if p.is_dir() {
1105            n = n.saturating_add(count_files(&p));
1106        } else if p.extension().is_some_and(|x| x == "omy") {
1107            n = n.saturating_add(1);
1108        }
1109    }
1110    n
1111}
1112
1113/// 改写单个文件。错误转成字符串,好让调用方逐个展示。
1114fn rekey_one(
1115    path: &Path,
1116    unlock: &[Kek],
1117    keep: &[Kek],
1118    rotate: bool,
1119    others: crate::keyslot::OtherSlots,
1120    on_bytes: &mut dyn FnMut(u64, u64),
1121) -> core::result::Result<u64, String> {
1122    let data = std::fs::read(path).map_err(|e| format!("read: {e}"))?;
1123
1124    // 两条路径只差这一步:改密码只重建 384 字节的 slot 区,轮换要把载荷
1125    // 读一遍再写一遍。前后的检查(自证可打开、原子写回)完全共用——
1126    // 分成两个函数的话,将来加一项检查就必然漏掉一边
1127    let (bytes, written) = if rotate {
1128        // 读与写各占一半:把两个阶段拼成一条 0→100,用户看到的是一条
1129        // 匀速推进的进度,而不是「跑到 100% 又归零重来一遍」
1130        //
1131        // 走 RefCell 是因为 rotate_fek_with_progress 要两个独立的
1132        // `&mut dyn FnMut`,而它们都得往同一个回调里写——借用检查器不接受
1133        // 两个闭包同时可变借用。运行期两者其实不重叠(解密跑完才轮到加密)
1134        let sink = core::cell::RefCell::new(on_bytes);
1135        let mut dec = |done: u64, total: u64| {
1136            if let Ok(mut f) = sink.try_borrow_mut() {
1137                f(done, total.saturating_mul(2));
1138            }
1139        };
1140        let mut enc = |done: u64, total: u64| {
1141            if let Ok(mut f) = sink.try_borrow_mut() {
1142                f(total.saturating_add(done), total.saturating_mul(2));
1143            }
1144        };
1145        let out = crate::reencrypt::rotate_fek_with_progress(
1146            &data,
1147            unlock,
1148            keep,
1149            &crate::file::RandomMaterial::generate(),
1150            Some(&mut dec),
1151            Some(&mut enc),
1152        )
1153        .map_err(|e| e.to_string())?;
1154        (out.bytes, out.plaintext_size)
1155    } else {
1156        // 策略由调用方决定:add / change 用 Carry 保住认不出来的槽位
1157        // (典型是恢复码),只有 remove 用 Discard 清场。
1158        //
1159        // 「树只能有一个密码」的旧约束已经不存在了——目录名改用两层结构
1160        // 之后,任一把钥匙都能解开它。这段注释以前写的是那个旧前提,
1161        // 那是用 key add 或恢复码流程加上去的,与「树只能有一个浏览密码」
1162        // 并不矛盾。填随机会把它们静默抹掉
1163        let out = crate::keyslot::rewrite_slots(&data, unlock, keep, others)
1164        .map_err(|e| e.to_string())?;
1165        (out.bytes, 0)
1166    };
1167
1168    // 写回前自证每个保留密码都能打开新字节。逐个验而不是只验第一个:
1169    // add 最容易犯的错是新密码能开、原密码被挤掉,只验一个正好漏掉。
1170    //
1171    // 顺序不能反——先覆盖再发现打不开,用户就同时失去了文件和访问权
1172    for k in keep {
1173        crate::file::open(&bytes, &[k.duplicate()])
1174            .map_err(|_| String::from("rewrite verify failed"))?;
1175    }
1176
1177    crate::fsatomic::write_atomic(path, &bytes).map_err(|e| format!("write: {e}"))?;
1178    Ok(written)
1179}
1180
1181/// 16 字节转小写十六进制。
1182fn hex16(b: &[u8; 16]) -> String {
1183    let mut s = String::with_capacity(32);
1184    for byte in b {
1185        // 手写而不用 format! 循环:这在每个文件上都要跑一次
1186        const HEX: &[u8; 16] = b"0123456789abcdef";
1187        let hi = usize::from(byte >> 4);
1188        let lo = usize::from(byte & 0x0F);
1189        s.push(char::from(*HEX.get(hi).unwrap_or(&b'0')));
1190        s.push(char::from(*HEX.get(lo).unwrap_or(&b'0')));
1191    }
1192    s
1193}
1194
1195#[cfg(test)]
1196#[expect(clippy::unwrap_used, clippy::indexing_slicing, reason = "测试里用断言即可")]
1197mod tests {
1198    use super::*;
1199    use crate::crypto::Argon2Params;
1200    use crate::header::FixedHeader;
1201
1202    /// 造一棵小树并加密,返回 (工作目录, 密文根, salt, cipher)。
1203    ///
1204    /// cipher 一并返回而不是让调用方硬写常量:写错的话目录名会用错算法
1205    /// 解密、报 content hash mismatch,看起来像「加密坏了」这种严重缺陷。
1206    fn tree_fixture(tag: &str) -> (PathBuf, PathBuf, [u8; 16], CipherId) {
1207        let root = std::env::temp_dir().join(format!("omy-rekey-{tag}"));
1208        let _ = std::fs::remove_dir_all(&root);
1209        let src = root.join("src");
1210        std::fs::create_dir_all(src.join("sub")).unwrap();
1211        std::fs::write(src.join("a.txt"), b"AAA").unwrap();
1212        std::fs::write(src.join("sub/b.txt"), b"BBB").unwrap();
1213
1214        let salt = [7u8; 16];
1215        let kek = Kek::from_password(b"old", &salt, Argon2Params::TEST_WEAK).unwrap();
1216        let out = root.join("enc");
1217        std::fs::create_dir_all(&out).unwrap();
1218        let opts = crate::file::EncryptOptions::default();
1219        let rep = encrypt_tree(&src, &out, &[kek], &salt, &opts, None).unwrap();
1220        (root, rep.root, salt, opts.cipher)
1221    }
1222
1223    fn kek_of(pw: &[u8], salt: &[u8; 16]) -> Kek {
1224        Kek::from_password(pw, salt, Argon2Params::TEST_WEAK).unwrap()
1225    }
1226
1227    #[test]
1228    fn media_extras_are_written_per_file_not_shared() {
1229        // 回调返回的缩略图必须写进**对应那个文件**。
1230        //
1231        // 不这样会怎样:早先树形模式只设了 filename,其余字段沿用调用方
1232        // 传进来的那份 opts —— 那是整棵树共用的一份,于是要么全为空
1233        // (谁都没有缩略图),要么写进了上一个文件的缩略图(A 的图出现在
1234        // B 上)。后者尤其难查:界面能显示图,只是显示错了。
1235        let root = std::env::temp_dir().join("omy-media-cb");
1236        let _ = std::fs::remove_dir_all(&root);
1237        let src = root.join("src");
1238        std::fs::create_dir_all(&src).unwrap();
1239        std::fs::write(src.join("one.bin"), b"1111").unwrap();
1240        std::fs::write(src.join("two.bin"), b"2222").unwrap();
1241
1242        let salt = [9u8; 16];
1243        let kek = kek_of(b"pw", &salt);
1244        let out = root.join("enc");
1245        std::fs::create_dir_all(&out).unwrap();
1246        let opts = crate::file::EncryptOptions::default();
1247
1248        // 每个文件给一份**内容不同**的假缩略图,内容由文件名决定。
1249        // 若实现把它们串了,下面按名字取回时就会对不上。
1250        let mut seen = Vec::new();
1251        let mut cb = |_data: &[u8], name: &str| {
1252            seen.push(name.to_owned());
1253            let thumb = format!("thumb-of-{name}").into_bytes();
1254            let meta = format!("{{\"name\":\"{name}\"}}").into_bytes();
1255            (Some(thumb), Some(meta), None)
1256        };
1257        let rep = encrypt_tree_with_media(
1258            &src,
1259            &out,
1260            &[kek.duplicate()],
1261            &salt,
1262            &opts,
1263            None,
1264            Some(&mut cb),
1265        )
1266        .unwrap();
1267        assert_eq!(rep.files, 2);
1268        seen.sort();
1269        assert_eq!(seen, vec!["one.bin", "two.bin"], "回调应当每个文件各调一次");
1270
1271        // 逐个解开,核对缩略图与文件名是否配套
1272        let mut checked = 0;
1273        for p in all_files(&rep.root) {
1274            let data = std::fs::read(&p).unwrap();
1275            let opened = crate::file::open(&data, &[kek.duplicate()]).unwrap();
1276            let name = opened.filename().unwrap();
1277            let thumb = opened.thumbnail().unwrap();
1278            assert_eq!(
1279                thumb,
1280                format!("thumb-of-{name}").into_bytes(),
1281                "{name} 拿到的是别人的缩略图"
1282            );
1283            let meta = opened.media_meta().unwrap();
1284            assert_eq!(meta, format!("{{\"name\":\"{name}\"}}").into_bytes());
1285            checked += 1;
1286        }
1287        assert_eq!(checked, 2);
1288        let _ = std::fs::remove_dir_all(&root);
1289    }
1290
1291    #[test]
1292    fn encrypt_tree_without_media_writes_no_thumbnail() {
1293        // 不传回调时不该凭空造出缩略图。这条守住「回调是唯一来源」,
1294        // 顺带保证不传回调的调用方(老代码)行为不变。
1295        let root = std::env::temp_dir().join("omy-media-none");
1296        let _ = std::fs::remove_dir_all(&root);
1297        let src = root.join("src");
1298        std::fs::create_dir_all(&src).unwrap();
1299        std::fs::write(src.join("a.bin"), b"AAAA").unwrap();
1300
1301        let salt = [11u8; 16];
1302        let kek = kek_of(b"pw", &salt);
1303        let out = root.join("enc");
1304        std::fs::create_dir_all(&out).unwrap();
1305        let opts = crate::file::EncryptOptions::default();
1306        let rep = encrypt_tree(&src, &out, &[kek.duplicate()], &salt, &opts, None).unwrap();
1307
1308        for p in all_files(&rep.root) {
1309            let data = std::fs::read(&p).unwrap();
1310            let opened = crate::file::open(&data, &[kek.duplicate()]).unwrap();
1311            assert!(!opened.has_thumbnail(), "没传回调却有缩略图");
1312        }
1313        let _ = std::fs::remove_dir_all(&root);
1314    }
1315
1316    #[test]
1317    fn rekey_tree_changes_every_file_and_old_password_stops_working() {
1318        let (root, enc_root, salt, cipher) = tree_fixture("change");
1319        let old = kek_of(b"old", &salt);
1320        let new = kek_of(b"new", &salt);
1321
1322        let rep =
1323            rekey_tree(&enc_root, &[old.duplicate()], &[new.duplicate()], &salt, cipher, crate::keyslot::OtherSlots::Carry).unwrap();
1324        assert_eq!(rep.changed, 2, "两个文件都该改到;漏掉深层文件是最可能的缺陷");
1325        assert!(rep.is_complete(), "失败项: {:?}", rep.failed);
1326        // 目录名现在由随机 DK 加密,与密码无关——换密码不再改名。
1327        // 这比旧行为好:用户回到文件管理器不会发现文件夹「不见了」,
1328        // 增量备份也不用重传全部目录项
1329        assert_eq!(rep.root, enc_root, "换密码不该改目录名");
1330        assert!(rep.root.is_dir(), "根路径应当仍然有效");
1331        let enc_root = rep.root.clone();
1332
1333        // 正面:新密码能打开每一个文件
1334        let mut checked = 0;
1335        for f in all_files(&enc_root) {
1336            let data = std::fs::read(&f).unwrap();
1337            let opened = crate::file::open(&data, &[new.duplicate()]).unwrap();
1338            // 取出原文件名:能解出来就说明 FEK 是对的(文件名用 FEK 加密)
1339            assert!(opened.filename().unwrap().ends_with(".txt"));
1340            checked += 1;
1341            // 反证:旧密码必须打不开了,否则「改密码」根本没生效
1342            assert!(
1343                crate::file::open(&data, &[old.duplicate()]).is_err(),
1344                "旧密码仍能打开 {f:?}——改密码没生效,而用户以为已经换了"
1345            );
1346        }
1347        assert_eq!(checked, 2);
1348        let _ = std::fs::remove_dir_all(&root);
1349    }
1350
1351    #[test]
1352    fn rekey_tree_keeps_payload_byte_identical() {
1353        // 改密码只该动 384 字节 slot 区 + 32 字节 MAC。若实现退化成
1354        // 「解密后重新加密」,载荷和 file_uuid 都会变——对增量备份来说
1355        // 整棵树都要重传,而且大文件会慢几个数量级
1356        let (root, enc_root, salt, cipher) = tree_fixture("payload");
1357        // 按**磁盘文件名**索引而不是完整路径:目录名会随改密码变化,
1358        // 完整路径在 rekey 之后就失效了。文件名是随机 uuid,不会变
1359        let before: std::collections::BTreeMap<String, Vec<u8>> = all_files(&enc_root)
1360            .into_iter()
1361            .map(|p| {
1362                let d = std::fs::read(&p).unwrap();
1363                (p.file_name().unwrap().to_string_lossy().into_owned(), d)
1364            })
1365            .collect();
1366
1367        let old = kek_of(b"old", &salt);
1368        let new = kek_of(b"new", &salt);
1369        let rep = rekey_tree(&enc_root, &[old], &[new], &salt, cipher, crate::keyslot::OtherSlots::Carry).unwrap();
1370
1371        for p in all_files(&rep.root) {
1372            let name = p.file_name().unwrap().to_string_lossy().into_owned();
1373            let old_bytes = before.get(&name).expect("改密码后出现了新文件名——uuid 不该变");
1374            let new_bytes = std::fs::read(&p).unwrap();
1375            assert_eq!(old_bytes.len(), new_bytes.len(), "长度变了:{p:?}");
1376            let old_bytes = old_bytes.clone();
1377            // 头部固定区 + slot 区 + MAC 之后的部分必须逐字节相同。
1378            // 用 header_len 定位载荷起点,不靠硬编码偏移
1379            let h = FixedHeader::parse(&new_bytes).unwrap();
1380            let start = h.header_len as usize;
1381            assert_eq!(
1382                &old_bytes[start..],
1383                &new_bytes[start..],
1384                "载荷被改写了:{p:?}——说明退化成了解密后重新加密"
1385            );
1386            // file_uuid 也必须不变
1387            let h0 = FixedHeader::parse(&old_bytes).unwrap();
1388            assert_eq!(h0.file_uuid, h.file_uuid, "file_uuid 变了:{p:?}");
1389        }
1390        let _ = std::fs::remove_dir_all(&root);
1391    }
1392
1393    #[test]
1394    fn rekey_tree_refuses_empty_keep() {
1395        // keep 为空会留下永远打不开的文件,等同于销毁数据
1396        let (root, enc_root, salt, cipher) = tree_fixture("empty");
1397        let old = kek_of(b"old", &salt);
1398        assert!(rekey_tree(&enc_root, &[old], &[], &salt, cipher, crate::keyslot::OtherSlots::Carry).is_err());
1399        let _ = std::fs::remove_dir_all(&root);
1400    }
1401
1402    #[test]
1403    fn rekey_tree_rejects_a_plain_directory() {
1404        // 对普通目录执行必须报错,不能返回「0 个已改写」——那看着像成功,
1405        // 而用户其实选错了目录
1406        let root = std::env::temp_dir().join("omy-rekey-plain");
1407        let _ = std::fs::remove_dir_all(&root);
1408        std::fs::create_dir_all(&root).unwrap();
1409        std::fs::write(root.join("x.txt"), b"not encrypted").unwrap();
1410        let salt = [7u8; 16];
1411        let k = kek_of(b"pw", &salt);
1412        assert!(
1413            rekey_tree(&root, &[k.duplicate()], &[k], &salt, CipherId::ChaCha20Poly1305, crate::keyslot::OtherSlots::Carry)
1414                .is_err(),
1415            "对普通目录应当报错,而不是静默返回 0 个已改写"
1416        );
1417        let _ = std::fs::remove_dir_all(&root);
1418    }
1419
1420    #[test]
1421    fn rekey_tree_rejects_a_wrong_password_without_touching_anything() {
1422        // 密码不对时整体拒绝,且磁盘一字节不动。
1423        //
1424        // 行为在改用两层目录名结构后变了:以前是逐个文件试、报 N 条
1425        // 失败;现在开头读边车就失败,直接返回 Err。后者更好——用户
1426        // 拿到的是一句明确的「打不开这棵树」,而不是一串让他分不清
1427        // 「密码不对」还是「文件坏了」的逐文件失败。
1428        //
1429        // 不变的是那条核心断言:磁盘上一字节都不能动。写坏了才报错的
1430        // 实现会让用户既没改成密码、又丢了文件
1431        let (root, enc_root, salt, cipher) = tree_fixture("wrongpw");
1432        let before: Vec<Vec<u8>> =
1433            all_files(&enc_root).iter().map(|p| std::fs::read(p).unwrap()).collect();
1434
1435        let bad = kek_of(b"bad", &salt);
1436        let new = kek_of(b"new", &salt);
1437        let r = rekey_tree(&enc_root, &[bad], &[new], &salt, cipher, crate::keyslot::OtherSlots::Carry);
1438        assert!(r.is_err(), "密码不对必须整体拒绝,而不是部分成功");
1439
1440        let after: Vec<Vec<u8>> =
1441            all_files(&enc_root).iter().map(|p| std::fs::read(p).unwrap()).collect();
1442        assert_eq!(before, after, "密码不对却动了文件");
1443        assert!(enc_root.is_dir(), "密码不对却动了根目录");
1444        let _ = std::fs::remove_dir_all(&root);
1445    }
1446
1447    #[test]
1448    fn tree_remove_really_voids_the_other_passwords() {
1449        // remove 承诺「只保留当前密码」。这条守着它真的兑现了。
1450        //
1451        // 不这样会怎样:实测过一个版本,rekey_tree 内部对文件槽位和
1452        // 边车都写死 Carry,于是第二个密码照样能解开整棵树——用户以为
1453        // 撤销了某人的访问权,实际没有。这是安全事故级的缺陷,而当时
1454        // 没有任何测试守着它,是靠临时探针偶然发现的。
1455        let (root, enc_root, salt, cipher) = tree_fixture("rmvoid");
1456        let keep_me = kek_of(b"old", &salt);
1457        let to_void = kek_of(b"second", &salt);
1458
1459        // 先把第二个密码加上,并自证它真的能用——否则下面的断言
1460        // 可能只是因为它从来没生效过
1461        let added = rekey_tree(
1462            &enc_root,
1463            &[keep_me.duplicate()],
1464            &[keep_me.duplicate(), to_void.duplicate()],
1465            &salt,
1466            cipher,
1467            crate::keyslot::OtherSlots::Carry,
1468        )
1469        .unwrap();
1470        assert!(added.is_complete(), "{:?}", added.failed);
1471        let probe = root.join("probe-before");
1472        std::fs::create_dir_all(&probe).unwrap();
1473        assert!(
1474            decrypt_tree(&added.root, &probe, &[to_void.duplicate()], &salt, cipher, None)
1475                .is_ok(),
1476            "第二个密码一开始就该能解开整棵树,否则这条测试证明不了什么"
1477        );
1478
1479        // remove:只保留第一个密码
1480        let removed = rekey_tree(
1481            &added.root,
1482            &[keep_me.duplicate()],
1483            &[keep_me.duplicate()],
1484            &salt,
1485            cipher,
1486            crate::keyslot::OtherSlots::Discard,
1487        )
1488        .unwrap();
1489        assert!(removed.is_complete(), "{:?}", removed.failed);
1490
1491        // 保留的那个还能用
1492        let after_keep = root.join("after-keep");
1493        std::fs::create_dir_all(&after_keep).unwrap();
1494        assert!(
1495            decrypt_tree(&removed.root, &after_keep, &[keep_me], &salt, cipher, None).is_ok(),
1496            "remove 把该保留的密码也弄丢了"
1497        );
1498
1499        // 被移除的那个必须两层都失效:文件打不开,整棵树也解不开。
1500        // 只查其中一层是不够的——缺陷正是「文件层清了、目录名层没清」
1501        // 这种半残状态
1502        for f in all_files(&removed.root) {
1503            let data = std::fs::read(&f).unwrap();
1504            assert!(
1505                crate::file::open(&data, &[to_void.duplicate()]).is_err(),
1506                "被移除的密码仍能打开文件 {f:?}"
1507            );
1508        }
1509        let after_void = root.join("after-void");
1510        std::fs::create_dir_all(&after_void).unwrap();
1511        assert!(
1512            decrypt_tree(&removed.root, &after_void, &[to_void], &salt, cipher, None).is_err(),
1513            "被移除的密码仍能解开整棵树——用户以为撤销了访问权,实际没有"
1514        );
1515
1516        let _ = std::fs::remove_dir_all(&root);
1517    }
1518
1519    #[test]
1520    fn rekey_tree_can_add_a_second_password_keeping_the_first() {
1521        // add 的语义:两个密码都能开。最容易犯的错是新密码把原密码挤掉
1522        let (root, enc_root, salt, cipher) = tree_fixture("add");
1523        let old = kek_of(b"old", &salt);
1524        let extra = kek_of(b"extra", &salt);
1525        let rep = rekey_tree(
1526            &enc_root,
1527            &[old.duplicate()],
1528            &[old.duplicate(), extra.duplicate()],
1529            &salt,
1530            cipher,
1531            crate::keyslot::OtherSlots::Carry,
1532        )
1533        .unwrap();
1534        assert!(rep.is_complete(), "{:?}", rep.failed);
1535        // add 保留了原密码作为第一个 KEK,所以目录名密钥不变、根路径不变。
1536        // 这是 add 与 change 的一个可观察差别
1537        assert_eq!(rep.root, enc_root, "根目录名任何时候都不该变");
1538
1539        for f in all_files(&enc_root) {
1540            let data = std::fs::read(&f).unwrap();
1541            assert!(crate::file::open(&data, &[old.duplicate()]).is_ok(), "原密码被挤掉了");
1542            assert!(crate::file::open(&data, &[extra.duplicate()]).is_ok(), "新密码不生效");
1543        }
1544        let _ = std::fs::remove_dir_all(&root);
1545    }
1546
1547    /// 造一棵树并让其中一个文件写入失败,返回 (root, 报告, 两个 KEK, cipher)。
1548    ///
1549    /// 用「设为只读」制造失败而不是塞垃圾文件:只读文件 open 成功、写回失败,
1550    /// 正是真实场景(被别的程序占用、权限不对)走的那条路径。塞垃圾文件走的
1551    /// 是 open 失败,测不到「改到一半」的状态。
1552    ///
1553    /// 只在 Windows 上编译:POSIX 的写入权限看的是**目录**,只读文件照样能被
1554    /// rename 覆盖,所以这个手法在那里造不出失败。与其写一段在 Unix 上静默
1555    /// 失效的测试,不如明确限定平台
1556    #[cfg(windows)]
1557    fn partial_failure_fixture(tag: &str) -> (PathBuf, RekeyReport, Kek, Kek, CipherId) {
1558        let (root, enc_root, salt, cipher) = tree_fixture(tag);
1559        let old = kek_of(b"old", &salt);
1560        let new = kek_of(b"new", &salt);
1561
1562        let victim = all_files(&enc_root).into_iter().next().expect("树里应当有文件");
1563        let mut perm = std::fs::metadata(&victim).unwrap().permissions();
1564        perm.set_readonly(true);
1565        std::fs::set_permissions(&victim, perm).unwrap();
1566
1567        let rep =
1568            rekey_tree(&enc_root, &[old.duplicate()], &[new.duplicate()], &salt, cipher, crate::keyslot::OtherSlots::Carry).unwrap();
1569
1570        // 解除只读,让调用方可以接着测重试。
1571        // clippy 提醒 set_readonly(false) 在 Unix 上等于全局可写——这里整个
1572        // fixture 已经限定 windows,不存在那个问题
1573        for (p, _) in &rep.failed {
1574            if let Ok(md) = std::fs::metadata(p) {
1575                #[expect(
1576                    clippy::permissions_set_readonly_false,
1577                    reason = "仅 Windows 编译,不存在 Unix 上全局可写的问题"
1578                )]
1579                {
1580                    let mut perm = md.permissions();
1581                    perm.set_readonly(false);
1582                    let _ = std::fs::set_permissions(p, perm);
1583                }
1584            }
1585        }
1586        (root, rep, old, new, cipher)
1587    }
1588
1589    #[test]
1590    #[cfg(windows)]
1591    fn partial_failure_reports_paths_that_still_exist() {
1592        // 修复前这里每一条 exists() 都是 false:失败是在改名之前记下的,
1593        // 之后父目录被逐层改名,路径全部指向不存在的位置。
1594        //
1595        // 不这样会怎样:CLI 把一串不存在的路径打印给用户,GUI 也没法按它
1596        // 重试——「重试」拿到的每个目标都是「文件不存在」
1597        let (root, rep, _old, _new, _cipher) = partial_failure_fixture("partpath");
1598
1599        assert!(!rep.is_complete(), "只读文件应当导致部分失败");
1600        assert_eq!(rep.failed.len(), 1, "只有一个文件被设为只读:{:?}", rep.failed);
1601        // 两层结构下不再改名,所以失败路径天然不会失效。
1602        // 这条断言保留是为了记录:路径有效性现在是结构保证的,
1603        // 不再依赖「改完名记得回填路径」那段容易漏的逻辑
1604        assert_eq!(rep.dirs_renamed, 0, "不该再有改名发生");
1605
1606        for (p, _) in &rep.failed {
1607            assert!(p.exists(), "报告里的失败路径不存在:{p:?}");
1608            // 而且必须落在改名后的新树里,不能是旧前缀
1609            assert!(
1610                p.starts_with(&rep.root),
1611                "失败路径不在报告返回的新根目录下:{p:?} 不以 {:?} 开头",
1612                rep.root
1613            );
1614        }
1615        let _ = std::fs::remove_dir_all(&root);
1616    }
1617
1618    #[test]
1619    #[cfg(windows)]
1620    fn retry_files_finishes_the_job_without_touching_the_rest() {
1621        // 重试的完整闭环:只处理清单里的文件,处理完整棵树能用新密码解开。
1622        let (root, rep, old, new, cipher) = partial_failure_fixture("retryok");
1623        let salt = [7u8; 16];
1624        let before = rep.changed;
1625        let list: Vec<PathBuf> = rep.failed.iter().map(|(p, _)| p.clone()).collect();
1626
1627        // 目录名不该再变一次:改名在第一次就完成了
1628        let names_before: Vec<String> = walk_dirs(&rep.root);
1629
1630        let r2 = retry_files(&list, &[old.duplicate()], &[new.duplicate()], false, crate::keyslot::OtherSlots::Carry, None).unwrap();
1631        assert!(r2.is_complete(), "重试应当成功:{:?}", r2.failed);
1632        assert_eq!(r2.changed, list.len(), "改写数应当等于清单长度");
1633
1634        assert_eq!(walk_dirs(&rep.root), names_before, "重试不该改动目录名");
1635        assert!(rep.root.exists(), "重试不该动根目录");
1636
1637        // 最终判据:整棵树用新密码完整解开,内容一字不差
1638        let out = root.join("dec");
1639        std::fs::create_dir_all(&out).unwrap();
1640        let d = decrypt_tree(&rep.root, &out, &[new], &salt, cipher, None).unwrap();
1641        assert_eq!(
1642            d.files,
1643            before.saturating_add(r2.changed),
1644            "解出的文件数应当等于第一次改好的 + 重试补上的"
1645        );
1646        assert_eq!(std::fs::read(d.root.join("a.txt")).unwrap(), b"AAA");
1647        assert_eq!(std::fs::read(d.root.join("sub/b.txt")).unwrap(), b"BBB");
1648        let _ = std::fs::remove_dir_all(&root);
1649    }
1650
1651    #[test]
1652    #[cfg(windows)]
1653    fn retry_files_only_touches_the_listed_files() {
1654        // 反证:清单外的文件一个字节都不能动。
1655        //
1656        // 不测的话,一个「收到清单却仍然遍历整棵树」的实现照样能让上一条
1657        // 通过(它最终也能全部改好),但那会拿旧密码去开已经改成新密码的
1658        // 文件,产生一堆假失败,用户分不清哪些是真问题
1659        let (root, rep, old, new, cipher) = partial_failure_fixture("retryscope");
1660        let list: Vec<PathBuf> = rep.failed.iter().map(|(p, _)| p.clone()).collect();
1661
1662        // 记下清单外每个文件的字节
1663        let others: Vec<(PathBuf, Vec<u8>)> = all_files(&rep.root)
1664            .into_iter()
1665            .filter(|p| !list.contains(p))
1666            .map(|p| {
1667                let b = std::fs::read(&p).unwrap();
1668                (p, b)
1669            })
1670            .collect();
1671        assert!(!others.is_empty(), "应当有清单外的文件");
1672
1673        let r2 = retry_files(&list, &[old], &[new], false, crate::keyslot::OtherSlots::Carry, None).unwrap();
1674        assert!(r2.is_complete(), "{:?}", r2.failed);
1675        assert_eq!(r2.changed, list.len(), "改写数不该超过清单长度");
1676
1677        for (p, was) in &others {
1678            assert_eq!(&std::fs::read(p).unwrap(), was, "动了清单外的文件:{p:?}");
1679        }
1680        let _ = std::fs::remove_dir_all(&root);
1681        let _ = cipher;
1682    }
1683
1684    #[test]
1685    fn retry_files_refuses_to_leave_a_file_unopenable() {
1686        // keep 为空会写出永远打不开的文件。空清单则应当直接成功(无事可做),
1687        // 不要为此报错——调用方可能在循环里无条件调用
1688        let empty: Vec<PathBuf> = Vec::new();
1689        let salt = [7u8; 16];
1690        let k = kek_of(b"x", &salt);
1691        assert!(retry_files(&empty, &[k.duplicate()], &[], false, crate::keyslot::OtherSlots::Carry, None).is_err());
1692        let rep = retry_files(&empty, &[k.duplicate()], &[k], false, crate::keyslot::OtherSlots::Carry, None).unwrap();
1693        assert_eq!(rep.changed, 0);
1694        assert!(rep.is_complete());
1695    }
1696
1697    /// 列出一棵树里所有子目录的磁盘名(排序后),用于断言「没改名」。
1698    #[cfg(windows)]
1699    fn walk_dirs(root: &Path) -> Vec<String> {
1700        let mut out = Vec::new();
1701        fn rec(d: &Path, out: &mut Vec<String>) {
1702            let Ok(rd) = std::fs::read_dir(d) else { return };
1703            for e in rd.flatten() {
1704                let p = e.path();
1705                if p.is_dir() {
1706                    out.push(p.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default());
1707                    rec(&p, out);
1708                }
1709            }
1710        }
1711        rec(root, &mut out);
1712        out.sort();
1713        out
1714    }
1715
1716    #[test]
1717    fn find_any_file_skips_files_whose_header_does_not_parse() {
1718        // 树里混进一个坏 .omy 时,样本必须绕过它。
1719        //
1720        // 不这样会怎样:拿坏文件当样本 → peek_header 失败 → 整个操作报
1721        // 「文件已损坏」,而那棵树其余部分完好。更糟的是结果**随目录枚举
1722        // 顺序变化**:同一棵树同一条命令,两次运行给出不同的错误。实测
1723        // 遇到过,是最难查的一类不确定性
1724        let (root, enc_root, _salt, _cipher) = tree_fixture("skipbad");
1725
1726        // 名字排在真密文之前,尽量让它被先枚举到
1727        let junk = enc_root.join("0000-broken.omy");
1728        std::fs::write(&junk, b"not an omy file at all").unwrap();
1729
1730        let found = find_any_file(&enc_root).expect("应当仍能找到样本文件");
1731        assert_ne!(found, junk, "选中了头部解析不过的文件");
1732        // 自证选到的那个真的能解析,而不是碰巧躲开了坏文件
1733        let data = std::fs::read(&found).unwrap();
1734        assert!(
1735            crate::file::peek_header(&data).is_ok(),
1736            "选中的样本头部也解析不过:{found:?}"
1737        );
1738
1739        // 全是坏文件时要退回那个坏文件,好让调用方报「已损坏」。
1740        // 返回 None 会被报成「这不是密文树」——可它确实是,只是文件坏了,
1741        // 把「坏了」说成「不是」会让用户往错的方向排查
1742        let only_bad = root.join("onlybad");
1743        std::fs::create_dir_all(&only_bad).unwrap();
1744        let bad_one = only_bad.join("x.omy");
1745        std::fs::write(&bad_one, b"garbage").unwrap();
1746        assert_eq!(find_any_file(&only_bad).as_deref(), Some(bad_one.as_path()));
1747
1748        // 压根没有 .omy 才是 None:这时才该报「不是密文树」
1749        let empty = root.join("empty");
1750        std::fs::create_dir_all(&empty).unwrap();
1751        std::fs::write(empty.join("readme.txt"), b"hi").unwrap();
1752        assert!(find_any_file(&empty).is_none(), "没有密文文件时应当返回 None");
1753
1754        let _ = std::fs::remove_dir_all(&root);
1755    }
1756
1757    #[test]
1758    fn rekey_tree_rotate_replaces_the_ciphertext_unlike_a_plain_rekey() {
1759        // 轮换与改密码的**唯一**可观察差别就是这个:载荷密文变没变。
1760        // 少了这条,一个「悄悄忽略 rotate 参数」的实现能通过其余所有断言
1761        let (root, enc_root, salt, cipher) = tree_fixture("rot");
1762        let old = kek_of(b"old", &salt);
1763        let new = kek_of(b"new", &salt);
1764
1765        let before: std::collections::BTreeMap<String, Vec<u8>> = all_files(&enc_root)
1766            .into_iter()
1767            .map(|p| {
1768                let d = std::fs::read(&p).unwrap();
1769                (p.file_name().unwrap().to_string_lossy().into_owned(), d)
1770            })
1771            .collect();
1772
1773        let rep = rekey_tree_with_progress(
1774            &enc_root,
1775            &[old.duplicate()],
1776            &[new.duplicate()],
1777            RekeyParams { vault_salt: &salt, cipher, rotate: true, others: crate::keyslot::OtherSlots::Carry },
1778            None,
1779        )
1780        .unwrap();
1781        assert!(rep.is_complete(), "{:?}", rep.failed);
1782        assert!(rep.payload_rewritten, "报告里要标明换了文件密钥");
1783        assert!(rep.bytes_rewritten > 0, "重写的明文字节数不该是 0");
1784
1785        // 轮换会换掉 file_uuid,所以磁盘名(uuid)也会变——按名字配不上,
1786        // 正好说明确实换了。逐字节比对全体内容:只要有一个文件的字节
1787        // 原封不动,就是没真的轮换
1788        let after: Vec<Vec<u8>> =
1789            all_files(&rep.root).iter().map(|p| std::fs::read(p).unwrap()).collect();
1790        assert_eq!(after.len(), before.len(), "文件数不该变");
1791        for bytes in &after {
1792            assert!(
1793                !before.values().any(|b| b == bytes),
1794                "有文件的密文一字节没变——rotate 没生效"
1795            );
1796        }
1797
1798        // 换完还得能用:整棵树用新密码解得开,内容一致
1799        let out = root.join("dec");
1800        std::fs::create_dir_all(&out).unwrap();
1801        let d = decrypt_tree(&rep.root, &out, &[new], &salt, cipher, None).unwrap();
1802        assert_eq!(d.files, 2);
1803        assert_eq!(std::fs::read(d.root.join("a.txt")).unwrap(), b"AAA");
1804        assert_eq!(std::fs::read(d.root.join("sub/b.txt")).unwrap(), b"BBB");
1805        let _ = std::fs::remove_dir_all(&root);
1806    }
1807
1808    #[test]
1809    fn rekey_tree_without_rotate_keeps_the_ciphertext() {
1810        // 反证。上一条若因为「所有路径都重写密文」而通过,这条会失败——
1811        // 改密码必须只动 slot 区,载荷一字节不改(否则增量备份要重传整份)
1812        //
1813        // 单独造 fixture 而不用 tree_fixture:那个写的是 3 字节明文,载荷
1814        // 密文才 23 字节,比对尾部时很容易倒着切进头部区——而头部本来就
1815        // 该变(slot 区重建了),于是断言必然失败,看着像产品缺陷
1816        const N: usize = 2000;
1817        let root = std::env::temp_dir().join("omy-rekey-norot");
1818        let _ = std::fs::remove_dir_all(&root);
1819        let src = root.join("src");
1820        std::fs::create_dir_all(&src).unwrap();
1821        let body: Vec<u8> = (0..N).map(|i| (i % 251) as u8).collect();
1822        std::fs::write(src.join("big.bin"), &body).unwrap();
1823        let salt = [7u8; 16];
1824        let out = root.join("enc");
1825        std::fs::create_dir_all(&out).unwrap();
1826        let opts = crate::file::EncryptOptions::default();
1827        let cipher = opts.cipher;
1828        let old = kek_of(b"old", &salt);
1829        let new = kek_of(b"new", &salt);
1830        let enc_root =
1831            encrypt_tree(&src, &out, &[old.duplicate()], &salt, &opts, None).unwrap().root;
1832
1833        let before: std::collections::BTreeMap<String, Vec<u8>> = all_files(&enc_root)
1834            .into_iter()
1835            .map(|p| {
1836                let d = std::fs::read(&p).unwrap();
1837                (p.file_name().unwrap().to_string_lossy().into_owned(), d)
1838            })
1839            .collect();
1840        assert_eq!(before.len(), 1);
1841
1842        let rep =
1843            rekey_tree_with_progress(&enc_root, &[old], &[new], RekeyParams { vault_salt: &salt, cipher, rotate: false, others: crate::keyslot::OtherSlots::Carry }, None)
1844                .unwrap();
1845        assert!(!rep.payload_rewritten);
1846        assert_eq!(rep.bytes_rewritten, 0, "没轮换就不该报重写字节数");
1847
1848        for p in all_files(&rep.root) {
1849            let name = p.file_name().unwrap().to_string_lossy().into_owned();
1850            // 磁盘名就是 file_uuid:改密码不换它,所以按名字一定配得上。
1851            // 配不上说明 file_uuid 变了,那正是我们要防的
1852            let old_bytes = before.get(&name).expect("改密码不该换掉磁盘名(file_uuid)");
1853            let new_bytes = std::fs::read(&p).unwrap();
1854            assert_eq!(old_bytes.len(), new_bytes.len(), "长度变了:{p:?}");
1855            // 载荷在文件末尾,长度是明文 + AEAD tag。往回取这么多一定落在
1856            // 载荷内,不会切进会变的头部区
1857            let tail = old_bytes.len().saturating_sub(N);
1858            assert_eq!(&old_bytes[tail..], &new_bytes[tail..], "载荷变了:{p:?}");
1859        }
1860        let _ = std::fs::remove_dir_all(&root);
1861    }
1862
1863    #[test]
1864    fn rekey_tree_rotate_reports_progress_for_every_file() {
1865        // 进度回调是 GUI 进度条的唯一数据来源。不测的话,一个「压根不调
1866        // 回调」的实现完全无声——界面上表现为进度条一直停在 0
1867        let (root, enc_root, salt, cipher) = tree_fixture("prog");
1868        let old = kek_of(b"old", &salt);
1869
1870        let mut seen: Vec<(String, usize, usize)> = Vec::new();
1871        let mut max_ratio = 0.0f64;
1872        let mut cb = |name: &str, idx: usize, total: usize, done: u64, bytes: u64| {
1873            let key = (name.to_owned(), idx, total);
1874            if !seen.contains(&key) {
1875                seen.push(key);
1876            }
1877            if bytes > 0 {
1878                #[expect(clippy::cast_precision_loss, reason = "测试里比个比例够用")]
1879                let r = done as f64 / bytes as f64;
1880                if r > max_ratio {
1881                    max_ratio = r;
1882                }
1883            }
1884        };
1885        let rep = rekey_tree_with_progress(
1886            &enc_root,
1887            &[old.duplicate()],
1888            &[old],
1889            RekeyParams { vault_salt: &salt, cipher, rotate: true, others: crate::keyslot::OtherSlots::Carry },
1890            Some(&mut cb),
1891        )
1892        .unwrap();
1893        assert!(rep.is_complete(), "{:?}", rep.failed);
1894
1895        assert_eq!(seen.len(), 2, "两个文件都该汇报进度,实际 {seen:?}");
1896        for (_, idx, total) in &seen {
1897            assert_eq!(*total, 2, "总数应当是文件数");
1898            assert!(*idx >= 1 && *idx <= 2, "序号越界: {idx}");
1899        }
1900        // 序号必须各不相同:都报 1 的话进度条永远停在第一个
1901        assert_ne!(seen[0].1, seen[1].1, "两个文件报了同一个序号");
1902        // 进度要真的推进到接近完成,而不是只发一次 0
1903        assert!(max_ratio > 0.5, "进度最高只到 {max_ratio},没有推进到末尾");
1904        let _ = std::fs::remove_dir_all(&root);
1905    }
1906
1907    #[test]
1908    fn rekey_tree_rotate_can_keep_the_same_password() {
1909        // 「密码不变、只让旧副本作废」是轮换的正当用法。要求必须换密码
1910        // 会让这个需求无法表达
1911        let (root, enc_root, salt, cipher) = tree_fixture("samepw");
1912        let old = kek_of(b"old", &salt);
1913        let before: Vec<Vec<u8>> =
1914            all_files(&enc_root).iter().map(|p| std::fs::read(p).unwrap()).collect();
1915
1916        let rep = rekey_tree_with_progress(
1917            &enc_root,
1918            &[old.duplicate()],
1919            &[old.duplicate()],
1920            RekeyParams { vault_salt: &salt, cipher, rotate: true, others: crate::keyslot::OtherSlots::Carry },
1921            None,
1922        )
1923        .unwrap();
1924        assert!(rep.is_complete(), "{:?}", rep.failed);
1925        // 密码没变 → 目录名密钥没变 → 根路径不该变
1926        assert_eq!(rep.root, enc_root, "目录名与密码无关,任何时候都不该变");
1927
1928        // 但密文必须变了:这正是「让旧副本作废」的实质
1929        let after: Vec<Vec<u8>> =
1930            all_files(&enc_root).iter().map(|p| std::fs::read(p).unwrap()).collect();
1931        for b in &after {
1932            assert!(!before.contains(b), "密文没变,旧副本仍然等价");
1933        }
1934        // 原密码还能用
1935        let out = root.join("dec");
1936        std::fs::create_dir_all(&out).unwrap();
1937        let d = decrypt_tree(&enc_root, &out, &[old], &salt, cipher, None).unwrap();
1938        assert_eq!(d.files, 2);
1939        let _ = std::fs::remove_dir_all(&root);
1940    }
1941
1942    #[test]
1943    fn rekey_tree_leaves_directory_names_openable() {
1944        // 目录名由 keep 的**第一个** KEK 派生。如果改密码后第一个 KEK 变了,
1945        // 目录名就解不开、整个分支进不去。这条守的是「换密码后树还能浏览」
1946        let (root, enc_root, salt, cipher) = tree_fixture("dirname");
1947        let old = kek_of(b"old", &salt);
1948        let new = kek_of(b"new", &salt);
1949        let rk =
1950            rekey_tree(&enc_root, &[old.duplicate()], &[new.duplicate()], &salt, cipher, crate::keyslot::OtherSlots::Carry).unwrap();
1951        assert!(rk.is_complete(), "{:?}", rk.failed);
1952        // 不再改名,所以 dirs_renamed 恒为 0。真正要守的是下面那条:
1953        // 换完密码整棵树仍能用新密码完整解开
1954        assert_eq!(rk.dirs_renamed, 0, "两层结构下换密码不该改名");
1955        assert_eq!(rk.root, enc_root, "根路径不该变");
1956
1957        // 用新密码解整棵树。这一条是整个改动的核心:只改文件不改目录名的话,
1958        // 新密码开得了文件却解不开目录名,旧密码反之——两个密码都用不了
1959        let out = root.join("dec");
1960        std::fs::create_dir_all(&out).unwrap();
1961        let rep = decrypt_tree(&rk.root, &out, &[new], &salt, cipher, None).unwrap();
1962        assert_eq!(rep.files, 2, "换密码后整棵树应当仍能解开,失败: {:?}", rep.skipped);
1963        assert!(rep.root.join("sub/b.txt").is_file(), "子目录结构没还原对");
1964
1965        // 反证:旧密码不该还能解开这棵树
1966        let out2 = root.join("dec-old");
1967        std::fs::create_dir_all(&out2).unwrap();
1968        assert!(
1969            decrypt_tree(&rk.root, &out2, &[old], &salt, cipher, None).is_err(),
1970            "旧密码仍能解开整棵树——改密码没生效"
1971        );
1972        let _ = std::fs::remove_dir_all(&root);
1973    }
1974
1975    /// 收集树里所有密文文件,按路径排序好让比较稳定。
1976    fn all_files(root: &Path) -> Vec<PathBuf> {
1977        let mut out = Vec::new();
1978        walk(root, &mut out);
1979        out.sort();
1980        out
1981    }
1982
1983    fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
1984        let Ok(rd) = std::fs::read_dir(dir) else { return };
1985        for e in rd.flatten() {
1986            let p = e.path();
1987            if p.is_dir() {
1988                walk(&p, out);
1989            } else if p.extension().is_some_and(|x| x == "omy") {
1990                out.push(p);
1991            }
1992        }
1993    }
1994
1995    fn kek() -> Kek {
1996        Kek::from_password(b"pw", &[3u8; 16], Argon2Params::TEST_WEAK).unwrap()
1997    }
1998
1999    /// 造一棵测试树。
2000    fn make_tree(tag: &str) -> PathBuf {
2001        let root = std::env::temp_dir().join(format!("omy-tree-{tag}"));
2002        let _ = std::fs::remove_dir_all(&root);
2003        std::fs::create_dir_all(root.join("文档/草稿")).unwrap();
2004        std::fs::create_dir_all(root.join("empty")).unwrap();
2005        std::fs::write(root.join("a.txt"), b"content of a").unwrap();
2006        std::fs::write(root.join("文档/报告.docx"), b"report body").unwrap();
2007        std::fs::write(root.join("文档/草稿/draft.md"), b"# draft").unwrap();
2008        root
2009    }
2010
2011    fn opts() -> EncryptOptions {
2012        EncryptOptions { argon2: Argon2Params::TEST_WEAK, ..EncryptOptions::default() }
2013    }
2014
2015    /// 造一个「够长到触发截断 + 边车、又能真正创建出来」的目录名。
2016    ///
2017    /// 两个约束是相反方向的,必须同时满足:
2018    ///
2019    /// - **下限**:明文超过 128 字节,密文 base32 编码后才会超过 255,
2020    ///   走截断 + 边车那条路。低于它测的就不是这条路径了。
2021    /// - **上限**:Linux 单个路径组件上限是 255 **字节**,超了
2022    ///   `create_dir_all` 直接报 `File name too long`(ENAMETOOLONG)。
2023    ///
2024    /// 这里刻意不写 `"某某".repeat(45)` 这种字面量:Windows 按 UTF-16
2025    /// 计数,同样的字面量在本机建得出来、在 Linux 上建不出来,于是
2026    /// **本地全绿而 CI 挂**,报错还只是一句 os error 36,看不出是名字太长。
2027    /// 按字节算长度就不会有这种平台差异。
2028    fn long_dir_name(unit: &str) -> String {
2029        let unit_len = unit.len();
2030        assert!(unit_len > 0, "重复单元不能为空");
2031        // 取 200 字节上下:稳稳越过 128 的下限,又离 255 有余量
2032        let times = 200_usize.div_euclid(unit_len).max(1);
2033        let name = unit.repeat(times);
2034        assert!(
2035            name.len() > 128,
2036            "目录名 {} 字节,不足以触发截断+边车",
2037            name.len()
2038        );
2039        assert!(
2040            name.len() <= 255,
2041            "目录名 {} 字节,超过 Linux 单组件上限,测试自身会建不出目录",
2042            name.len()
2043        );
2044        name
2045    }
2046
2047    #[cfg(unix)]
2048    #[test]
2049    fn tree_mode_reports_symlinks_as_skipped_and_does_not_follow() {
2050        // 树形模式存不下链接(每个条目是独立 .omy 文件,没有容器索引那样的
2051        // 地方记目标),所以必须如实报成跳过。
2052        //
2053        // 更重要的是不能跟随:`std::fs::read` 会读到**目标的内容**,于是
2054        // 链接被加密成一个普通文件。解出来内容看着完全正常,只是类型从
2055        // 链接变成了文件——不比对类型根本发现不了。
2056        let root = std::env::temp_dir().join("omy-tree-symlink-src");
2057        let _ = std::fs::remove_dir_all(&root);
2058        std::fs::create_dir_all(&root).unwrap();
2059        std::fs::write(root.join("real.txt"), b"real body").unwrap();
2060        std::os::unix::fs::symlink("real.txt", root.join("link.txt")).unwrap();
2061
2062        let out = std::env::temp_dir().join("omy-tree-symlink-out");
2063        let _ = std::fs::remove_dir_all(&out);
2064        std::fs::create_dir_all(&out).unwrap();
2065
2066        let ks = [kek()];
2067        let rep = encrypt_tree(&root, &out, &ks, &[3u8; 16], &opts(), None).unwrap();
2068
2069        assert_eq!(rep.files, 1, "只有 real.txt 该被加密;跟随链接会变成 2");
2070        assert!(
2071            rep.skipped.iter().any(|s| s.reason == SkipReason::Symlink),
2072            "链接必须如实报成跳过,实际 {:?}",
2073            rep.skipped
2074        );
2075
2076        let _ = std::fs::remove_dir_all(&root);
2077        let _ = std::fs::remove_dir_all(&out);
2078    }
2079
2080    #[test]
2081    fn find_any_file_skips_the_name_sidecar() {
2082        // 边车文件叫 `.omy-name`,它以 `.omy` 开头但不是加密文件。
2083        // 把它当成样本去读头部只会失败,而失败的表现是「解不开目录名」,
2084        // 会被误认为密码不对。
2085        //
2086        // 这条同时守「只有子目录时要能往下找」:根目录下没有直接的文件。
2087        let root = std::env::temp_dir().join("omy-find-any-sidecar");
2088        let _ = std::fs::remove_dir_all(&root);
2089        std::fs::create_dir_all(root.join("sub")).unwrap();
2090        // 边车放在**上层**,真文件放在下层:如果实现按广度优先且不排除
2091        // 边车,就会先撞上边车
2092        std::fs::write(root.join(crate::dirname::DIRNAME_SIDECAR), b"not a file").unwrap();
2093        std::fs::write(root.join("sub/aabb.omy"), b"pretend header").unwrap();
2094
2095        let got = find_any_file(&root).expect("应当找到子目录里的 .omy");
2096        assert_eq!(
2097            got.file_name().and_then(|s| s.to_str()),
2098            Some("aabb.omy"),
2099            "拿到的必须是真加密文件而不是边车,实际 {got:?}"
2100        );
2101
2102        let _ = std::fs::remove_dir_all(&root);
2103    }
2104
2105    #[test]
2106    fn find_any_file_returns_none_for_tree_without_files() {
2107        // 只有目录没有文件时必须返回 None 而不是随便给一个路径。
2108        // 给错的话调用方会拿它去读头部,得到一个含糊的 IO 错误
2109        let root = std::env::temp_dir().join("omy-find-any-empty");
2110        let _ = std::fs::remove_dir_all(&root);
2111        std::fs::create_dir_all(root.join("a/b/c")).unwrap();
2112        assert!(find_any_file(&root).is_none(), "空树应当返回 None");
2113        let _ = std::fs::remove_dir_all(&root);
2114    }
2115
2116    #[test]
2117    fn roundtrips_whole_tree() {
2118        let src = make_tree("round");
2119        let enc_parent = std::env::temp_dir().join("omy-tree-round-enc");
2120        let dec_parent = std::env::temp_dir().join("omy-tree-round-dec");
2121        let _ = std::fs::remove_dir_all(&enc_parent);
2122        let _ = std::fs::remove_dir_all(&dec_parent);
2123        std::fs::create_dir_all(&enc_parent).unwrap();
2124        std::fs::create_dir_all(&dec_parent).unwrap();
2125
2126        let ks = [kek()];
2127        let rep = encrypt_tree(&src, &enc_parent, &ks, &[3u8; 16], &opts(), None).unwrap();
2128        assert_eq!(rep.files, 3, "三个文件都要加密");
2129        assert_eq!(rep.dirs, 4, "根 + 文档 + 草稿 + empty");
2130
2131        let out = decrypt_tree(
2132            &rep.root,
2133            &dec_parent,
2134            &ks,
2135            &[3u8; 16],
2136            CipherId::ChaCha20Poly1305,
2137            None,
2138        )
2139        .unwrap();
2140
2141        // 逐个核对内容,而不只看文件数——数量对但内容串位是最难查的形态
2142        assert_eq!(
2143            std::fs::read(out.root.join("a.txt")).unwrap(),
2144            b"content of a"
2145        );
2146        assert_eq!(
2147            std::fs::read(out.root.join("文档/报告.docx")).unwrap(),
2148            b"report body"
2149        );
2150        assert_eq!(
2151            std::fs::read(out.root.join("文档/草稿/draft.md")).unwrap(),
2152            b"# draft"
2153        );
2154        assert!(out.root.join("empty").is_dir(), "空目录也要还原");
2155        assert_eq!(
2156            out.root.file_name().unwrap().to_string_lossy(),
2157            "omy-tree-round",
2158            "根目录名要还原成原名"
2159        );
2160
2161        for p in [&src, &enc_parent, &dec_parent] {
2162            let _ = std::fs::remove_dir_all(p);
2163        }
2164    }
2165
2166    #[test]
2167    fn no_plaintext_name_appears_on_disk() {
2168        // 这是树形模式的核心承诺:结构可见但名字不可见。
2169        // 遍历密文树的所有路径,确认没有任何明文名残留
2170        let src = make_tree("names");
2171        let enc_parent = std::env::temp_dir().join("omy-tree-names-enc");
2172        let _ = std::fs::remove_dir_all(&enc_parent);
2173        std::fs::create_dir_all(&enc_parent).unwrap();
2174
2175        let ks = [kek()];
2176        let rep = encrypt_tree(&src, &enc_parent, &ks, &[3u8; 16], &opts(), None).unwrap();
2177
2178        let mut all = Vec::new();
2179        collect_paths(&rep.root, &mut all);
2180        let joined = all.join("|");
2181        for leaked in ["文档", "草稿", "报告", "draft", "a.txt", "empty"] {
2182            assert!(
2183                !joined.contains(leaked),
2184                "明文名 {leaked} 出现在密文树的路径里:{joined}"
2185            );
2186        }
2187
2188        for p in [&src, &enc_parent] {
2189            let _ = std::fs::remove_dir_all(p);
2190        }
2191    }
2192
2193    #[test]
2194    fn structure_is_preserved_and_therefore_leaked() {
2195        // 反证:树形模式**故意**保留结构。这条断言存在的意义是让
2196        // 「泄露了什么」变成可验证的事实,而不是文档里一句提醒——
2197        // 哪天有人以为这个模式也藏结构,这条会告诉他不是
2198        let src = make_tree("leak");
2199        let enc_parent = std::env::temp_dir().join("omy-tree-leak-enc");
2200        let _ = std::fs::remove_dir_all(&enc_parent);
2201        std::fs::create_dir_all(&enc_parent).unwrap();
2202
2203        let ks = [kek()];
2204        let rep = encrypt_tree(&src, &enc_parent, &ks, &[3u8; 16], &opts(), None).unwrap();
2205
2206        // 不解密也能数出:3 个文件、4 层结构
2207        let mut files = 0;
2208        let mut dirs = 0;
2209        count(&rep.root, &mut files, &mut dirs);
2210        assert_eq!(files, 3, "文件数对外可见——这正是 N6 声明的泄露");
2211        assert_eq!(dirs, 4, "目录数同样可见");
2212
2213        // 改用两层目录名结构后,每个密文目录都恒定带一份 .omy-keys。
2214        // 这条把新增的泄露面钉成事实:**边车个数等于目录个数**。
2215        // 边车长度固定 408 字节,所以不泄露钥匙数量;但它确实又多了
2216        // 一个「这是 omy 加密目录」的指纹。
2217        //
2218        // 反过来这条也守着「每个目录都必须有边车」:少一个,那个目录
2219        // 被单独拷走之后名字就永远解不开了
2220        let mut sidecars = 0;
2221        count_sidecars(&rep.root, &mut sidecars);
2222        assert_eq!(sidecars, dirs, "每个密文目录都该有一份边车");
2223
2224        // 文案必须具体到「能数出多少文件」,而不是抽象的「泄露元数据」
2225        let notice = TreeReport::leak_notice();
2226        assert!(notice.contains("多少文件"), "告知文案要说清泄露什么");
2227        assert!(notice.contains("看不到文件名"), "也要说清藏住了什么");
2228
2229        for p in [&src, &enc_parent] {
2230            let _ = std::fs::remove_dir_all(p);
2231        }
2232    }
2233
2234    #[test]
2235    fn wrong_password_cannot_decrypt_tree() {
2236        let src = make_tree("wrongpw");
2237        let enc_parent = std::env::temp_dir().join("omy-tree-wrongpw-enc");
2238        let dec_parent = std::env::temp_dir().join("omy-tree-wrongpw-dec");
2239        for p in [&enc_parent, &dec_parent] {
2240            let _ = std::fs::remove_dir_all(p);
2241            std::fs::create_dir_all(p).unwrap();
2242        }
2243
2244        let rep =
2245            encrypt_tree(&src, &enc_parent, &[kek()], &[3u8; 16], &opts(), None).unwrap();
2246
2247        let bad = Kek::from_password(b"wrong", &[3u8; 16], Argon2Params::TEST_WEAK).unwrap();
2248        assert!(
2249            decrypt_tree(
2250                &rep.root,
2251                &dec_parent,
2252                &[bad],
2253                &[3u8; 16],
2254                CipherId::ChaCha20Poly1305,
2255                None
2256            )
2257            .is_err(),
2258            "错密码必须解不开"
2259        );
2260
2261        for p in [&src, &enc_parent, &dec_parent] {
2262            let _ = std::fs::remove_dir_all(p);
2263        }
2264    }
2265
2266    #[test]
2267    fn same_content_files_get_different_disk_names() {
2268        // 磁盘名取 file_uuid 而不是名字/内容的哈希。若取后者,
2269        // 两个同名或同内容的文件会得到相同磁盘名——既撞名,
2270        // 又泄露「这两个文件一样」
2271        let root = std::env::temp_dir().join("omy-tree-dup");
2272        let _ = std::fs::remove_dir_all(&root);
2273        std::fs::create_dir_all(root.join("d1")).unwrap();
2274        std::fs::create_dir_all(root.join("d2")).unwrap();
2275        std::fs::write(root.join("d1/same.txt"), b"identical").unwrap();
2276        std::fs::write(root.join("d2/same.txt"), b"identical").unwrap();
2277
2278        let enc_parent = std::env::temp_dir().join("omy-tree-dup-enc");
2279        let _ = std::fs::remove_dir_all(&enc_parent);
2280        std::fs::create_dir_all(&enc_parent).unwrap();
2281
2282        let rep =
2283            encrypt_tree(&root, &enc_parent, &[kek()], &[3u8; 16], &opts(), None).unwrap();
2284        let mut names = Vec::new();
2285        collect_file_names(&rep.root, &mut names);
2286        assert_eq!(names.len(), 2);
2287        assert_ne!(names[0], names[1], "同名同内容的文件不该得到相同磁盘名");
2288
2289        for p in [&root, &enc_parent] {
2290            let _ = std::fs::remove_dir_all(p);
2291        }
2292    }
2293
2294    #[test]
2295    fn long_directory_name_survives_roundtrip() {
2296        // 超长目录名走截断 + 边车文件那条路。这条端到端确认边车真的
2297        // 被写进去且被读出来——单测里 encrypt_dirname 返回 sidecar
2298        // 不代表调用方写了它
2299        let root = std::env::temp_dir().join("omy-tree-long");
2300        let _ = std::fs::remove_dir_all(&root);
2301        // 长度要同时满足两个约束,见 `long_dir_name` 的说明
2302        let long = long_dir_name("很长的目录名");
2303        std::fs::create_dir_all(root.join(&long)).unwrap();
2304        std::fs::write(root.join(&long).join("x.txt"), b"deep").unwrap();
2305
2306        let enc_parent = std::env::temp_dir().join("omy-tree-long-enc");
2307        let dec_parent = std::env::temp_dir().join("omy-tree-long-dec");
2308        for p in [&enc_parent, &dec_parent] {
2309            let _ = std::fs::remove_dir_all(p);
2310            std::fs::create_dir_all(p).unwrap();
2311        }
2312
2313        let rep =
2314            encrypt_tree(&root, &enc_parent, &[kek()], &[3u8; 16], &opts(), None).unwrap();
2315        assert!(rep.long_names >= 1, "该报告有超长目录名");
2316
2317        let out = decrypt_tree(
2318            &rep.root,
2319            &dec_parent,
2320            &[kek()],
2321            &[3u8; 16],
2322            CipherId::ChaCha20Poly1305,
2323            None,
2324        )
2325        .unwrap();
2326        assert_eq!(
2327            std::fs::read(out.root.join(&long).join("x.txt")).unwrap(),
2328            b"deep",
2329            "超长目录名要能完整还原"
2330        );
2331
2332        for p in [&root, &enc_parent, &dec_parent] {
2333            let _ = std::fs::remove_dir_all(p);
2334        }
2335    }
2336
2337    #[test]
2338    fn sidecar_file_is_not_restored_as_user_data() {
2339        // 边车文件是我们自己的记账。还原时把它当用户文件写出来,
2340        // 用户会在自己的目录里看到一个莫名的 .omy-name
2341        let root = std::env::temp_dir().join("omy-tree-sc");
2342        let _ = std::fs::remove_dir_all(&root);
2343        let long = long_dir_name("长目录名");
2344        std::fs::create_dir_all(root.join(&long)).unwrap();
2345        std::fs::write(root.join(&long).join("f.txt"), b"x").unwrap();
2346
2347        let enc_parent = std::env::temp_dir().join("omy-tree-sc-enc");
2348        let dec_parent = std::env::temp_dir().join("omy-tree-sc-dec");
2349        for p in [&enc_parent, &dec_parent] {
2350            let _ = std::fs::remove_dir_all(p);
2351            std::fs::create_dir_all(p).unwrap();
2352        }
2353
2354        let rep =
2355            encrypt_tree(&root, &enc_parent, &[kek()], &[3u8; 16], &opts(), None).unwrap();
2356        // 前提校验:这条测试的意义在于「边车文件存在时不被当用户数据还原」,
2357        // 所以必须先确认真的产生了边车。否则名字一旦不够长就不走截断那条路,
2358        // 测试会在什么都没覆盖的情况下照样变绿——变异测试实测到过这个假绿。
2359        assert!(
2360            rep.long_names >= 1,
2361            "该用例需要触发截断+边车,实际 long_names={}",
2362            rep.long_names
2363        );
2364
2365        let out = decrypt_tree(
2366            &rep.root,
2367            &dec_parent,
2368            &[kek()],
2369            &[3u8; 16],
2370            CipherId::ChaCha20Poly1305,
2371            None,
2372        )
2373        .unwrap();
2374
2375        let mut all = Vec::new();
2376        collect_paths(&out.root, &mut all);
2377        assert!(
2378            !all.iter().any(|p| p.contains(DIRNAME_SIDECAR)),
2379            "还原结果里不该出现边车文件:{all:?}"
2380        );
2381        // 上面那条断言不够:把 `name == DIRNAME_SIDECAR` 的跳过删掉后,
2382        // 边车文件会被 is_omy_file 拦下、不会写出去,所以那条依然通过。
2383        // 但它会以 NotRegular 出现在 skipped 里——用户看到一个莫名的
2384        // 「跳过了 .omy-name」,会以为自己的数据丢了东西。
2385        // 干净的树还原后 skipped 必须是空的
2386        assert!(
2387            out.skipped.is_empty(),
2388            "干净的树还原后不该有跳过项,实际 {:?}",
2389            out.skipped
2390        );
2391
2392        for p in [&root, &enc_parent, &dec_parent] {
2393            let _ = std::fs::remove_dir_all(p);
2394        }
2395    }
2396
2397    #[test]
2398    fn malicious_filename_in_ciphertext_cannot_escape() {
2399        // 文件名来自**密文内部**,是攻击者可控的:拿到密钥的人(或另一个
2400        // 实现)可以造一个 TLV_FILENAME 写着 `../../evil.txt` 的文件。
2401        // 还原时若不过 sanitize,写盘位置就跑到目标目录之外去了。
2402        //
2403        // 变异测试抓出来的缺口:原有的 roundtrip 测试用的都是正常文件名,
2404        // 把 sanitize 那行删掉照样全绿。这条断言必须用真正带 `../` 的名字。
2405        //
2406        // 逃逸落点必须留在自己的沙盒里。第一版把 dec_parent 直接放在
2407        // 系统临时目录下,于是变异跑的时候真的在 %TEMP% 根上写出了
2408        // `escaped.txt`——它留在那儿,导致**还原源码后这条测试依然失败**,
2409        // 看起来像产品有问题。所以套一层 sandbox/a/b:`../..` 最远只能
2410        // 逃到 sandbox 内,随 remove_dir_all 一起清掉。
2411        let sandbox = std::env::temp_dir().join("omy-tree-evil-sandbox");
2412        let _ = std::fs::remove_dir_all(&sandbox);
2413        let enc_parent = sandbox.join("enc");
2414        let dec_parent = sandbox.join("a/b");
2415        for p in [&enc_parent, &dec_parent] {
2416            std::fs::create_dir_all(p).unwrap();
2417        }
2418
2419        // 先正常加密一棵树,拿到密文根目录
2420        let src = make_tree("evil");
2421        let ks = [kek()];
2422        let rep = encrypt_tree(&src, &enc_parent, &ks, &[3u8; 16], &opts(), None).unwrap();
2423
2424        // 手工造一个文件名带路径逃逸的加密文件,塞进密文目录
2425        let mut evil = opts();
2426        evil.filename = Some(String::from("../../escaped.txt"));
2427        let rnd = RandomMaterial::generate();
2428        let ef = crate::file::encrypt(b"pwned", &ks, &[3u8; 16], &evil, &rnd).unwrap();
2429        std::fs::write(rep.root.join("deadbeef.omy"), &ef.bytes).unwrap();
2430
2431        let out = decrypt_tree(
2432            &rep.root,
2433            &dec_parent,
2434            &ks,
2435            &[3u8; 16],
2436            CipherId::ChaCha20Poly1305,
2437            None,
2438        )
2439        .unwrap();
2440
2441        // 还原根目录之外的每一层都不能出现那个文件
2442        for up in [sandbox.join("a"), sandbox.clone(), dec_parent.clone()] {
2443            let leaked = up.join("escaped.txt");
2444            assert!(!leaked.exists(), "文件逃出了还原根目录:{}", leaked.display());
2445        }
2446        // 内容应当落在还原根目录内,名字里的分隔符被替换掉
2447        let mut names = Vec::new();
2448        collect_file_names(&out.root, &mut names);
2449        assert!(
2450            names.iter().any(|n| n.contains("escaped.txt") && !n.contains('/')),
2451            "逃逸名应被清洗后留在根目录内,实际 {names:?}"
2452        );
2453
2454        let _ = std::fs::remove_dir_all(&src);
2455        let _ = std::fs::remove_dir_all(&sandbox);
2456    }
2457
2458    #[test]
2459    fn rejects_non_directory() {
2460        let f = std::env::temp_dir().join("omy-tree-not-dir.txt");
2461        std::fs::write(&f, b"x").unwrap();
2462        assert!(
2463            encrypt_tree(&f, &std::env::temp_dir(), &[kek()], &[3u8; 16], &opts(), None).is_err(),
2464            "对文件调用必须报错"
2465        );
2466        let _ = std::fs::remove_file(&f);
2467    }
2468
2469    #[test]
2470    fn progress_reports_every_file() {
2471        let src = make_tree("prog");
2472        let enc_parent = std::env::temp_dir().join("omy-tree-prog-enc");
2473        let _ = std::fs::remove_dir_all(&enc_parent);
2474        std::fs::create_dir_all(&enc_parent).unwrap();
2475
2476        let mut seen = Vec::new();
2477        let mut cb = |n: &str, s: u64| seen.push((n.to_owned(), s));
2478        let rep =
2479            encrypt_tree(&src, &enc_parent, &[kek()], &[3u8; 16], &opts(), Some(&mut cb)).unwrap();
2480        assert_eq!(seen.len(), rep.files, "每个文件都要回调一次");
2481
2482        for p in [&src, &enc_parent] {
2483            let _ = std::fs::remove_dir_all(p);
2484        }
2485    }
2486
2487    // ---- 测试辅助 ----
2488
2489    fn collect_paths(dir: &Path, out: &mut Vec<String>) {
2490        let Ok(rd) = std::fs::read_dir(dir) else { return };
2491        for e in rd.flatten() {
2492            out.push(e.file_name().to_string_lossy().into_owned());
2493            if e.path().is_dir() {
2494                collect_paths(&e.path(), out);
2495            }
2496        }
2497    }
2498
2499    fn collect_file_names(dir: &Path, out: &mut Vec<String>) {
2500        let Ok(rd) = std::fs::read_dir(dir) else { return };
2501        for e in rd.flatten() {
2502            if e.path().is_dir() {
2503                collect_file_names(&e.path(), out);
2504                continue;
2505            }
2506            // 边车不是用户文件。算进来会让「两个同内容文件磁盘名不同」
2507            // 这类断言拿到一堆同名的 .omy-keys,看着像磁盘名撞了
2508            let name = e.file_name().to_string_lossy().into_owned();
2509            if name == crate::dirsidecar::KEYS_SIDECAR || name == DIRNAME_SIDECAR {
2510                continue;
2511            }
2512            out.push(name);
2513        }
2514    }
2515
2516    /// 数用户文件与目录。
2517    ///
2518    /// 边车不计入:它是我们自己的记账,不是用户数据。把它算进来的话
2519    /// 「外界能数出几个文件」会虚增一倍——那不是真实的泄露面,
2520    /// 会让 N6 那条断言失去意义。
2521    fn count(dir: &Path, files: &mut usize, dirs: &mut usize) {
2522        *dirs = dirs.saturating_add(1);
2523        let Ok(rd) = std::fs::read_dir(dir) else { return };
2524        for e in rd.flatten() {
2525            if e.path().is_dir() {
2526                count(&e.path(), files, dirs);
2527                continue;
2528            }
2529            let name = e.file_name().to_string_lossy().into_owned();
2530            if name == crate::dirsidecar::KEYS_SIDECAR || name == DIRNAME_SIDECAR {
2531                continue;
2532            }
2533            *files = files.saturating_add(1);
2534        }
2535    }
2536
2537    /// 数边车个数,用于断言「每个密文目录都有一份」。
2538    fn count_sidecars(dir: &Path, n: &mut usize) {
2539        let Ok(rd) = std::fs::read_dir(dir) else { return };
2540        for e in rd.flatten() {
2541            if e.path().is_dir() {
2542                count_sidecars(&e.path(), n);
2543            } else if e.file_name().to_string_lossy() == crate::dirsidecar::KEYS_SIDECAR {
2544                *n = n.saturating_add(1);
2545            }
2546        }
2547    }
2548}