use crate::crypto::{CipherId, Kek, NONCE_LEN, SecretKey};
use crate::error::{Error, Result};
const INFO_DIRNAME: &[u8] = b"omy/v1/dirname";
pub const DIR_SUFFIX: &str = ".omy";
pub const DIRNAME_SIDECAR: &str = ".omy-name";
const MAX_COMPONENT: usize = 255;
const TRUNCATE_AT: usize = 200;
const HASH_SUFFIX_LEN: usize = 8;
const B32: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
#[derive(Debug)]
pub struct DirnameKey(SecretKey);
impl DirnameKey {
#[must_use]
pub fn derive(kek: &Kek, vault_salt: &[u8; 16]) -> Self {
Self(kek.derive_dirname_key(vault_salt, INFO_DIRNAME))
}
#[must_use]
const fn as_key(&self) -> &SecretKey {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EncryptedDirname {
pub disk_name: String,
pub sidecar: Option<Vec<u8>>,
}
pub fn encrypt_dirname(
name: &str,
key: &DirnameKey,
nonce: &[u8; NONCE_LEN],
cipher: CipherId,
) -> Result<EncryptedDirname> {
encrypt_with_key(name, key.as_key(), nonce, cipher)
}
fn encrypt_with_key(
name: &str,
key: &SecretKey,
nonce: &[u8; NONCE_LEN],
cipher: CipherId,
) -> Result<EncryptedDirname> {
if name.is_empty() {
return Err(Error::MalformedTlv {
tlv_type: 0,
reason: "directory name must not be empty",
});
}
if name.contains('/') || name.contains('\\') {
return Err(Error::MalformedTlv {
tlv_type: 0,
reason: "directory name must be a single component",
});
}
let ct = cipher.encrypt(key, nonce, name.as_bytes(), &[])?;
let mut blob = Vec::with_capacity(NONCE_LEN.saturating_add(ct.len()));
blob.extend_from_slice(nonce);
blob.extend_from_slice(&ct);
let encoded = base32_encode(&blob);
if encoded.len().saturating_add(DIR_SUFFIX.len()) <= MAX_COMPONENT {
return Ok(EncryptedDirname {
disk_name: format!("{encoded}{DIR_SUFFIX}"),
sidecar: None,
});
}
let digest = crate::util::blake2b_256(&blob);
let tail = base32_encode(digest.get(..5).unwrap_or(&digest));
let head = encoded.get(..TRUNCATE_AT).unwrap_or(&encoded);
Ok(EncryptedDirname {
disk_name: format!(
"{head}~{}{DIR_SUFFIX}",
tail.get(..HASH_SUFFIX_LEN).unwrap_or(&tail)
),
sidecar: Some(blob),
})
}
pub fn decrypt_dirname(
disk_name: &str,
sidecar: Option<&[u8]>,
key: &DirnameKey,
cipher: CipherId,
) -> Result<String> {
decrypt_with_key(disk_name, sidecar, key.as_key(), cipher)
}
fn decrypt_with_key(
disk_name: &str,
sidecar: Option<&[u8]>,
key: &SecretKey,
cipher: CipherId,
) -> Result<String> {
let stem = disk_name.strip_suffix(DIR_SUFFIX).ok_or(Error::MalformedTlv {
tlv_type: 0,
reason: "not an encrypted directory name",
})?;
let blob = if stem.contains('~') {
sidecar
.ok_or(Error::MalformedTlv {
tlv_type: 0,
reason: "truncated directory name requires sidecar file",
})?
.to_vec()
} else {
base32_decode(stem)?
};
let (nonce, ct) = blob.split_at_checked(NONCE_LEN).ok_or(Error::MalformedTlv {
tlv_type: 0,
reason: "encrypted directory name too short to contain a nonce",
})?;
let mut n = [0u8; NONCE_LEN];
n.copy_from_slice(nonce);
let plain = cipher
.decrypt(key, &n, ct, &[])?
.ok_or(Error::ContentHashMismatch)?;
String::from_utf8(plain).map_err(|_| Error::MalformedTlv {
tlv_type: 0,
reason: "decrypted directory name is not valid UTF-8",
})
}
pub fn encrypt_dirname_with(
name: &str,
dk: &crate::dirsidecar::DirKey,
nonce: &[u8; NONCE_LEN],
cipher: CipherId,
) -> Result<EncryptedDirname> {
encrypt_with_key(name, dk.as_key(), nonce, cipher)
}
pub fn decrypt_dirname_with(
disk_name: &str,
long_name: Option<&[u8]>,
dk: &crate::dirsidecar::DirKey,
cipher: CipherId,
) -> Result<String> {
decrypt_with_key(disk_name, long_name, dk.as_key(), cipher)
}
#[must_use]
pub fn looks_encrypted(disk_name: &str) -> bool {
let Some(stem) = disk_name.strip_suffix(DIR_SUFFIX) else {
return false;
};
if stem.is_empty() {
return false;
}
stem.bytes()
.all(|b| b == b'~' || B32.contains(&b))
}
fn base32_encode(data: &[u8]) -> String {
let mut out = String::with_capacity(data.len().div_ceil(5).saturating_mul(8));
for chunk in data.chunks(5) {
let mut acc = 0u64;
for (i, &b) in chunk.iter().enumerate() {
let shift = 32usize.saturating_sub(i.saturating_mul(8));
acc |= u64::from(b) << shift;
}
let chars = chunk.len().saturating_mul(8).div_ceil(5);
for i in 0..chars {
let shift = 35usize.saturating_sub(i.saturating_mul(5));
let idx = ((acc >> shift) & 0x1F) as usize;
out.push(char::from(*B32.get(idx).unwrap_or(&b'A')));
}
}
out
}
fn base32_decode(s: &str) -> Result<Vec<u8>> {
let mut out = Vec::with_capacity(s.len().saturating_mul(5).div_ceil(8));
let mut acc = 0u64;
let mut bits = 0u32;
for b in s.bytes() {
let Some(v) = B32.iter().position(|&c| c == b) else {
return Err(Error::MalformedTlv {
tlv_type: 0,
reason: "invalid base32 character in directory name",
});
};
acc = (acc << 5) | v as u64;
bits = bits.saturating_add(5);
if bits >= 8 {
bits = bits.saturating_sub(8);
let byte = ((acc >> bits) & 0xFF) as u8;
out.push(byte);
}
}
if bits > 0 && (acc & ((1u64 << bits).saturating_sub(1))) != 0 {
return Err(Error::MalformedTlv {
tlv_type: 0,
reason: "non-zero base32 padding bits",
});
}
Ok(out)
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
clippy::indexing_slicing,
clippy::panic,
reason = "测试里用断言即可"
)]
mod tests {
use super::*;
use crate::crypto::Argon2Params;
fn test_key() -> DirnameKey {
let kek = Kek::from_password(b"pw", &[7u8; 16], Argon2Params::TEST_WEAK).unwrap();
DirnameKey::derive(&kek, &[7u8; 16])
}
fn nonce(seed: u8) -> [u8; NONCE_LEN] {
core::array::from_fn(|i| (i as u8).wrapping_mul(3) ^ seed)
}
#[test]
fn roundtrips_ascii_and_chinese() {
let k = test_key();
for name in ["docs", "工作资料", "a b c", "photos.2024"] {
let e = encrypt_dirname(name, &k, &nonce(1), CipherId::ChaCha20Poly1305).unwrap();
assert!(e.sidecar.is_none(), "{name} 不该需要边车文件");
let back =
decrypt_dirname(&e.disk_name, None, &k, CipherId::ChaCha20Poly1305).unwrap();
assert_eq!(back, name);
}
}
#[test]
fn encoded_name_has_no_slash_and_is_uppercase() {
let k = test_key();
let e = encrypt_dirname("工作资料", &k, &nonce(2), CipherId::ChaCha20Poly1305).unwrap();
let stem = e.disk_name.strip_suffix(DIR_SUFFIX).unwrap();
assert!(!stem.contains('/'), "不能含路径分隔符");
assert!(!stem.contains('\\'), "不能含反斜杠");
assert!(
stem.bytes().all(|b| B32.contains(&b)),
"只能出现 base32 字母表内的字符,实际 {stem}"
);
}
#[test]
fn case_insensitive_filesystems_cannot_collide() {
let k = test_key();
for seed in 0..40u8 {
let e = encrypt_dirname("x", &k, &nonce(seed), CipherId::ChaCha20Poly1305).unwrap();
let stem = e.disk_name.strip_suffix(DIR_SUFFIX).unwrap();
assert_eq!(
stem,
stem.to_uppercase(),
"第 {seed} 个密文名含小写字母,在 Windows/APFS 上会与其它名字折叠"
);
}
}
#[test]
fn different_nonce_yields_different_ciphertext() {
let k = test_key();
let a = encrypt_dirname("同一个名字", &k, &nonce(1), CipherId::ChaCha20Poly1305).unwrap();
let b = encrypt_dirname("同一个名字", &k, &nonce(2), CipherId::ChaCha20Poly1305).unwrap();
assert_ne!(a.disk_name, b.disk_name, "相同明文不该产出相同密文名");
for e in [a, b] {
let back =
decrypt_dirname(&e.disk_name, None, &k, CipherId::ChaCha20Poly1305).unwrap();
assert_eq!(back, "同一个名字");
}
}
#[test]
fn long_name_is_truncated_with_sidecar() {
let k = test_key();
let long = "很长的目录名".repeat(40); let e = encrypt_dirname(&long, &k, &nonce(3), CipherId::ChaCha20Poly1305).unwrap();
assert!(
e.disk_name.len() <= MAX_COMPONENT,
"截断后必须落在文件系统上限内,实际 {} 字节",
e.disk_name.len()
);
assert!(e.disk_name.contains('~'), "截断名要有 ~ 标记");
let sc = e.sidecar.as_deref().expect("截断时必须给出边车内容");
let back = decrypt_dirname(&e.disk_name, Some(sc), &k, CipherId::ChaCha20Poly1305).unwrap();
assert_eq!(back, long, "靠边车文件要能完整还原");
}
#[test]
fn truncated_names_of_same_plaintext_still_differ() {
let k = test_key();
let long = "同一个很长的目录名".repeat(30);
let a = encrypt_dirname(&long, &k, &nonce(11), CipherId::ChaCha20Poly1305).unwrap();
let b = encrypt_dirname(&long, &k, &nonce(12), CipherId::ChaCha20Poly1305).unwrap();
assert!(a.disk_name.contains('~') && b.disk_name.contains('~'), "两者都该被截断");
assert_ne!(a.disk_name, b.disk_name, "同名长目录不该产出相同的磁盘名");
let suffix = |s: &str| -> String {
s.rsplit_once('~').map(|(_, t)| t.to_owned()).unwrap_or_default()
};
assert_ne!(
suffix(&a.disk_name),
suffix(&b.disk_name),
"哈希后缀相同就泄露了「这两个目录同名」"
);
for e in [a, b] {
let sc = e.sidecar.as_deref().unwrap();
let back =
decrypt_dirname(&e.disk_name, Some(sc), &k, CipherId::ChaCha20Poly1305).unwrap();
assert_eq!(back, long);
}
}
#[test]
fn truncated_name_without_sidecar_reports_clearly() {
let k = test_key();
let long = "长".repeat(200);
let e = encrypt_dirname(&long, &k, &nonce(4), CipherId::ChaCha20Poly1305).unwrap();
let err = decrypt_dirname(&e.disk_name, None, &k, CipherId::ChaCha20Poly1305).unwrap_err();
match err {
Error::MalformedTlv { reason, .. } => {
assert!(reason.contains("sidecar"), "错误应指明缺边车文件,实际 {reason}");
}
other => panic!("应报缺边车文件,实际 {other:?}"),
}
}
#[test]
fn wrong_key_fails_authentication() {
let k = test_key();
let e = encrypt_dirname("secret", &k, &nonce(5), CipherId::ChaCha20Poly1305).unwrap();
let other_kek =
Kek::from_password(b"different", &[7u8; 16], Argon2Params::TEST_WEAK).unwrap();
let other = DirnameKey::derive(&other_kek, &[7u8; 16]);
assert!(
decrypt_dirname(&e.disk_name, None, &other, CipherId::ChaCha20Poly1305).is_err(),
"错误密钥必须解不开"
);
}
#[test]
fn different_vault_salt_yields_different_key() {
let kek = Kek::from_password(b"pw", &[7u8; 16], Argon2Params::TEST_WEAK).unwrap();
let a = DirnameKey::derive(&kek, &[7u8; 16]);
let b = DirnameKey::derive(&kek, &[9u8; 16]);
let e = encrypt_dirname("x", &a, &nonce(6), CipherId::ChaCha20Poly1305).unwrap();
assert!(
decrypt_dirname(&e.disk_name, None, &b, CipherId::ChaCha20Poly1305).is_err(),
"换了 vault_salt 就不该解得开"
);
}
#[test]
fn rejects_path_components() {
let k = test_key();
for bad in ["a/b", "a\\b", ""] {
assert!(
encrypt_dirname(bad, &k, &nonce(7), CipherId::ChaCha20Poly1305).is_err(),
"{bad:?} 必须被拒绝"
);
}
}
#[test]
fn base32_matches_rfc4648_vectors() {
assert_eq!(base32_encode(b""), "");
assert_eq!(base32_encode(b"f"), "MY");
assert_eq!(base32_encode(b"fo"), "MZXQ");
assert_eq!(base32_encode(b"foo"), "MZXW6");
assert_eq!(base32_encode(b"foob"), "MZXW6YQ");
assert_eq!(base32_encode(b"fooba"), "MZXW6YTB");
assert_eq!(base32_encode(b"foobar"), "MZXW6YTBOI");
}
#[test]
fn base32_matches_python_stdlib_at_length_boundaries() {
let data: Vec<u8> = (0..256u32).map(|i| ((i * 11) ^ 0x5B) as u8).collect();
let expected: &[(usize, &str)] = &[
(1, "LM"),
(2, "LNIA"),
(3, "LNIE2"),
(4, "LNIE26Q"),
(5, "LNIE26TX"),
(6, "LNIE26TXNQ"),
(7, "LNIE26TXNQMQ"),
(8, "LNIE26TXNQMRM"),
(31, "LNIE26TXNQMRMAZYGURN7VGB73V6BHMKQ66KTJSTJBCXE33ECE"),
(32, "LNIE26TXNQMRMAZYGURN7VGB73V6BHMKQ66KTJSTJBCXE33ECEHA"),
(33, "LNIE26TXNQMRMAZYGURN7VGB73V6BHMKQ66KTJSTJBCXE33ECEHDW"),
];
for &(len, want) in expected {
assert_eq!(
base32_encode(&data[..len]),
want,
"长度 {len}(mod 5 = {})与 Python 标准库不一致",
len % 5
);
}
for len in 1..=64usize {
let want_chars = (len * 8).div_ceil(5);
assert_eq!(
base32_encode(&data[..len]).len(),
want_chars,
"长度 {len} 应产出 {want_chars} 个字符"
);
}
}
#[test]
fn base32_roundtrips_arbitrary_bytes() {
let data: Vec<u8> = (0..=255u8).map(|i| i.wrapping_mul(11) ^ 0x5B).collect();
for len in [1usize, 2, 3, 4, 5, 6, 7, 8, 31, 32, 33, 255] {
let slice = &data[..len];
let enc = base32_encode(slice);
let dec = base32_decode(&enc).unwrap();
assert_eq!(dec, slice, "长度 {len} 往返失败");
}
}
#[test]
fn base32_rejects_invalid_chars() {
for bad in ["mzxw", "MZXW0", "MZXW1", "MZXW8", "MZ=XW"] {
assert!(base32_decode(bad).is_err(), "{bad} 应被拒绝");
}
}
#[test]
fn base32_rejects_nonzero_padding_bits() {
assert!(base32_decode("MY").is_ok(), "MY 的填充位是 0");
assert!(base32_decode("MZ").is_err(), "非零填充位必须被拒绝");
}
#[test]
fn looks_encrypted_is_precise() {
let k = test_key();
let e = encrypt_dirname("x", &k, &nonce(8), CipherId::ChaCha20Poly1305).unwrap();
assert!(looks_encrypted(&e.disk_name));
for plain in ["docs", "工作资料.omy", "my-stuff.omy", ".omy", "docs.txt"] {
assert!(!looks_encrypted(plain), "{plain} 不该被判为密文目录名");
}
}
}