Skip to main content

omy_core/
dirname.rs

1//! 树形模式的目录名加密(文档 05 §3.2、§3.3)。
2//!
3//! # 为什么目录名需要单独一套机制
4//!
5//! 文件名有地方藏——写进该文件自己的 `TLV_FILENAME`,磁盘上的名字随便叫
6//! 什么都行。**目录名没有这个奢侈**:它就是磁盘上的一个真实路径组件,
7//! 没有任何文件头可以承载它,只能把密文编码进名字本身。
8//!
9//! 这带来了三个约束,下面每一条都是踩过的坑或明确的设计决策。
10//!
11//! # 一、必须用 base32,不能用 base64
12//!
13//! 这不是风格偏好,两条都是硬伤:
14//!
15//! | 问题 | base64 | base32 |
16//! |---|---|---|
17//! | 含 `/` | 是,是路径分隔符 | 否 |
18//! | 大小写敏感 | 是 | 否(全大写) |
19//!
20//! 大小写那条尤其致命:**Windows 与 APFS 默认大小写不敏感**,base64 下
21//! 两个只差大小写的密文目录名(`aB...` 与 `Ab...`)在磁盘上会直接撞车,
22//! 而且是随机撞——同一个明文目录名每次加密的 nonce 不同,密文不同,
23//! 只在偶然差一个字母大小写时才炸。这种缺陷在测试里几乎不可能复现。
24//!
25//! 代价是长度膨胀 1.6×(base64 是 1.33×),见下条。
26//!
27//! # 二、长度限制会真的撞上
28//!
29//! 多数文件系统单个路径组件上限 255 字节。我们的密文结构是
30//! `nonce(12) || ciphertext || tag(16)`,再乘 base32 的 1.6×,
31//! 于是原始目录名的可用长度只剩约 130 字节——中文按 UTF-8 算一字三节,
32//! 也就是 **40 多个汉字**。这不是极端情况,「2024年第三季度市场调研原始
33//! 数据备份」这种目录名很常见。
34//!
35//! 超长的处理:截断 base32 输出 + 追加哈希后缀保证唯一性。此时**完整
36//! 目录名无法从名字本身恢复**,所以额外在该目录下放一个
37//! [`DIRNAME_SIDECAR`] 文件存完整密文。这是不得已的妥协,但比「加密到
38//! 一半突然报名字太长」要好。
39//!
40//! # 三、密钥是 vault 级的,不是文件级的
41//!
42//! 目录名不属于任何单个文件,无法用某个文件的 FEK。所以用 KEK 直接派生:
43//!
44//! ```text
45//! dirname_key = HKDF(KEK, salt=vault_salt, info="omy/v1/dirname")
46//! ```
47//!
48//! **这有一个必须写进文档、也必须在 UI 里告知的副作用**:目录结构的
49//! 可见性绑定整个 vault,而不是单个文件的 slot。多密码场景下,能解开
50//! vault 的**任一**密码都能看到完整目录树。如果有人期望「给同事一个
51//! 只能看部分内容的密码」,树形模式做不到——这是设计约束不是缺陷,
52//! 但不说清楚就会变成安全事故。
53//!
54//! # 为什么手写 base32 而不加依赖
55//!
56//! 与 [`crate::pack`] 里不引入 `walkdir` 同一个理由:core 的依赖目前只有
57//! 密码学与格式相关的几个 crate,保持这份克制让 core 能被任意项目复用,
58//! 审计面也更小。base32 编解码是三十行的位操作,不值得为它引入一个新的
59//! 供应链节点。
60
61use crate::crypto::{CipherId, Kek, NONCE_LEN, SecretKey};
62use crate::error::{Error, Result};
63
64/// HKDF info:目录名密钥。
65///
66/// 与 `crypto.rs` 里那几个 `INFO_*` 同族,但放在这里而不是那边——
67/// 目录名是树形模式独有的概念,crypto 层不需要知道它存在。
68const INFO_DIRNAME: &[u8] = b"omy/v1/dirname";
69
70/// 密文目录名的后缀。
71///
72/// 加了后缀才能在扫描时一眼区分「这是加密目录」和「用户自己建的目录」。
73/// 与文件用同一个 `.omy`:对用户而言它们是同一种东西的两种形态,
74/// 用两个后缀只会让人猜哪个是哪个。
75pub const DIR_SUFFIX: &str = ".omy";
76
77/// 存放超长目录名完整密文的边车文件名。
78///
79/// 放在被截断的那个目录**内部**,而不是父目录里:这样目录被整体移动或
80/// 复制时它跟着走。放父目录的话,移动一个目录会让它的名字变成不可恢复。
81pub const DIRNAME_SIDECAR: &str = ".omy-name";
82
83/// 单个路径组件的字节上限。
84///
85/// 255 是 NTFS / ext4 / APFS 的共同下限。不取更大值——跨平台是硬需求,
86/// 按最宽松的文件系统设计会导致在别的平台上打不开。
87const MAX_COMPONENT: usize = 255;
88
89/// 截断后保留的 base32 字符数。
90///
91/// 留出 `~` 分隔符、8 字符哈希与 `.omy` 后缀的空间:
92/// `200 + 1 + 8 + 4 = 213`,离 255 还有余量。余量是留给
93/// **调用方可能再加前缀**的——例如分片模式的 `.001` 之类。
94const TRUNCATE_AT: usize = 200;
95
96/// 截断名的哈希后缀长度(base32 字符数)。
97///
98/// 5 字节哈希 → base32 正好 8 字符,无填充。取 5 字节而不是 4:
99/// 生日界下 4 字节在同一目录几万个子目录时碰撞概率已不可忽略,
100/// 5 字节把它推到 2^20 量级,足够了。
101const HASH_SUFFIX_LEN: usize = 8;
102
103/// RFC 4648 base32 字母表(全大写,无填充)。
104const B32: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
105
106/// 目录名加密密钥。
107///
108/// 独立类型而不是裸 `SecretKey`:这样类型系统会阻止把它误用成文件密钥
109/// 或反之。两者都是 32 字节,混用不会编译报错但会产出解不开的数据。
110#[derive(Debug)]
111pub struct DirnameKey(SecretKey);
112
113impl DirnameKey {
114    /// 从 KEK 与 vault salt 派生。
115    ///
116    /// 同一个 vault 内必须得到同一个密钥,否则同一棵树里的目录名会用
117    /// 不同密钥加密,解开时部分目录变成乱码。这由 `vault_salt` 保证——
118    /// 它在 vault 内是固定的。
119    #[must_use]
120    pub fn derive(kek: &Kek, vault_salt: &[u8; 16]) -> Self {
121        // 复用 crypto 层的 HKDF:那里已经处理了输出长度不可能失败的情形
122        Self(kek.derive_dirname_key(vault_salt, INFO_DIRNAME))
123    }
124
125    /// 借出底层密钥。仅供本模块内部与测试使用。
126    #[must_use]
127    const fn as_key(&self) -> &SecretKey {
128        &self.0
129    }
130}
131
132/// 加密一个目录名的结果。
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct EncryptedDirname {
135    /// 落在磁盘上的名字(已含 `.omy` 后缀)。
136    pub disk_name: String,
137    /// 名字被截断时,需要写进 [`DIRNAME_SIDECAR`] 的完整密文。
138    ///
139    /// `None` 表示 `disk_name` 自身已包含完整信息,不需要边车文件。
140    /// 调用方**必须**处理 `Some` 的情况,否则该目录名将无法还原。
141    pub sidecar: Option<Vec<u8>>,
142}
143
144/// 加密目录名。
145///
146/// `nonce` 由调用方提供而不是内部随机生成,理由与
147/// [`crate::file::RandomMaterial`] 相同:可确定性复现是测试向量的前提。
148///
149/// # Errors
150///
151/// - [`Error::MalformedTlv`]:目录名为空、含路径分隔符、或 AEAD 失败
152pub fn encrypt_dirname(
153    name: &str,
154    key: &DirnameKey,
155    nonce: &[u8; NONCE_LEN],
156    cipher: CipherId,
157) -> Result<EncryptedDirname> {
158    encrypt_with_key(name, key.as_key(), nonce, cipher)
159}
160
161/// 目录名加密的实际实现,按裸密钥工作。
162///
163/// 抽出来是因为现在有两种密钥来源(单钥匙派生的 [`DirnameKey`]、随机的
164/// [`crate::dirsidecar::DirKey`]),而截断、边车、base32 这些逻辑一个字
165/// 都不该有两份——同一套规则实现两遍,迟早在某次改动里只改了一边。
166fn encrypt_with_key(
167    name: &str,
168    key: &SecretKey,
169    nonce: &[u8; NONCE_LEN],
170    cipher: CipherId,
171) -> Result<EncryptedDirname> {
172    if name.is_empty() {
173        return Err(Error::MalformedTlv {
174            tlv_type: 0,
175            reason: "directory name must not be empty",
176        });
177    }
178    // 含分隔符的「目录名」说明调用方传了路径而不是单个组件。
179    // 静默接受会产出一个跨目录的密文名,解开时凭空多出一层
180    if name.contains('/') || name.contains('\\') {
181        return Err(Error::MalformedTlv {
182            tlv_type: 0,
183            reason: "directory name must be a single component",
184        });
185    }
186
187    // nonce 必须前置存储:解密时要先取出它才能解密,而它本身不是秘密
188    let ct = cipher.encrypt(key, nonce, name.as_bytes(), &[])?;
189    let mut blob = Vec::with_capacity(NONCE_LEN.saturating_add(ct.len()));
190    blob.extend_from_slice(nonce);
191    blob.extend_from_slice(&ct);
192
193    let encoded = base32_encode(&blob);
194
195    // 常见情况:编码后没超限,名字自身就是完整信息
196    if encoded.len().saturating_add(DIR_SUFFIX.len()) <= MAX_COMPONENT {
197        return Ok(EncryptedDirname {
198            disk_name: format!("{encoded}{DIR_SUFFIX}"),
199            sidecar: None,
200        });
201    }
202
203    // 超长:截断 + 哈希后缀。哈希算的是**完整密文**而不是明文名——
204    // 明文哈希会让相同目录名产出相同后缀,等于泄露「这两个目录同名」
205    let digest = crate::util::blake2b_256(&blob);
206    let tail = base32_encode(digest.get(..5).unwrap_or(&digest));
207    let head = encoded.get(..TRUNCATE_AT).unwrap_or(&encoded);
208    Ok(EncryptedDirname {
209        disk_name: format!(
210            "{head}~{}{DIR_SUFFIX}",
211            tail.get(..HASH_SUFFIX_LEN).unwrap_or(&tail)
212        ),
213        sidecar: Some(blob),
214    })
215}
216
217/// 解密目录名。
218///
219/// `sidecar` 是从 [`DIRNAME_SIDECAR`] 读到的内容(若该文件存在)。
220/// 名字被截断过时必须提供,否则无法还原。
221///
222/// # Errors
223///
224/// - [`Error::MalformedTlv`]:不是密文目录名、base32 非法、密文过短
225/// - [`Error::MalformedTlv`]:名字被截断但未提供 sidecar
226/// - [`Error::ContentHashMismatch`]:AEAD 认证失败(密钥不对或被篡改)
227pub fn decrypt_dirname(
228    disk_name: &str,
229    sidecar: Option<&[u8]>,
230    key: &DirnameKey,
231    cipher: CipherId,
232) -> Result<String> {
233    decrypt_with_key(disk_name, sidecar, key.as_key(), cipher)
234}
235
236/// 目录名解密的实际实现,按裸密钥工作。理由同 [`encrypt_with_key`]。
237fn decrypt_with_key(
238    disk_name: &str,
239    sidecar: Option<&[u8]>,
240    key: &SecretKey,
241    cipher: CipherId,
242) -> Result<String> {
243    let stem = disk_name.strip_suffix(DIR_SUFFIX).ok_or(Error::MalformedTlv {
244        tlv_type: 0,
245        reason: "not an encrypted directory name",
246    })?;
247
248    // 截断过的名字里有 `~`,此时名字本身不足以还原,必须靠 sidecar。
249    // 明确报错而不是尝试解半截密文:后者会得到 AEAD 认证失败,
250    // 那个错误信息会把人引向「密码不对」的错误方向
251    let blob = if stem.contains('~') {
252        sidecar
253            .ok_or(Error::MalformedTlv {
254                tlv_type: 0,
255                reason: "truncated directory name requires sidecar file",
256            })?
257            .to_vec()
258    } else {
259        base32_decode(stem)?
260    };
261
262    let (nonce, ct) = blob.split_at_checked(NONCE_LEN).ok_or(Error::MalformedTlv {
263        tlv_type: 0,
264        reason: "encrypted directory name too short to contain a nonce",
265    })?;
266    let mut n = [0u8; NONCE_LEN];
267    n.copy_from_slice(nonce);
268
269    let plain = cipher
270        .decrypt(key, &n, ct, &[])?
271        // 认证失败最可能的原因是密钥不对(换了密码 / 不是这个 vault 的目录)。
272        // 用 ContentHashMismatch 而不是自造变体:它归到 Corrupted 退出码,
273        // 与其它 AEAD 失败一致
274        .ok_or(Error::ContentHashMismatch)?;
275
276    String::from_utf8(plain).map_err(|_| Error::MalformedTlv {
277        tlv_type: 0,
278        reason: "decrypted directory name is not valid UTF-8",
279    })
280}
281
282/// 用目录密钥加密目录名(两层结构)。
283///
284/// 与 [`encrypt_dirname`] 的区别只在密钥来源:这里收的是随机生成的
285/// [`DirKey`],它本身被每把 KEK 各包一份放进边车。
286///
287/// # 为什么要有这个而不是改 `encrypt_dirname` 的签名
288///
289/// [`DirnameKey`] 由单把 KEK 派生,结构上只能被那一把钥匙解开——这正是
290/// 多密码和恢复码在树上用不了的根因。两者的**密钥语义不同**,共用一个
291/// 函数名会让调用方分不清自己拿的是哪一种,而拿错的后果是目录名解不开。
292///
293/// # Errors
294///
295/// 同 [`encrypt_dirname`]。
296pub fn encrypt_dirname_with(
297    name: &str,
298    dk: &crate::dirsidecar::DirKey,
299    nonce: &[u8; NONCE_LEN],
300    cipher: CipherId,
301) -> Result<EncryptedDirname> {
302    encrypt_with_key(name, dk.as_key(), nonce, cipher)
303}
304
305/// 用目录密钥解密目录名(两层结构)。
306///
307/// `long_name` 是名字被截断时存下来的完整密文。注意它与「钥匙包裹边车」
308/// 是**两回事**:前者存的是目录名密文,后者存的是 DK 的包裹。
309///
310/// # Errors
311///
312/// 同 [`decrypt_dirname`]。
313pub fn decrypt_dirname_with(
314    disk_name: &str,
315    long_name: Option<&[u8]>,
316    dk: &crate::dirsidecar::DirKey,
317    cipher: CipherId,
318) -> Result<String> {
319    decrypt_with_key(disk_name, long_name, dk.as_key(), cipher)
320}
321
322/// 判断一个磁盘名是否可能是密文目录名。
323///
324/// 只做**廉价**判断(后缀 + 字符集),不尝试解密。扫描时对每个目录都要
325/// 调一次,解密的开销不该出现在这条路径上。
326#[must_use]
327pub fn looks_encrypted(disk_name: &str) -> bool {
328    let Some(stem) = disk_name.strip_suffix(DIR_SUFFIX) else {
329        return false;
330    };
331    if stem.is_empty() {
332        return false;
333    }
334    // 全大写 base32 字符集,允许一个 `~`(截断标记)
335    stem.bytes()
336        .all(|b| b == b'~' || B32.contains(&b))
337}
338
339/// base32 编码(RFC 4648,无填充)。
340///
341/// 每 5 字节 → 8 字符。手写而不用 crate,理由见模块文档。
342fn base32_encode(data: &[u8]) -> String {
343    let mut out = String::with_capacity(data.len().div_ceil(5).saturating_mul(8));
344    // 用 u64 累加器而不是逐位移:5 字节一组正好 40 位,放得下且不会溢出
345    for chunk in data.chunks(5) {
346        let mut acc = 0u64;
347        for (i, &b) in chunk.iter().enumerate() {
348            // 高位在前:第 0 字节占最高 8 位
349            let shift = 32usize.saturating_sub(i.saturating_mul(8));
350            acc |= u64::from(b) << shift;
351        }
352        // 5 字节产 8 字符,不足 5 字节时按位数向上取整
353        let chars = chunk.len().saturating_mul(8).div_ceil(5);
354        for i in 0..chars {
355            let shift = 35usize.saturating_sub(i.saturating_mul(5));
356            let idx = ((acc >> shift) & 0x1F) as usize;
357            out.push(char::from(*B32.get(idx).unwrap_or(&b'A')));
358        }
359    }
360    out
361}
362
363/// base32 解码(RFC 4648,无填充)。
364///
365/// # Errors
366///
367/// [`Error::MalformedTlv`]:出现字母表之外的字符。
368fn base32_decode(s: &str) -> Result<Vec<u8>> {
369    // 容量预估,除法截断无妨(少算一字节只是多一次 realloc)。
370    // 用 div_ceil 而不是 `/`:clippy 的 integer_division 在加密代码里是
371    // 有价值的告警,不该为一处容量计算把它关掉
372    let mut out = Vec::with_capacity(s.len().saturating_mul(5).div_ceil(8));
373    let mut acc = 0u64;
374    let mut bits = 0u32;
375    for b in s.bytes() {
376        let Some(v) = B32.iter().position(|&c| c == b) else {
377            return Err(Error::MalformedTlv {
378                tlv_type: 0,
379                reason: "invalid base32 character in directory name",
380            });
381        };
382        acc = (acc << 5) | v as u64;
383        bits = bits.saturating_add(5);
384        if bits >= 8 {
385            bits = bits.saturating_sub(8);
386            // 取出高 8 位
387            let byte = ((acc >> bits) & 0xFF) as u8;
388            out.push(byte);
389        }
390    }
391    // 余下不足 8 位的是编码填充,必须为 0。非 0 说明数据被改过——
392    // 不检查的话篡改者可以在这里塞信息而不影响解码结果
393    if bits > 0 && (acc & ((1u64 << bits).saturating_sub(1))) != 0 {
394        return Err(Error::MalformedTlv {
395            tlv_type: 0,
396            reason: "non-zero base32 padding bits",
397        });
398    }
399    Ok(out)
400}
401
402#[cfg(test)]
403#[expect(
404    clippy::unwrap_used,
405    clippy::indexing_slicing,
406    clippy::panic,
407    reason = "测试里用断言即可"
408)]
409mod tests {
410    use super::*;
411    use crate::crypto::Argon2Params;
412
413    fn test_key() -> DirnameKey {
414        let kek = Kek::from_password(b"pw", &[7u8; 16], Argon2Params::TEST_WEAK).unwrap();
415        DirnameKey::derive(&kek, &[7u8; 16])
416    }
417
418    fn nonce(seed: u8) -> [u8; NONCE_LEN] {
419        core::array::from_fn(|i| (i as u8).wrapping_mul(3) ^ seed)
420    }
421
422    #[test]
423    fn roundtrips_ascii_and_chinese() {
424        let k = test_key();
425        for name in ["docs", "工作资料", "a b c", "photos.2024"] {
426            let e = encrypt_dirname(name, &k, &nonce(1), CipherId::ChaCha20Poly1305).unwrap();
427            assert!(e.sidecar.is_none(), "{name} 不该需要边车文件");
428            let back =
429                decrypt_dirname(&e.disk_name, None, &k, CipherId::ChaCha20Poly1305).unwrap();
430            assert_eq!(back, name);
431        }
432    }
433
434    #[test]
435    fn encoded_name_has_no_slash_and_is_uppercase() {
436        // 这条守护「必须用 base32」的两个理由。base64 会在这里失败:
437        // 它含 `/`(路径分隔符)且大小写混合(Windows 上会撞名)
438        let k = test_key();
439        let e = encrypt_dirname("工作资料", &k, &nonce(2), CipherId::ChaCha20Poly1305).unwrap();
440        let stem = e.disk_name.strip_suffix(DIR_SUFFIX).unwrap();
441        assert!(!stem.contains('/'), "不能含路径分隔符");
442        assert!(!stem.contains('\\'), "不能含反斜杠");
443        assert!(
444            stem.bytes().all(|b| B32.contains(&b)),
445            "只能出现 base32 字母表内的字符,实际 {stem}"
446        );
447    }
448
449    #[test]
450    fn case_insensitive_filesystems_cannot_collide() {
451        // 大小写不敏感的文件系统上,只有「密文名不含小写」才能保证
452        // 两个不同密文不会折叠成同一个磁盘名。逐字符检查大写
453        let k = test_key();
454        for seed in 0..40u8 {
455            let e = encrypt_dirname("x", &k, &nonce(seed), CipherId::ChaCha20Poly1305).unwrap();
456            let stem = e.disk_name.strip_suffix(DIR_SUFFIX).unwrap();
457            assert_eq!(
458                stem,
459                stem.to_uppercase(),
460                "第 {seed} 个密文名含小写字母,在 Windows/APFS 上会与其它名字折叠"
461            );
462        }
463    }
464
465    #[test]
466    fn different_nonce_yields_different_ciphertext() {
467        // 相同目录名必须产出不同密文,否则「这两个目录同名」会泄露
468        let k = test_key();
469        let a = encrypt_dirname("同一个名字", &k, &nonce(1), CipherId::ChaCha20Poly1305).unwrap();
470        let b = encrypt_dirname("同一个名字", &k, &nonce(2), CipherId::ChaCha20Poly1305).unwrap();
471        assert_ne!(a.disk_name, b.disk_name, "相同明文不该产出相同密文名");
472        // 但都要能解回来
473        for e in [a, b] {
474            let back =
475                decrypt_dirname(&e.disk_name, None, &k, CipherId::ChaCha20Poly1305).unwrap();
476            assert_eq!(back, "同一个名字");
477        }
478    }
479
480    #[test]
481    fn long_name_is_truncated_with_sidecar() {
482        // 130 字节以上的名字会超过 255 组件上限。这不是极端情况:
483        // 40 多个汉字就到了
484        let k = test_key();
485        let long = "很长的目录名".repeat(40); // 6 字 × 40 = 240 字 = 720 字节
486        let e = encrypt_dirname(&long, &k, &nonce(3), CipherId::ChaCha20Poly1305).unwrap();
487
488        assert!(
489            e.disk_name.len() <= MAX_COMPONENT,
490            "截断后必须落在文件系统上限内,实际 {} 字节",
491            e.disk_name.len()
492        );
493        assert!(e.disk_name.contains('~'), "截断名要有 ~ 标记");
494        let sc = e.sidecar.as_deref().expect("截断时必须给出边车内容");
495
496        let back = decrypt_dirname(&e.disk_name, Some(sc), &k, CipherId::ChaCha20Poly1305).unwrap();
497        assert_eq!(back, long, "靠边车文件要能完整还原");
498    }
499
500    #[test]
501    fn truncated_names_of_same_plaintext_still_differ() {
502        // 变异测试抓出来的缺口:截断名的哈希后缀若算的是**明文名**,
503        // 同名的两个长目录会得到完全相同的后缀,于是观察者不用解密就知道
504        // 「这两个目录同名」——而藏住目录名正是这个模式的全部意义。
505        //
506        // 前一条 different_nonce_yields_different_ciphertext 抓不到它:
507        // 那里用的是短名字,走的是不截断的分支,压根没碰到哈希后缀。
508        let k = test_key();
509        let long = "同一个很长的目录名".repeat(30);
510        let a = encrypt_dirname(&long, &k, &nonce(11), CipherId::ChaCha20Poly1305).unwrap();
511        let b = encrypt_dirname(&long, &k, &nonce(12), CipherId::ChaCha20Poly1305).unwrap();
512
513        assert!(a.disk_name.contains('~') && b.disk_name.contains('~'), "两者都该被截断");
514        assert_ne!(a.disk_name, b.disk_name, "同名长目录不该产出相同的磁盘名");
515
516        // 后缀本身也要不同:只比整个名字的话,前 200 字符已经不同就够了,
517        // 而后缀相同仍然泄露信息
518        let suffix = |s: &str| -> String {
519            s.rsplit_once('~').map(|(_, t)| t.to_owned()).unwrap_or_default()
520        };
521        assert_ne!(
522            suffix(&a.disk_name),
523            suffix(&b.disk_name),
524            "哈希后缀相同就泄露了「这两个目录同名」"
525        );
526
527        // 而且都要能靠边车文件还原
528        for e in [a, b] {
529            let sc = e.sidecar.as_deref().unwrap();
530            let back =
531                decrypt_dirname(&e.disk_name, Some(sc), &k, CipherId::ChaCha20Poly1305).unwrap();
532            assert_eq!(back, long);
533        }
534    }
535
536    #[test]
537    fn truncated_name_without_sidecar_reports_clearly() {
538        // 不给 sidecar 时必须明确报「需要边车文件」,而不是去解半截密文
539        // 得到 AEAD 认证失败——后者会把人引向「密码不对」的错误方向
540        let k = test_key();
541        let long = "长".repeat(200);
542        let e = encrypt_dirname(&long, &k, &nonce(4), CipherId::ChaCha20Poly1305).unwrap();
543        let err = decrypt_dirname(&e.disk_name, None, &k, CipherId::ChaCha20Poly1305).unwrap_err();
544        match err {
545            Error::MalformedTlv { reason, .. } => {
546                assert!(reason.contains("sidecar"), "错误应指明缺边车文件,实际 {reason}");
547            }
548            other => panic!("应报缺边车文件,实际 {other:?}"),
549        }
550    }
551
552    #[test]
553    fn wrong_key_fails_authentication() {
554        let k = test_key();
555        let e = encrypt_dirname("secret", &k, &nonce(5), CipherId::ChaCha20Poly1305).unwrap();
556
557        let other_kek =
558            Kek::from_password(b"different", &[7u8; 16], Argon2Params::TEST_WEAK).unwrap();
559        let other = DirnameKey::derive(&other_kek, &[7u8; 16]);
560        assert!(
561            decrypt_dirname(&e.disk_name, None, &other, CipherId::ChaCha20Poly1305).is_err(),
562            "错误密钥必须解不开"
563        );
564    }
565
566    #[test]
567    fn different_vault_salt_yields_different_key() {
568        // 目录名密钥绑定 vault_salt。这条守护「不同 vault 的目录名互不可见」
569        let kek = Kek::from_password(b"pw", &[7u8; 16], Argon2Params::TEST_WEAK).unwrap();
570        let a = DirnameKey::derive(&kek, &[7u8; 16]);
571        let b = DirnameKey::derive(&kek, &[9u8; 16]);
572        let e = encrypt_dirname("x", &a, &nonce(6), CipherId::ChaCha20Poly1305).unwrap();
573        assert!(
574            decrypt_dirname(&e.disk_name, None, &b, CipherId::ChaCha20Poly1305).is_err(),
575            "换了 vault_salt 就不该解得开"
576        );
577    }
578
579    #[test]
580    fn rejects_path_components() {
581        // 传进来一个路径而不是单个组件,说明调用方用错了。
582        // 静默接受会产出跨目录的密文名,解开时凭空多一层
583        let k = test_key();
584        for bad in ["a/b", "a\\b", ""] {
585            assert!(
586                encrypt_dirname(bad, &k, &nonce(7), CipherId::ChaCha20Poly1305).is_err(),
587                "{bad:?} 必须被拒绝"
588            );
589        }
590    }
591
592    #[test]
593    fn base32_matches_rfc4648_vectors() {
594        // RFC 4648 §10 的官方测试向量。手写编解码必须对齐标准,
595        // 否则别的实现解不开我们的目录名
596        assert_eq!(base32_encode(b""), "");
597        assert_eq!(base32_encode(b"f"), "MY");
598        assert_eq!(base32_encode(b"fo"), "MZXQ");
599        assert_eq!(base32_encode(b"foo"), "MZXW6");
600        assert_eq!(base32_encode(b"foob"), "MZXW6YQ");
601        assert_eq!(base32_encode(b"fooba"), "MZXW6YTB");
602        assert_eq!(base32_encode(b"foobar"), "MZXW6YTBOI");
603    }
604
605    #[test]
606    fn base32_matches_python_stdlib_at_length_boundaries() {
607        // RFC 向量只覆盖长度 1–6 的 ASCII,而手写位操作最容易错在
608        // **长度 mod 5 的边界**上(余 1/2/3/4 字节时该产出几个字符、
609        // 末尾几位怎么填)。那几个向量恰好能被一个写错移位量的实现凑对。
610        //
611        // 下面的参考值由 Python `base64.b32encode` 独立生成——用另一个
612        // 实现做参照,而不是拿自己的输出当预期值(后者只能证明
613        // 「函数是确定性的」)。
614        let data: Vec<u8> = (0..256u32).map(|i| ((i * 11) ^ 0x5B) as u8).collect();
615        let expected: &[(usize, &str)] = &[
616            (1, "LM"),
617            (2, "LNIA"),
618            (3, "LNIE2"),
619            (4, "LNIE26Q"),
620            (5, "LNIE26TX"),
621            (6, "LNIE26TXNQ"),
622            (7, "LNIE26TXNQMQ"),
623            (8, "LNIE26TXNQMRM"),
624            (31, "LNIE26TXNQMRMAZYGURN7VGB73V6BHMKQ66KTJSTJBCXE33ECE"),
625            (32, "LNIE26TXNQMRMAZYGURN7VGB73V6BHMKQ66KTJSTJBCXE33ECEHA"),
626            (33, "LNIE26TXNQMRMAZYGURN7VGB73V6BHMKQ66KTJSTJBCXE33ECEHDW"),
627        ];
628        for &(len, want) in expected {
629            assert_eq!(
630                base32_encode(&data[..len]),
631                want,
632                "长度 {len}(mod 5 = {})与 Python 标准库不一致",
633                len % 5
634            );
635        }
636
637        // 再覆盖全部 1..=64 的字符数。上面只在 11 个长度上比对,
638        // 这条能抓到「余数长度算错」——漏掉末尾不满 5 位的组,
639        // 或者多补一个字符
640        for len in 1..=64usize {
641            let want_chars = (len * 8).div_ceil(5);
642            assert_eq!(
643                base32_encode(&data[..len]).len(),
644                want_chars,
645                "长度 {len} 应产出 {want_chars} 个字符"
646            );
647        }
648    }
649
650    #[test]
651    fn base32_roundtrips_arbitrary_bytes() {
652        // 各字节都不同,按错误偏移拼接更容易被测出来
653        let data: Vec<u8> = (0..=255u8).map(|i| i.wrapping_mul(11) ^ 0x5B).collect();
654        for len in [1usize, 2, 3, 4, 5, 6, 7, 8, 31, 32, 33, 255] {
655            let slice = &data[..len];
656            let enc = base32_encode(slice);
657            let dec = base32_decode(&enc).unwrap();
658            assert_eq!(dec, slice, "长度 {len} 往返失败");
659        }
660    }
661
662    #[test]
663    fn base32_rejects_invalid_chars() {
664        // 小写字母、数字 0/1/8/9 都不在字母表里(避免与 O/I/B/g 混淆)
665        for bad in ["mzxw", "MZXW0", "MZXW1", "MZXW8", "MZ=XW"] {
666            assert!(base32_decode(bad).is_err(), "{bad} 应被拒绝");
667        }
668    }
669
670    #[test]
671    fn base32_rejects_nonzero_padding_bits() {
672        // 编码后余下的位必须为 0。不检查的话篡改者能在这里塞信息,
673        // 而解码结果看起来完全正常
674        assert!(base32_decode("MY").is_ok(), "MY 的填充位是 0");
675        // "MZ" = 01100 11001 → 取出 0x66 后余 1 位为 1
676        assert!(base32_decode("MZ").is_err(), "非零填充位必须被拒绝");
677    }
678
679    #[test]
680    fn looks_encrypted_is_precise() {
681        let k = test_key();
682        let e = encrypt_dirname("x", &k, &nonce(8), CipherId::ChaCha20Poly1305).unwrap();
683        assert!(looks_encrypted(&e.disk_name));
684
685        // 用户自己建的目录不该被误判——否则扫描时会拿它去解密并报错,
686        // 界面上出现一堆「无法解密」的噪音
687        for plain in ["docs", "工作资料.omy", "my-stuff.omy", ".omy", "docs.txt"] {
688            assert!(!looks_encrypted(plain), "{plain} 不该被判为密文目录名");
689        }
690    }
691}