use crate::crypto::{CipherId, Kek, NONCE_LEN};
use crate::dirname::{DIRNAME_SIDECAR, EncryptedDirname, looks_encrypted};
use crate::error::{Error, Result};
use crate::file::{EncryptOptions, RandomMaterial};
use crate::pack::{ProgressFn, SkipReason, SkippedEntry, walk_dir};
use std::path::{Path, PathBuf};
#[derive(Debug, Default)]
pub struct TreeReport {
pub root: PathBuf,
pub files: usize,
pub dirs: usize,
pub long_names: usize,
pub skipped: Vec<SkippedEntry>,
}
impl TreeReport {
#[must_use]
pub const fn leak_notice() -> &'static str {
"逐个加密模式下,别人能看到你有多少文件、每个文件多大、目录有几层,但看不到文件名和内容。"
}
}
pub type MediaExtrasFn<'a> =
&'a mut dyn FnMut(&[u8], &str) -> (Option<Vec<u8>>, Option<Vec<u8>>, Option<Vec<u8>>);
pub fn encrypt_tree(
root: &Path,
out_parent: &Path,
keks: &[Kek],
vault_salt: &[u8; 16],
opts: &EncryptOptions,
progress: Option<ProgressFn<'_>>,
) -> Result<TreeReport> {
encrypt_tree_with_media(root, out_parent, keks, vault_salt, opts, progress, None)
}
pub fn encrypt_tree_with_media(
root: &Path,
out_parent: &Path,
keks: &[Kek],
vault_salt: &[u8; 16],
opts: &EncryptOptions,
mut progress: Option<ProgressFn<'_>>,
mut media: Option<MediaExtrasFn<'_>>,
) -> Result<TreeReport> {
if !root.is_dir() {
return Err(Error::Io(std::io::Error::new(
std::io::ErrorKind::NotADirectory,
"encrypt_tree expects a directory",
)));
}
if keks.is_empty() {
return Err(Error::TooManySlots { got: 0, max: 8 });
}
let dk = crate::dirsidecar::DirKey::generate();
let root_name = root
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| String::from("folder"));
let mut rep = TreeReport::default();
let enc_root =
crate::dirname::encrypt_dirname_with(&root_name, &dk, &random_nonce(), opts.cipher)?;
let out_root = out_parent.join(&enc_root.disk_name);
create_encrypted_dir(&out_root, &enc_root, &mut rep)?;
let tree_slot_dir = match &opts.slot_directory {
Some(raw) => Some(crate::slotdir::SlotDirectory::decode(raw)?),
None => None,
};
write_keys_sidecar(&out_root, keks, &dk, vault_salt, opts.cipher, tree_slot_dir.as_ref())?;
rep.root = out_root.clone();
let mut mapping: std::collections::HashMap<Vec<String>, PathBuf> =
std::collections::HashMap::new();
mapping.insert(Vec::new(), out_root.clone());
let mut walk_skipped = Vec::new();
walk_dir(root, &mut walk_skipped, &mut |item| {
let Some(parent_rel) = item.comps.split_last().map(|(_, p)| p.to_vec()) else {
return Ok(());
};
let Some(parent_out) = mapping.get(&parent_rel).cloned() else {
rep.skipped.push(SkippedEntry {
path: item.comps.join("/"),
reason: SkipReason::Unreadable,
});
return Ok(());
};
let Some(name) = item.comps.last() else {
return Ok(());
};
if item.link_target.is_some() {
rep.skipped.push(SkippedEntry {
path: item.comps.join("/"),
reason: SkipReason::Symlink,
});
return Ok(());
}
if item.is_dir {
let enc =
crate::dirname::encrypt_dirname_with(name, &dk, &random_nonce(), opts.cipher)?;
let dir_out = parent_out.join(&enc.disk_name);
create_encrypted_dir(&dir_out, &enc, &mut rep)?;
write_keys_sidecar(
&dir_out,
keks,
&dk,
vault_salt,
opts.cipher,
tree_slot_dir.as_ref(),
)?;
mapping.insert(item.comps.clone(), dir_out);
} else {
let Ok(data) = std::fs::read(&item.path) else {
rep.skipped.push(SkippedEntry {
path: item.comps.join("/"),
reason: SkipReason::Unreadable,
});
return Ok(());
};
let size = data.len() as u64;
let mut fopts = opts.clone();
fopts.filename = Some(name.clone());
if let Some(cb) = media.as_deref_mut() {
let (thumb, meta, moov) = cb(&data, name);
fopts.thumbnail = thumb;
fopts.media_meta = meta;
fopts.moov_cache = moov;
}
let rnd = RandomMaterial::generate();
let enc = crate::file::encrypt(&data, keks, vault_salt, &fopts, &rnd)?;
let disk_name = format!("{}.omy", hex16(&rnd.file_uuid));
crate::fsatomic::write_atomic(&parent_out.join(&disk_name), &enc.bytes)?;
rep.files = rep.files.saturating_add(1);
if let Some(cb) = progress.as_deref_mut() {
cb(&item.comps.join("/"), size);
}
}
Ok(())
})?;
rep.skipped.extend(walk_skipped);
Ok(rep)
}
fn write_keys_sidecar(
dir: &Path,
keks: &[Kek],
dk: &crate::dirsidecar::DirKey,
vault_salt: &[u8; 16],
cipher: CipherId,
slot_dir: Option<&crate::slotdir::SlotDirectory>,
) -> Result<()> {
let blob = match slot_dir {
Some(sd) => crate::dirsidecar::build_sidecar_managed(
keks,
sd,
dk,
vault_salt,
&random_nonce(),
cipher,
)?,
None => crate::dirsidecar::build_sidecar(keks, dk, vault_salt, &random_nonce(), cipher)?,
};
crate::fsatomic::write_atomic(&dir.join(crate::dirsidecar::KEYS_SIDECAR), &blob)?;
Ok(())
}
fn rewrite_keys_sidecar(
dir: &Path,
keep: &[Kek],
dk: &crate::dirsidecar::DirKey,
vault_salt: &[u8; 16],
cipher: CipherId,
others: crate::keyslot::OtherSlots,
) -> Result<()> {
let path = dir.join(crate::dirsidecar::KEYS_SIDECAR);
let raw = std::fs::read(&path).ok();
let was_managed = raw.as_deref().is_some_and(crate::dirsidecar::is_managed);
let old = match others {
crate::keyslot::OtherSlots::Carry => raw,
crate::keyslot::OtherSlots::Discard => None,
};
let blob = if was_managed {
let mut sd = crate::slotdir::SlotDirectory::new();
for i in 0..keep.len().min(crate::header::SLOT_COUNT) {
sd.set(i, crate::slotdir::SlotEntry::of(crate::slotdir::SlotKind::Vault))?;
}
crate::dirsidecar::build_sidecar_managed(
keep,
&sd,
dk,
vault_salt,
&random_nonce(),
cipher,
)?
} else {
crate::dirsidecar::rebuild_sidecar_carrying(
old.as_deref(),
keep,
dk,
vault_salt,
&random_nonce(),
cipher,
)?
};
crate::fsatomic::write_atomic(&path, &blob)?;
Ok(())
}
fn read_keys_sidecar(
dir: &Path,
keks: &[Kek],
vault_salt: &[u8; 16],
cipher: CipherId,
) -> Result<crate::dirsidecar::DirKey> {
let blob = std::fs::read(dir.join(crate::dirsidecar::KEYS_SIDECAR))?;
let (dk, _nonce) = crate::dirsidecar::open_sidecar(&blob, keks, vault_salt, cipher)?;
Ok(dk)
}
#[derive(Debug)]
pub struct TreeSlots {
pub managed: bool,
pub directory: Option<crate::slotdir::SlotDirectory>,
pub current: usize,
}
pub fn tree_slots(
root: &Path,
keks: &[Kek],
vault_salt: &[u8; 16],
cipher: CipherId,
) -> Result<TreeSlots> {
let blob = std::fs::read(root.join(crate::dirsidecar::KEYS_SIDECAR))?;
let (dk, _nonce, at) =
crate::dirsidecar::open_sidecar_at(&blob, keks, vault_salt, cipher)?;
if !crate::dirsidecar::is_managed(&blob) {
return Ok(TreeSlots { managed: false, directory: None, current: at });
}
let dir = crate::dirsidecar::read_slot_directory(&blob, &dk, vault_salt, cipher)?;
Ok(TreeSlots { managed: true, directory: Some(dir), current: at })
}
pub fn tree_remove_slot(
root: &Path,
keks: &[Kek],
slot: usize,
vault_salt: &[u8; 16],
cipher: CipherId,
) -> Result<usize> {
let info = tree_slots(root, keks, vault_salt, cipher)?;
let Some(dir) = info.directory else {
return Err(Error::MalformedTlv {
tlv_type: 0,
reason: "this tree is in deniable mode; slots cannot be removed individually",
});
};
if slot == info.current {
return Err(Error::MalformedTlv {
tlv_type: 0,
reason: "refusing to remove the key currently in use",
});
}
if !dir.get(slot).is_some_and(|e| e.kind.is_occupied()) {
return Err(Error::MalformedTlv {
tlv_type: 0,
reason: "that slot is already empty",
});
}
let mut after = dir;
after.set(slot, crate::slotdir::SlotEntry::empty())?;
let mut changed = 0usize;
let mut stack = vec![root.to_path_buf()];
while let Some(cur) = stack.pop() {
let path = cur.join(crate::dirsidecar::KEYS_SIDECAR);
if let Ok(blob) = std::fs::read(&path)
&& crate::dirsidecar::is_managed(&blob)
&& let Ok((dk, nonce, _)) =
crate::dirsidecar::open_sidecar_at(&blob, keks, vault_salt, cipher)
{
let mut plans: Vec<crate::dirsidecar::SidecarPlan> =
(0..crate::dirsidecar::SIDECAR_SLOTS)
.map(|_| crate::dirsidecar::SidecarPlan::Keep)
.collect();
if let Some(p) = plans.get_mut(slot) {
*p = crate::dirsidecar::SidecarPlan::Clear;
}
let out = crate::dirsidecar::rewrite_sidecar_managed(
&blob, &plans, &after, &dk, vault_salt, &nonce, cipher,
)?;
crate::fsatomic::write_atomic(&path, &out)?;
changed = changed.saturating_add(1);
}
let Ok(rd) = std::fs::read_dir(&cur) else { continue };
for ent in rd.flatten() {
let sub = ent.path();
if sub.is_dir() {
stack.push(sub);
}
}
}
Ok(changed)
}
fn create_encrypted_dir(path: &Path, enc: &EncryptedDirname, rep: &mut TreeReport) -> Result<()> {
std::fs::create_dir_all(path)?;
rep.dirs = rep.dirs.saturating_add(1);
if let Some(sc) = &enc.sidecar {
crate::fsatomic::write_atomic(&path.join(DIRNAME_SIDECAR), sc)?;
rep.long_names = rep.long_names.saturating_add(1);
}
Ok(())
}
pub fn decrypt_tree(
enc_root: &Path,
out_parent: &Path,
keks: &[Kek],
vault_salt: &[u8; 16],
cipher: CipherId,
mut progress: Option<ProgressFn<'_>>,
) -> Result<TreeReport> {
if !enc_root.is_dir() {
return Err(Error::Io(std::io::Error::new(
std::io::ErrorKind::NotADirectory,
"decrypt_tree expects a directory",
)));
}
let dk = read_keys_sidecar(enc_root, keks, vault_salt, cipher)?;
let enc_name = enc_root
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let plain_root = decrypt_dir_name_at(enc_root, &enc_name, &dk, cipher)?;
let out_root = out_parent.join(&plain_root);
std::fs::create_dir_all(&out_root)?;
let mut rep = TreeReport { root: out_root.clone(), dirs: 1, ..TreeReport::default() };
decrypt_into(enc_root, &out_root, keks, &dk, cipher, &mut rep, &mut progress)?;
Ok(rep)
}
fn decrypt_into(
enc_dir: &Path,
out_dir: &Path,
keks: &[Kek],
dk: &crate::dirsidecar::DirKey,
cipher: CipherId,
rep: &mut TreeReport,
progress: &mut Option<ProgressFn<'_>>,
) -> Result<()> {
let rd = std::fs::read_dir(enc_dir)?;
let mut items: Vec<_> = rd.flatten().collect();
items.sort_by_key(std::fs::DirEntry::file_name);
for item in items {
let name = item.file_name().to_string_lossy().into_owned();
if name == DIRNAME_SIDECAR || name == crate::dirsidecar::KEYS_SIDECAR {
continue;
}
let path = item.path();
let Ok(md) = path.symlink_metadata() else {
rep.skipped.push(SkippedEntry {
path: name,
reason: SkipReason::Unreadable,
});
continue;
};
if md.is_dir() {
let plain = decrypt_dir_name_at(&path, &name, dk, cipher)?;
let sub_out = out_dir.join(&plain);
std::fs::create_dir_all(&sub_out)?;
rep.dirs = rep.dirs.saturating_add(1);
decrypt_into(&path, &sub_out, keks, dk, cipher, rep, progress)?;
} else if md.is_file() {
let Ok(bytes) = std::fs::read(&path) else {
rep.skipped.push(SkippedEntry {
path: name,
reason: SkipReason::Unreadable,
});
continue;
};
if !crate::file::is_omy_file(&bytes) {
rep.skipped.push(SkippedEntry {
path: name,
reason: SkipReason::NotRegular,
});
continue;
}
let opened = crate::file::open(&bytes, keks)?;
let plain_name = opened.filename()?;
let data = opened.decrypt_all(&bytes)?;
let size = data.len() as u64;
let safe = crate::unpack::sanitize_filename(&plain_name);
crate::fsatomic::write_atomic(&out_dir.join(&safe), &data)?;
rep.files = rep.files.saturating_add(1);
if let Some(cb) = progress.as_deref_mut() {
cb(&safe, size);
}
}
}
Ok(())
}
fn decrypt_dir_name_at(
dir: &Path,
disk_name: &str,
dk: &crate::dirsidecar::DirKey,
cipher: CipherId,
) -> Result<String> {
if !looks_encrypted(disk_name) {
return Err(Error::MalformedTlv {
tlv_type: 0,
reason: "directory name is not an encrypted name",
});
}
let long_name = if disk_name.contains('~') {
Some(std::fs::read(dir.join(DIRNAME_SIDECAR))?)
} else {
None
};
crate::dirname::decrypt_dirname_with(disk_name, long_name.as_deref(), dk, cipher)
}
fn random_nonce() -> [u8; NONCE_LEN] {
let mut n = [0u8; NONCE_LEN];
crate::util::fill_random(&mut n);
n
}
#[must_use]
pub fn find_any_file(root: &Path) -> Option<PathBuf> {
let mut damaged = None;
find_sample(root, &mut damaged).or(damaged)
}
fn find_sample(dir: &Path, damaged: &mut Option<PathBuf>) -> Option<PathBuf> {
let Ok(rd) = std::fs::read_dir(dir) else {
return None;
};
let mut dirs = Vec::new();
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
dirs.push(p);
continue;
}
if !p.extension().is_some_and(|x| x == "omy") {
continue;
}
if header_parses(&p) {
return Some(p);
}
if damaged.is_none() {
*damaged = Some(p);
}
}
dirs.into_iter().find_map(|d| find_sample(&d, damaged))
}
fn header_parses(path: &Path) -> bool {
use std::io::Read as _;
let Ok(mut f) = std::fs::File::open(path) else {
return false;
};
let mut buf = vec![0u8; crate::scan::MIN_PROBE_SIZE];
let mut got = 0usize;
while got < buf.len() {
let Some(dst) = buf.get_mut(got..) else { break };
match f.read(dst) {
Ok(0) => break,
Ok(n) => got = got.saturating_add(n),
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(_) => return false,
}
}
buf.truncate(got);
crate::file::peek_header(&buf).is_ok()
}
#[derive(Debug, Default)]
pub struct RekeyReport {
pub changed: usize,
pub dirs_renamed: usize,
pub root: PathBuf,
pub failed: Vec<(PathBuf, String)>,
pub payload_rewritten: bool,
pub bytes_rewritten: u64,
}
impl RekeyReport {
#[must_use]
pub const fn is_complete(&self) -> bool {
self.failed.is_empty()
}
}
pub fn rekey_tree(
root: &Path,
unlock: &[Kek],
keep: &[Kek],
vault_salt: &[u8; 16],
cipher: CipherId,
others: crate::keyslot::OtherSlots,
) -> Result<RekeyReport> {
let params = RekeyParams { vault_salt, cipher, rotate: false, others };
rekey_tree_with_progress(root, unlock, keep, params, None)
}
pub type RekeyProgressFn<'a> = &'a mut dyn FnMut(&str, usize, usize, u64, u64);
#[derive(Debug, Clone, Copy)]
pub struct RekeyParams<'a> {
pub vault_salt: &'a [u8; 16],
pub cipher: CipherId,
pub rotate: bool,
pub others: crate::keyslot::OtherSlots,
}
pub fn rekey_tree_with_progress(
root: &Path,
unlock: &[Kek],
keep: &[Kek],
params: RekeyParams<'_>,
mut progress: Option<RekeyProgressFn<'_>>,
) -> Result<RekeyReport> {
let RekeyParams { vault_salt, cipher, rotate, others } = params;
if keep.is_empty() {
return Err(Error::MalformedHeader {
reason: "refusing to leave files with zero key slots; they could never be opened again",
});
}
if find_any_file(root).is_none() {
return Err(Error::MalformedHeader {
reason: "no encrypted file found under the given directory",
});
}
let dk = read_keys_sidecar(root, unlock, vault_salt, cipher)?;
let mut rep = RekeyReport { payload_rewritten: rotate, ..RekeyReport::default() };
let total = if rotate { count_files(root) } else { 0 };
let mut ctx = RekeyCtx {
unlock,
keep,
dk: &dk,
vault_salt,
cipher,
others,
rotate,
total,
done: 0,
};
rekey_into(root, &mut ctx, &mut progress, &mut rep);
if let Err(e) = rewrite_keys_sidecar(root, keep, &dk, vault_salt, cipher, others) {
rep.failed.push((root.to_path_buf(), e.to_string()));
}
rep.root = root.to_path_buf();
Ok(rep)
}
pub fn retry_files(
files: &[PathBuf],
unlock: &[Kek],
keep: &[Kek],
rotate: bool,
others: crate::keyslot::OtherSlots,
mut progress: Option<RekeyProgressFn<'_>>,
) -> Result<RekeyReport> {
if keep.is_empty() {
return Err(Error::MalformedHeader {
reason: "refusing to leave files with zero key slots; they could never be opened again",
});
}
let mut rep = RekeyReport { payload_rewritten: rotate, ..RekeyReport::default() };
let total = files.len();
for (i, path) in files.iter().enumerate() {
let idx = i.saturating_add(1);
let shown =
path.file_name().map_or_else(String::new, |n| n.to_string_lossy().into_owned());
let mut on_bytes = |done: u64, bytes: u64| {
if let Some(cb) = progress.as_deref_mut() {
cb(&shown, idx, total, done, bytes);
}
};
match rekey_one(path, unlock, keep, rotate, others, &mut on_bytes) {
Ok(written) => {
rep.changed = rep.changed.saturating_add(1);
rep.bytes_rewritten = rep.bytes_rewritten.saturating_add(written);
}
Err(err) => rep.failed.push((path.clone(), err)),
}
}
Ok(rep)
}
struct RekeyCtx<'a> {
unlock: &'a [Kek],
keep: &'a [Kek],
dk: &'a crate::dirsidecar::DirKey,
vault_salt: &'a [u8; 16],
cipher: CipherId,
others: crate::keyslot::OtherSlots,
rotate: bool,
total: usize,
done: usize,
}
fn rekey_into(
dir: &Path,
ctx: &mut RekeyCtx<'_>,
progress: &mut Option<RekeyProgressFn<'_>>,
rep: &mut RekeyReport,
) {
let Ok(rd) = std::fs::read_dir(dir) else {
rep.failed.push((dir.to_path_buf(), String::from("read_dir failed")));
return;
};
let mut subdirs = Vec::new();
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
subdirs.push(p);
continue;
}
if !p.extension().is_some_and(|x| x == "omy") {
continue;
}
ctx.done = ctx.done.saturating_add(1);
let idx = ctx.done;
let total = ctx.total;
let shown = p.file_name().map_or_else(String::new, |n| n.to_string_lossy().into_owned());
let mut on_bytes = |done: u64, bytes_total: u64| {
if let Some(cb) = progress.as_deref_mut() {
cb(&shown, idx, total, done, bytes_total);
}
};
match rekey_one(&p, ctx.unlock, ctx.keep, ctx.rotate, ctx.others, &mut on_bytes) {
Ok(written) => {
rep.changed = rep.changed.saturating_add(1);
rep.bytes_rewritten = rep.bytes_rewritten.saturating_add(written);
}
Err(err) => rep.failed.push((p, err)),
}
}
for sub in subdirs {
rekey_into(&sub, ctx, progress, rep);
if !crate::dirname::looks_encrypted(
&sub.file_name().map_or_else(String::new, |n| n.to_string_lossy().into_owned()),
) {
continue;
}
if let Err(e) = rewrite_keys_sidecar(&sub, ctx.keep, ctx.dk, ctx.vault_salt, ctx.cipher, ctx.others) {
rep.failed.push((sub, e.to_string()));
}
}
}
fn count_files(dir: &Path) -> usize {
let Ok(rd) = std::fs::read_dir(dir) else {
return 0;
};
let mut n: usize = 0;
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
n = n.saturating_add(count_files(&p));
} else if p.extension().is_some_and(|x| x == "omy") {
n = n.saturating_add(1);
}
}
n
}
fn rekey_one(
path: &Path,
unlock: &[Kek],
keep: &[Kek],
rotate: bool,
others: crate::keyslot::OtherSlots,
on_bytes: &mut dyn FnMut(u64, u64),
) -> core::result::Result<u64, String> {
let data = std::fs::read(path).map_err(|e| format!("read: {e}"))?;
let (bytes, written) = if rotate {
let sink = core::cell::RefCell::new(on_bytes);
let mut dec = |done: u64, total: u64| {
if let Ok(mut f) = sink.try_borrow_mut() {
f(done, total.saturating_mul(2));
}
};
let mut enc = |done: u64, total: u64| {
if let Ok(mut f) = sink.try_borrow_mut() {
f(total.saturating_add(done), total.saturating_mul(2));
}
};
let out = crate::reencrypt::rotate_fek_with_progress(
&data,
unlock,
keep,
&crate::file::RandomMaterial::generate(),
Some(&mut dec),
Some(&mut enc),
)
.map_err(|e| e.to_string())?;
(out.bytes, out.plaintext_size)
} else {
let out = crate::keyslot::rewrite_slots(&data, unlock, keep, others)
.map_err(|e| e.to_string())?;
(out.bytes, 0)
};
for k in keep {
crate::file::open(&bytes, &[k.duplicate()])
.map_err(|_| String::from("rewrite verify failed"))?;
}
crate::fsatomic::write_atomic(path, &bytes).map_err(|e| format!("write: {e}"))?;
Ok(written)
}
fn hex16(b: &[u8; 16]) -> String {
let mut s = String::with_capacity(32);
for byte in b {
const HEX: &[u8; 16] = b"0123456789abcdef";
let hi = usize::from(byte >> 4);
let lo = usize::from(byte & 0x0F);
s.push(char::from(*HEX.get(hi).unwrap_or(&b'0')));
s.push(char::from(*HEX.get(lo).unwrap_or(&b'0')));
}
s
}
#[cfg(test)]
#[expect(clippy::unwrap_used, clippy::indexing_slicing, reason = "测试里用断言即可")]
mod tests {
use super::*;
use crate::crypto::Argon2Params;
use crate::header::FixedHeader;
fn tree_fixture(tag: &str) -> (PathBuf, PathBuf, [u8; 16], CipherId) {
let root = std::env::temp_dir().join(format!("omy-rekey-{tag}"));
let _ = std::fs::remove_dir_all(&root);
let src = root.join("src");
std::fs::create_dir_all(src.join("sub")).unwrap();
std::fs::write(src.join("a.txt"), b"AAA").unwrap();
std::fs::write(src.join("sub/b.txt"), b"BBB").unwrap();
let salt = [7u8; 16];
let kek = Kek::from_password(b"old", &salt, Argon2Params::TEST_WEAK).unwrap();
let out = root.join("enc");
std::fs::create_dir_all(&out).unwrap();
let opts = crate::file::EncryptOptions::default();
let rep = encrypt_tree(&src, &out, &[kek], &salt, &opts, None).unwrap();
(root, rep.root, salt, opts.cipher)
}
fn kek_of(pw: &[u8], salt: &[u8; 16]) -> Kek {
Kek::from_password(pw, salt, Argon2Params::TEST_WEAK).unwrap()
}
#[test]
fn media_extras_are_written_per_file_not_shared() {
let root = std::env::temp_dir().join("omy-media-cb");
let _ = std::fs::remove_dir_all(&root);
let src = root.join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("one.bin"), b"1111").unwrap();
std::fs::write(src.join("two.bin"), b"2222").unwrap();
let salt = [9u8; 16];
let kek = kek_of(b"pw", &salt);
let out = root.join("enc");
std::fs::create_dir_all(&out).unwrap();
let opts = crate::file::EncryptOptions::default();
let mut seen = Vec::new();
let mut cb = |_data: &[u8], name: &str| {
seen.push(name.to_owned());
let thumb = format!("thumb-of-{name}").into_bytes();
let meta = format!("{{\"name\":\"{name}\"}}").into_bytes();
(Some(thumb), Some(meta), None)
};
let rep = encrypt_tree_with_media(
&src,
&out,
&[kek.duplicate()],
&salt,
&opts,
None,
Some(&mut cb),
)
.unwrap();
assert_eq!(rep.files, 2);
seen.sort();
assert_eq!(seen, vec!["one.bin", "two.bin"], "回调应当每个文件各调一次");
let mut checked = 0;
for p in all_files(&rep.root) {
let data = std::fs::read(&p).unwrap();
let opened = crate::file::open(&data, &[kek.duplicate()]).unwrap();
let name = opened.filename().unwrap();
let thumb = opened.thumbnail().unwrap();
assert_eq!(
thumb,
format!("thumb-of-{name}").into_bytes(),
"{name} 拿到的是别人的缩略图"
);
let meta = opened.media_meta().unwrap();
assert_eq!(meta, format!("{{\"name\":\"{name}\"}}").into_bytes());
checked += 1;
}
assert_eq!(checked, 2);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn encrypt_tree_without_media_writes_no_thumbnail() {
let root = std::env::temp_dir().join("omy-media-none");
let _ = std::fs::remove_dir_all(&root);
let src = root.join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("a.bin"), b"AAAA").unwrap();
let salt = [11u8; 16];
let kek = kek_of(b"pw", &salt);
let out = root.join("enc");
std::fs::create_dir_all(&out).unwrap();
let opts = crate::file::EncryptOptions::default();
let rep = encrypt_tree(&src, &out, &[kek.duplicate()], &salt, &opts, None).unwrap();
for p in all_files(&rep.root) {
let data = std::fs::read(&p).unwrap();
let opened = crate::file::open(&data, &[kek.duplicate()]).unwrap();
assert!(!opened.has_thumbnail(), "没传回调却有缩略图");
}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn rekey_tree_changes_every_file_and_old_password_stops_working() {
let (root, enc_root, salt, cipher) = tree_fixture("change");
let old = kek_of(b"old", &salt);
let new = kek_of(b"new", &salt);
let rep =
rekey_tree(&enc_root, &[old.duplicate()], &[new.duplicate()], &salt, cipher, crate::keyslot::OtherSlots::Carry).unwrap();
assert_eq!(rep.changed, 2, "两个文件都该改到;漏掉深层文件是最可能的缺陷");
assert!(rep.is_complete(), "失败项: {:?}", rep.failed);
assert_eq!(rep.root, enc_root, "换密码不该改目录名");
assert!(rep.root.is_dir(), "根路径应当仍然有效");
let enc_root = rep.root.clone();
let mut checked = 0;
for f in all_files(&enc_root) {
let data = std::fs::read(&f).unwrap();
let opened = crate::file::open(&data, &[new.duplicate()]).unwrap();
assert!(opened.filename().unwrap().ends_with(".txt"));
checked += 1;
assert!(
crate::file::open(&data, &[old.duplicate()]).is_err(),
"旧密码仍能打开 {f:?}——改密码没生效,而用户以为已经换了"
);
}
assert_eq!(checked, 2);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn rekey_tree_keeps_payload_byte_identical() {
let (root, enc_root, salt, cipher) = tree_fixture("payload");
let before: std::collections::BTreeMap<String, Vec<u8>> = all_files(&enc_root)
.into_iter()
.map(|p| {
let d = std::fs::read(&p).unwrap();
(p.file_name().unwrap().to_string_lossy().into_owned(), d)
})
.collect();
let old = kek_of(b"old", &salt);
let new = kek_of(b"new", &salt);
let rep = rekey_tree(&enc_root, &[old], &[new], &salt, cipher, crate::keyslot::OtherSlots::Carry).unwrap();
for p in all_files(&rep.root) {
let name = p.file_name().unwrap().to_string_lossy().into_owned();
let old_bytes = before.get(&name).expect("改密码后出现了新文件名——uuid 不该变");
let new_bytes = std::fs::read(&p).unwrap();
assert_eq!(old_bytes.len(), new_bytes.len(), "长度变了:{p:?}");
let old_bytes = old_bytes.clone();
let h = FixedHeader::parse(&new_bytes).unwrap();
let start = h.header_len as usize;
assert_eq!(
&old_bytes[start..],
&new_bytes[start..],
"载荷被改写了:{p:?}——说明退化成了解密后重新加密"
);
let h0 = FixedHeader::parse(&old_bytes).unwrap();
assert_eq!(h0.file_uuid, h.file_uuid, "file_uuid 变了:{p:?}");
}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn rekey_tree_refuses_empty_keep() {
let (root, enc_root, salt, cipher) = tree_fixture("empty");
let old = kek_of(b"old", &salt);
assert!(rekey_tree(&enc_root, &[old], &[], &salt, cipher, crate::keyslot::OtherSlots::Carry).is_err());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn rekey_tree_rejects_a_plain_directory() {
let root = std::env::temp_dir().join("omy-rekey-plain");
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("x.txt"), b"not encrypted").unwrap();
let salt = [7u8; 16];
let k = kek_of(b"pw", &salt);
assert!(
rekey_tree(&root, &[k.duplicate()], &[k], &salt, CipherId::ChaCha20Poly1305, crate::keyslot::OtherSlots::Carry)
.is_err(),
"对普通目录应当报错,而不是静默返回 0 个已改写"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn rekey_tree_rejects_a_wrong_password_without_touching_anything() {
let (root, enc_root, salt, cipher) = tree_fixture("wrongpw");
let before: Vec<Vec<u8>> =
all_files(&enc_root).iter().map(|p| std::fs::read(p).unwrap()).collect();
let bad = kek_of(b"bad", &salt);
let new = kek_of(b"new", &salt);
let r = rekey_tree(&enc_root, &[bad], &[new], &salt, cipher, crate::keyslot::OtherSlots::Carry);
assert!(r.is_err(), "密码不对必须整体拒绝,而不是部分成功");
let after: Vec<Vec<u8>> =
all_files(&enc_root).iter().map(|p| std::fs::read(p).unwrap()).collect();
assert_eq!(before, after, "密码不对却动了文件");
assert!(enc_root.is_dir(), "密码不对却动了根目录");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn tree_remove_really_voids_the_other_passwords() {
let (root, enc_root, salt, cipher) = tree_fixture("rmvoid");
let keep_me = kek_of(b"old", &salt);
let to_void = kek_of(b"second", &salt);
let added = rekey_tree(
&enc_root,
&[keep_me.duplicate()],
&[keep_me.duplicate(), to_void.duplicate()],
&salt,
cipher,
crate::keyslot::OtherSlots::Carry,
)
.unwrap();
assert!(added.is_complete(), "{:?}", added.failed);
let probe = root.join("probe-before");
std::fs::create_dir_all(&probe).unwrap();
assert!(
decrypt_tree(&added.root, &probe, &[to_void.duplicate()], &salt, cipher, None)
.is_ok(),
"第二个密码一开始就该能解开整棵树,否则这条测试证明不了什么"
);
let removed = rekey_tree(
&added.root,
&[keep_me.duplicate()],
&[keep_me.duplicate()],
&salt,
cipher,
crate::keyslot::OtherSlots::Discard,
)
.unwrap();
assert!(removed.is_complete(), "{:?}", removed.failed);
let after_keep = root.join("after-keep");
std::fs::create_dir_all(&after_keep).unwrap();
assert!(
decrypt_tree(&removed.root, &after_keep, &[keep_me], &salt, cipher, None).is_ok(),
"remove 把该保留的密码也弄丢了"
);
for f in all_files(&removed.root) {
let data = std::fs::read(&f).unwrap();
assert!(
crate::file::open(&data, &[to_void.duplicate()]).is_err(),
"被移除的密码仍能打开文件 {f:?}"
);
}
let after_void = root.join("after-void");
std::fs::create_dir_all(&after_void).unwrap();
assert!(
decrypt_tree(&removed.root, &after_void, &[to_void], &salt, cipher, None).is_err(),
"被移除的密码仍能解开整棵树——用户以为撤销了访问权,实际没有"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn rekey_tree_can_add_a_second_password_keeping_the_first() {
let (root, enc_root, salt, cipher) = tree_fixture("add");
let old = kek_of(b"old", &salt);
let extra = kek_of(b"extra", &salt);
let rep = rekey_tree(
&enc_root,
&[old.duplicate()],
&[old.duplicate(), extra.duplicate()],
&salt,
cipher,
crate::keyslot::OtherSlots::Carry,
)
.unwrap();
assert!(rep.is_complete(), "{:?}", rep.failed);
assert_eq!(rep.root, enc_root, "根目录名任何时候都不该变");
for f in all_files(&enc_root) {
let data = std::fs::read(&f).unwrap();
assert!(crate::file::open(&data, &[old.duplicate()]).is_ok(), "原密码被挤掉了");
assert!(crate::file::open(&data, &[extra.duplicate()]).is_ok(), "新密码不生效");
}
let _ = std::fs::remove_dir_all(&root);
}
#[cfg(windows)]
fn partial_failure_fixture(tag: &str) -> (PathBuf, RekeyReport, Kek, Kek, CipherId) {
let (root, enc_root, salt, cipher) = tree_fixture(tag);
let old = kek_of(b"old", &salt);
let new = kek_of(b"new", &salt);
let victim = all_files(&enc_root).into_iter().next().expect("树里应当有文件");
let mut perm = std::fs::metadata(&victim).unwrap().permissions();
perm.set_readonly(true);
std::fs::set_permissions(&victim, perm).unwrap();
let rep =
rekey_tree(&enc_root, &[old.duplicate()], &[new.duplicate()], &salt, cipher, crate::keyslot::OtherSlots::Carry).unwrap();
for (p, _) in &rep.failed {
if let Ok(md) = std::fs::metadata(p) {
#[expect(
clippy::permissions_set_readonly_false,
reason = "仅 Windows 编译,不存在 Unix 上全局可写的问题"
)]
{
let mut perm = md.permissions();
perm.set_readonly(false);
let _ = std::fs::set_permissions(p, perm);
}
}
}
(root, rep, old, new, cipher)
}
#[test]
#[cfg(windows)]
fn partial_failure_reports_paths_that_still_exist() {
let (root, rep, _old, _new, _cipher) = partial_failure_fixture("partpath");
assert!(!rep.is_complete(), "只读文件应当导致部分失败");
assert_eq!(rep.failed.len(), 1, "只有一个文件被设为只读:{:?}", rep.failed);
assert_eq!(rep.dirs_renamed, 0, "不该再有改名发生");
for (p, _) in &rep.failed {
assert!(p.exists(), "报告里的失败路径不存在:{p:?}");
assert!(
p.starts_with(&rep.root),
"失败路径不在报告返回的新根目录下:{p:?} 不以 {:?} 开头",
rep.root
);
}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
#[cfg(windows)]
fn retry_files_finishes_the_job_without_touching_the_rest() {
let (root, rep, old, new, cipher) = partial_failure_fixture("retryok");
let salt = [7u8; 16];
let before = rep.changed;
let list: Vec<PathBuf> = rep.failed.iter().map(|(p, _)| p.clone()).collect();
let names_before: Vec<String> = walk_dirs(&rep.root);
let r2 = retry_files(&list, &[old.duplicate()], &[new.duplicate()], false, crate::keyslot::OtherSlots::Carry, None).unwrap();
assert!(r2.is_complete(), "重试应当成功:{:?}", r2.failed);
assert_eq!(r2.changed, list.len(), "改写数应当等于清单长度");
assert_eq!(walk_dirs(&rep.root), names_before, "重试不该改动目录名");
assert!(rep.root.exists(), "重试不该动根目录");
let out = root.join("dec");
std::fs::create_dir_all(&out).unwrap();
let d = decrypt_tree(&rep.root, &out, &[new], &salt, cipher, None).unwrap();
assert_eq!(
d.files,
before.saturating_add(r2.changed),
"解出的文件数应当等于第一次改好的 + 重试补上的"
);
assert_eq!(std::fs::read(d.root.join("a.txt")).unwrap(), b"AAA");
assert_eq!(std::fs::read(d.root.join("sub/b.txt")).unwrap(), b"BBB");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
#[cfg(windows)]
fn retry_files_only_touches_the_listed_files() {
let (root, rep, old, new, cipher) = partial_failure_fixture("retryscope");
let list: Vec<PathBuf> = rep.failed.iter().map(|(p, _)| p.clone()).collect();
let others: Vec<(PathBuf, Vec<u8>)> = all_files(&rep.root)
.into_iter()
.filter(|p| !list.contains(p))
.map(|p| {
let b = std::fs::read(&p).unwrap();
(p, b)
})
.collect();
assert!(!others.is_empty(), "应当有清单外的文件");
let r2 = retry_files(&list, &[old], &[new], false, crate::keyslot::OtherSlots::Carry, None).unwrap();
assert!(r2.is_complete(), "{:?}", r2.failed);
assert_eq!(r2.changed, list.len(), "改写数不该超过清单长度");
for (p, was) in &others {
assert_eq!(&std::fs::read(p).unwrap(), was, "动了清单外的文件:{p:?}");
}
let _ = std::fs::remove_dir_all(&root);
let _ = cipher;
}
#[test]
fn retry_files_refuses_to_leave_a_file_unopenable() {
let empty: Vec<PathBuf> = Vec::new();
let salt = [7u8; 16];
let k = kek_of(b"x", &salt);
assert!(retry_files(&empty, &[k.duplicate()], &[], false, crate::keyslot::OtherSlots::Carry, None).is_err());
let rep = retry_files(&empty, &[k.duplicate()], &[k], false, crate::keyslot::OtherSlots::Carry, None).unwrap();
assert_eq!(rep.changed, 0);
assert!(rep.is_complete());
}
#[cfg(windows)]
fn walk_dirs(root: &Path) -> Vec<String> {
let mut out = Vec::new();
fn rec(d: &Path, out: &mut Vec<String>) {
let Ok(rd) = std::fs::read_dir(d) else { return };
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
out.push(p.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default());
rec(&p, out);
}
}
}
rec(root, &mut out);
out.sort();
out
}
#[test]
fn find_any_file_skips_files_whose_header_does_not_parse() {
let (root, enc_root, _salt, _cipher) = tree_fixture("skipbad");
let junk = enc_root.join("0000-broken.omy");
std::fs::write(&junk, b"not an omy file at all").unwrap();
let found = find_any_file(&enc_root).expect("应当仍能找到样本文件");
assert_ne!(found, junk, "选中了头部解析不过的文件");
let data = std::fs::read(&found).unwrap();
assert!(
crate::file::peek_header(&data).is_ok(),
"选中的样本头部也解析不过:{found:?}"
);
let only_bad = root.join("onlybad");
std::fs::create_dir_all(&only_bad).unwrap();
let bad_one = only_bad.join("x.omy");
std::fs::write(&bad_one, b"garbage").unwrap();
assert_eq!(find_any_file(&only_bad).as_deref(), Some(bad_one.as_path()));
let empty = root.join("empty");
std::fs::create_dir_all(&empty).unwrap();
std::fs::write(empty.join("readme.txt"), b"hi").unwrap();
assert!(find_any_file(&empty).is_none(), "没有密文文件时应当返回 None");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn rekey_tree_rotate_replaces_the_ciphertext_unlike_a_plain_rekey() {
let (root, enc_root, salt, cipher) = tree_fixture("rot");
let old = kek_of(b"old", &salt);
let new = kek_of(b"new", &salt);
let before: std::collections::BTreeMap<String, Vec<u8>> = all_files(&enc_root)
.into_iter()
.map(|p| {
let d = std::fs::read(&p).unwrap();
(p.file_name().unwrap().to_string_lossy().into_owned(), d)
})
.collect();
let rep = rekey_tree_with_progress(
&enc_root,
&[old.duplicate()],
&[new.duplicate()],
RekeyParams { vault_salt: &salt, cipher, rotate: true, others: crate::keyslot::OtherSlots::Carry },
None,
)
.unwrap();
assert!(rep.is_complete(), "{:?}", rep.failed);
assert!(rep.payload_rewritten, "报告里要标明换了文件密钥");
assert!(rep.bytes_rewritten > 0, "重写的明文字节数不该是 0");
let after: Vec<Vec<u8>> =
all_files(&rep.root).iter().map(|p| std::fs::read(p).unwrap()).collect();
assert_eq!(after.len(), before.len(), "文件数不该变");
for bytes in &after {
assert!(
!before.values().any(|b| b == bytes),
"有文件的密文一字节没变——rotate 没生效"
);
}
let out = root.join("dec");
std::fs::create_dir_all(&out).unwrap();
let d = decrypt_tree(&rep.root, &out, &[new], &salt, cipher, None).unwrap();
assert_eq!(d.files, 2);
assert_eq!(std::fs::read(d.root.join("a.txt")).unwrap(), b"AAA");
assert_eq!(std::fs::read(d.root.join("sub/b.txt")).unwrap(), b"BBB");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn rekey_tree_without_rotate_keeps_the_ciphertext() {
const N: usize = 2000;
let root = std::env::temp_dir().join("omy-rekey-norot");
let _ = std::fs::remove_dir_all(&root);
let src = root.join("src");
std::fs::create_dir_all(&src).unwrap();
let body: Vec<u8> = (0..N).map(|i| (i % 251) as u8).collect();
std::fs::write(src.join("big.bin"), &body).unwrap();
let salt = [7u8; 16];
let out = root.join("enc");
std::fs::create_dir_all(&out).unwrap();
let opts = crate::file::EncryptOptions::default();
let cipher = opts.cipher;
let old = kek_of(b"old", &salt);
let new = kek_of(b"new", &salt);
let enc_root =
encrypt_tree(&src, &out, &[old.duplicate()], &salt, &opts, None).unwrap().root;
let before: std::collections::BTreeMap<String, Vec<u8>> = all_files(&enc_root)
.into_iter()
.map(|p| {
let d = std::fs::read(&p).unwrap();
(p.file_name().unwrap().to_string_lossy().into_owned(), d)
})
.collect();
assert_eq!(before.len(), 1);
let rep =
rekey_tree_with_progress(&enc_root, &[old], &[new], RekeyParams { vault_salt: &salt, cipher, rotate: false, others: crate::keyslot::OtherSlots::Carry }, None)
.unwrap();
assert!(!rep.payload_rewritten);
assert_eq!(rep.bytes_rewritten, 0, "没轮换就不该报重写字节数");
for p in all_files(&rep.root) {
let name = p.file_name().unwrap().to_string_lossy().into_owned();
let old_bytes = before.get(&name).expect("改密码不该换掉磁盘名(file_uuid)");
let new_bytes = std::fs::read(&p).unwrap();
assert_eq!(old_bytes.len(), new_bytes.len(), "长度变了:{p:?}");
let tail = old_bytes.len().saturating_sub(N);
assert_eq!(&old_bytes[tail..], &new_bytes[tail..], "载荷变了:{p:?}");
}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn rekey_tree_rotate_reports_progress_for_every_file() {
let (root, enc_root, salt, cipher) = tree_fixture("prog");
let old = kek_of(b"old", &salt);
let mut seen: Vec<(String, usize, usize)> = Vec::new();
let mut max_ratio = 0.0f64;
let mut cb = |name: &str, idx: usize, total: usize, done: u64, bytes: u64| {
let key = (name.to_owned(), idx, total);
if !seen.contains(&key) {
seen.push(key);
}
if bytes > 0 {
#[expect(clippy::cast_precision_loss, reason = "测试里比个比例够用")]
let r = done as f64 / bytes as f64;
if r > max_ratio {
max_ratio = r;
}
}
};
let rep = rekey_tree_with_progress(
&enc_root,
&[old.duplicate()],
&[old],
RekeyParams { vault_salt: &salt, cipher, rotate: true, others: crate::keyslot::OtherSlots::Carry },
Some(&mut cb),
)
.unwrap();
assert!(rep.is_complete(), "{:?}", rep.failed);
assert_eq!(seen.len(), 2, "两个文件都该汇报进度,实际 {seen:?}");
for (_, idx, total) in &seen {
assert_eq!(*total, 2, "总数应当是文件数");
assert!(*idx >= 1 && *idx <= 2, "序号越界: {idx}");
}
assert_ne!(seen[0].1, seen[1].1, "两个文件报了同一个序号");
assert!(max_ratio > 0.5, "进度最高只到 {max_ratio},没有推进到末尾");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn rekey_tree_rotate_can_keep_the_same_password() {
let (root, enc_root, salt, cipher) = tree_fixture("samepw");
let old = kek_of(b"old", &salt);
let before: Vec<Vec<u8>> =
all_files(&enc_root).iter().map(|p| std::fs::read(p).unwrap()).collect();
let rep = rekey_tree_with_progress(
&enc_root,
&[old.duplicate()],
&[old.duplicate()],
RekeyParams { vault_salt: &salt, cipher, rotate: true, others: crate::keyslot::OtherSlots::Carry },
None,
)
.unwrap();
assert!(rep.is_complete(), "{:?}", rep.failed);
assert_eq!(rep.root, enc_root, "目录名与密码无关,任何时候都不该变");
let after: Vec<Vec<u8>> =
all_files(&enc_root).iter().map(|p| std::fs::read(p).unwrap()).collect();
for b in &after {
assert!(!before.contains(b), "密文没变,旧副本仍然等价");
}
let out = root.join("dec");
std::fs::create_dir_all(&out).unwrap();
let d = decrypt_tree(&enc_root, &out, &[old], &salt, cipher, None).unwrap();
assert_eq!(d.files, 2);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn rekey_tree_leaves_directory_names_openable() {
let (root, enc_root, salt, cipher) = tree_fixture("dirname");
let old = kek_of(b"old", &salt);
let new = kek_of(b"new", &salt);
let rk =
rekey_tree(&enc_root, &[old.duplicate()], &[new.duplicate()], &salt, cipher, crate::keyslot::OtherSlots::Carry).unwrap();
assert!(rk.is_complete(), "{:?}", rk.failed);
assert_eq!(rk.dirs_renamed, 0, "两层结构下换密码不该改名");
assert_eq!(rk.root, enc_root, "根路径不该变");
let out = root.join("dec");
std::fs::create_dir_all(&out).unwrap();
let rep = decrypt_tree(&rk.root, &out, &[new], &salt, cipher, None).unwrap();
assert_eq!(rep.files, 2, "换密码后整棵树应当仍能解开,失败: {:?}", rep.skipped);
assert!(rep.root.join("sub/b.txt").is_file(), "子目录结构没还原对");
let out2 = root.join("dec-old");
std::fs::create_dir_all(&out2).unwrap();
assert!(
decrypt_tree(&rk.root, &out2, &[old], &salt, cipher, None).is_err(),
"旧密码仍能解开整棵树——改密码没生效"
);
let _ = std::fs::remove_dir_all(&root);
}
fn all_files(root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
walk(root, &mut out);
out.sort();
out
}
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
walk(&p, out);
} else if p.extension().is_some_and(|x| x == "omy") {
out.push(p);
}
}
}
fn kek() -> Kek {
Kek::from_password(b"pw", &[3u8; 16], Argon2Params::TEST_WEAK).unwrap()
}
fn make_tree(tag: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("omy-tree-{tag}"));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("文档/草稿")).unwrap();
std::fs::create_dir_all(root.join("empty")).unwrap();
std::fs::write(root.join("a.txt"), b"content of a").unwrap();
std::fs::write(root.join("文档/报告.docx"), b"report body").unwrap();
std::fs::write(root.join("文档/草稿/draft.md"), b"# draft").unwrap();
root
}
fn opts() -> EncryptOptions {
EncryptOptions { argon2: Argon2Params::TEST_WEAK, ..EncryptOptions::default() }
}
fn long_dir_name(unit: &str) -> String {
let unit_len = unit.len();
assert!(unit_len > 0, "重复单元不能为空");
let times = 200_usize.div_euclid(unit_len).max(1);
let name = unit.repeat(times);
assert!(
name.len() > 128,
"目录名 {} 字节,不足以触发截断+边车",
name.len()
);
assert!(
name.len() <= 255,
"目录名 {} 字节,超过 Linux 单组件上限,测试自身会建不出目录",
name.len()
);
name
}
#[cfg(unix)]
#[test]
fn tree_mode_reports_symlinks_as_skipped_and_does_not_follow() {
let root = std::env::temp_dir().join("omy-tree-symlink-src");
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("real.txt"), b"real body").unwrap();
std::os::unix::fs::symlink("real.txt", root.join("link.txt")).unwrap();
let out = std::env::temp_dir().join("omy-tree-symlink-out");
let _ = std::fs::remove_dir_all(&out);
std::fs::create_dir_all(&out).unwrap();
let ks = [kek()];
let rep = encrypt_tree(&root, &out, &ks, &[3u8; 16], &opts(), None).unwrap();
assert_eq!(rep.files, 1, "只有 real.txt 该被加密;跟随链接会变成 2");
assert!(
rep.skipped.iter().any(|s| s.reason == SkipReason::Symlink),
"链接必须如实报成跳过,实际 {:?}",
rep.skipped
);
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&out);
}
#[test]
fn find_any_file_skips_the_name_sidecar() {
let root = std::env::temp_dir().join("omy-find-any-sidecar");
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("sub")).unwrap();
std::fs::write(root.join(crate::dirname::DIRNAME_SIDECAR), b"not a file").unwrap();
std::fs::write(root.join("sub/aabb.omy"), b"pretend header").unwrap();
let got = find_any_file(&root).expect("应当找到子目录里的 .omy");
assert_eq!(
got.file_name().and_then(|s| s.to_str()),
Some("aabb.omy"),
"拿到的必须是真加密文件而不是边车,实际 {got:?}"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn find_any_file_returns_none_for_tree_without_files() {
let root = std::env::temp_dir().join("omy-find-any-empty");
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("a/b/c")).unwrap();
assert!(find_any_file(&root).is_none(), "空树应当返回 None");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn roundtrips_whole_tree() {
let src = make_tree("round");
let enc_parent = std::env::temp_dir().join("omy-tree-round-enc");
let dec_parent = std::env::temp_dir().join("omy-tree-round-dec");
let _ = std::fs::remove_dir_all(&enc_parent);
let _ = std::fs::remove_dir_all(&dec_parent);
std::fs::create_dir_all(&enc_parent).unwrap();
std::fs::create_dir_all(&dec_parent).unwrap();
let ks = [kek()];
let rep = encrypt_tree(&src, &enc_parent, &ks, &[3u8; 16], &opts(), None).unwrap();
assert_eq!(rep.files, 3, "三个文件都要加密");
assert_eq!(rep.dirs, 4, "根 + 文档 + 草稿 + empty");
let out = decrypt_tree(
&rep.root,
&dec_parent,
&ks,
&[3u8; 16],
CipherId::ChaCha20Poly1305,
None,
)
.unwrap();
assert_eq!(
std::fs::read(out.root.join("a.txt")).unwrap(),
b"content of a"
);
assert_eq!(
std::fs::read(out.root.join("文档/报告.docx")).unwrap(),
b"report body"
);
assert_eq!(
std::fs::read(out.root.join("文档/草稿/draft.md")).unwrap(),
b"# draft"
);
assert!(out.root.join("empty").is_dir(), "空目录也要还原");
assert_eq!(
out.root.file_name().unwrap().to_string_lossy(),
"omy-tree-round",
"根目录名要还原成原名"
);
for p in [&src, &enc_parent, &dec_parent] {
let _ = std::fs::remove_dir_all(p);
}
}
#[test]
fn no_plaintext_name_appears_on_disk() {
let src = make_tree("names");
let enc_parent = std::env::temp_dir().join("omy-tree-names-enc");
let _ = std::fs::remove_dir_all(&enc_parent);
std::fs::create_dir_all(&enc_parent).unwrap();
let ks = [kek()];
let rep = encrypt_tree(&src, &enc_parent, &ks, &[3u8; 16], &opts(), None).unwrap();
let mut all = Vec::new();
collect_paths(&rep.root, &mut all);
let joined = all.join("|");
for leaked in ["文档", "草稿", "报告", "draft", "a.txt", "empty"] {
assert!(
!joined.contains(leaked),
"明文名 {leaked} 出现在密文树的路径里:{joined}"
);
}
for p in [&src, &enc_parent] {
let _ = std::fs::remove_dir_all(p);
}
}
#[test]
fn structure_is_preserved_and_therefore_leaked() {
let src = make_tree("leak");
let enc_parent = std::env::temp_dir().join("omy-tree-leak-enc");
let _ = std::fs::remove_dir_all(&enc_parent);
std::fs::create_dir_all(&enc_parent).unwrap();
let ks = [kek()];
let rep = encrypt_tree(&src, &enc_parent, &ks, &[3u8; 16], &opts(), None).unwrap();
let mut files = 0;
let mut dirs = 0;
count(&rep.root, &mut files, &mut dirs);
assert_eq!(files, 3, "文件数对外可见——这正是 N6 声明的泄露");
assert_eq!(dirs, 4, "目录数同样可见");
let mut sidecars = 0;
count_sidecars(&rep.root, &mut sidecars);
assert_eq!(sidecars, dirs, "每个密文目录都该有一份边车");
let notice = TreeReport::leak_notice();
assert!(notice.contains("多少文件"), "告知文案要说清泄露什么");
assert!(notice.contains("看不到文件名"), "也要说清藏住了什么");
for p in [&src, &enc_parent] {
let _ = std::fs::remove_dir_all(p);
}
}
#[test]
fn wrong_password_cannot_decrypt_tree() {
let src = make_tree("wrongpw");
let enc_parent = std::env::temp_dir().join("omy-tree-wrongpw-enc");
let dec_parent = std::env::temp_dir().join("omy-tree-wrongpw-dec");
for p in [&enc_parent, &dec_parent] {
let _ = std::fs::remove_dir_all(p);
std::fs::create_dir_all(p).unwrap();
}
let rep =
encrypt_tree(&src, &enc_parent, &[kek()], &[3u8; 16], &opts(), None).unwrap();
let bad = Kek::from_password(b"wrong", &[3u8; 16], Argon2Params::TEST_WEAK).unwrap();
assert!(
decrypt_tree(
&rep.root,
&dec_parent,
&[bad],
&[3u8; 16],
CipherId::ChaCha20Poly1305,
None
)
.is_err(),
"错密码必须解不开"
);
for p in [&src, &enc_parent, &dec_parent] {
let _ = std::fs::remove_dir_all(p);
}
}
#[test]
fn same_content_files_get_different_disk_names() {
let root = std::env::temp_dir().join("omy-tree-dup");
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("d1")).unwrap();
std::fs::create_dir_all(root.join("d2")).unwrap();
std::fs::write(root.join("d1/same.txt"), b"identical").unwrap();
std::fs::write(root.join("d2/same.txt"), b"identical").unwrap();
let enc_parent = std::env::temp_dir().join("omy-tree-dup-enc");
let _ = std::fs::remove_dir_all(&enc_parent);
std::fs::create_dir_all(&enc_parent).unwrap();
let rep =
encrypt_tree(&root, &enc_parent, &[kek()], &[3u8; 16], &opts(), None).unwrap();
let mut names = Vec::new();
collect_file_names(&rep.root, &mut names);
assert_eq!(names.len(), 2);
assert_ne!(names[0], names[1], "同名同内容的文件不该得到相同磁盘名");
for p in [&root, &enc_parent] {
let _ = std::fs::remove_dir_all(p);
}
}
#[test]
fn long_directory_name_survives_roundtrip() {
let root = std::env::temp_dir().join("omy-tree-long");
let _ = std::fs::remove_dir_all(&root);
let long = long_dir_name("很长的目录名");
std::fs::create_dir_all(root.join(&long)).unwrap();
std::fs::write(root.join(&long).join("x.txt"), b"deep").unwrap();
let enc_parent = std::env::temp_dir().join("omy-tree-long-enc");
let dec_parent = std::env::temp_dir().join("omy-tree-long-dec");
for p in [&enc_parent, &dec_parent] {
let _ = std::fs::remove_dir_all(p);
std::fs::create_dir_all(p).unwrap();
}
let rep =
encrypt_tree(&root, &enc_parent, &[kek()], &[3u8; 16], &opts(), None).unwrap();
assert!(rep.long_names >= 1, "该报告有超长目录名");
let out = decrypt_tree(
&rep.root,
&dec_parent,
&[kek()],
&[3u8; 16],
CipherId::ChaCha20Poly1305,
None,
)
.unwrap();
assert_eq!(
std::fs::read(out.root.join(&long).join("x.txt")).unwrap(),
b"deep",
"超长目录名要能完整还原"
);
for p in [&root, &enc_parent, &dec_parent] {
let _ = std::fs::remove_dir_all(p);
}
}
#[test]
fn sidecar_file_is_not_restored_as_user_data() {
let root = std::env::temp_dir().join("omy-tree-sc");
let _ = std::fs::remove_dir_all(&root);
let long = long_dir_name("长目录名");
std::fs::create_dir_all(root.join(&long)).unwrap();
std::fs::write(root.join(&long).join("f.txt"), b"x").unwrap();
let enc_parent = std::env::temp_dir().join("omy-tree-sc-enc");
let dec_parent = std::env::temp_dir().join("omy-tree-sc-dec");
for p in [&enc_parent, &dec_parent] {
let _ = std::fs::remove_dir_all(p);
std::fs::create_dir_all(p).unwrap();
}
let rep =
encrypt_tree(&root, &enc_parent, &[kek()], &[3u8; 16], &opts(), None).unwrap();
assert!(
rep.long_names >= 1,
"该用例需要触发截断+边车,实际 long_names={}",
rep.long_names
);
let out = decrypt_tree(
&rep.root,
&dec_parent,
&[kek()],
&[3u8; 16],
CipherId::ChaCha20Poly1305,
None,
)
.unwrap();
let mut all = Vec::new();
collect_paths(&out.root, &mut all);
assert!(
!all.iter().any(|p| p.contains(DIRNAME_SIDECAR)),
"还原结果里不该出现边车文件:{all:?}"
);
assert!(
out.skipped.is_empty(),
"干净的树还原后不该有跳过项,实际 {:?}",
out.skipped
);
for p in [&root, &enc_parent, &dec_parent] {
let _ = std::fs::remove_dir_all(p);
}
}
#[test]
fn malicious_filename_in_ciphertext_cannot_escape() {
let sandbox = std::env::temp_dir().join("omy-tree-evil-sandbox");
let _ = std::fs::remove_dir_all(&sandbox);
let enc_parent = sandbox.join("enc");
let dec_parent = sandbox.join("a/b");
for p in [&enc_parent, &dec_parent] {
std::fs::create_dir_all(p).unwrap();
}
let src = make_tree("evil");
let ks = [kek()];
let rep = encrypt_tree(&src, &enc_parent, &ks, &[3u8; 16], &opts(), None).unwrap();
let mut evil = opts();
evil.filename = Some(String::from("../../escaped.txt"));
let rnd = RandomMaterial::generate();
let ef = crate::file::encrypt(b"pwned", &ks, &[3u8; 16], &evil, &rnd).unwrap();
std::fs::write(rep.root.join("deadbeef.omy"), &ef.bytes).unwrap();
let out = decrypt_tree(
&rep.root,
&dec_parent,
&ks,
&[3u8; 16],
CipherId::ChaCha20Poly1305,
None,
)
.unwrap();
for up in [sandbox.join("a"), sandbox.clone(), dec_parent.clone()] {
let leaked = up.join("escaped.txt");
assert!(!leaked.exists(), "文件逃出了还原根目录:{}", leaked.display());
}
let mut names = Vec::new();
collect_file_names(&out.root, &mut names);
assert!(
names.iter().any(|n| n.contains("escaped.txt") && !n.contains('/')),
"逃逸名应被清洗后留在根目录内,实际 {names:?}"
);
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&sandbox);
}
#[test]
fn rejects_non_directory() {
let f = std::env::temp_dir().join("omy-tree-not-dir.txt");
std::fs::write(&f, b"x").unwrap();
assert!(
encrypt_tree(&f, &std::env::temp_dir(), &[kek()], &[3u8; 16], &opts(), None).is_err(),
"对文件调用必须报错"
);
let _ = std::fs::remove_file(&f);
}
#[test]
fn progress_reports_every_file() {
let src = make_tree("prog");
let enc_parent = std::env::temp_dir().join("omy-tree-prog-enc");
let _ = std::fs::remove_dir_all(&enc_parent);
std::fs::create_dir_all(&enc_parent).unwrap();
let mut seen = Vec::new();
let mut cb = |n: &str, s: u64| seen.push((n.to_owned(), s));
let rep =
encrypt_tree(&src, &enc_parent, &[kek()], &[3u8; 16], &opts(), Some(&mut cb)).unwrap();
assert_eq!(seen.len(), rep.files, "每个文件都要回调一次");
for p in [&src, &enc_parent] {
let _ = std::fs::remove_dir_all(p);
}
}
fn collect_paths(dir: &Path, out: &mut Vec<String>) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
for e in rd.flatten() {
out.push(e.file_name().to_string_lossy().into_owned());
if e.path().is_dir() {
collect_paths(&e.path(), out);
}
}
}
fn collect_file_names(dir: &Path, out: &mut Vec<String>) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
for e in rd.flatten() {
if e.path().is_dir() {
collect_file_names(&e.path(), out);
continue;
}
let name = e.file_name().to_string_lossy().into_owned();
if name == crate::dirsidecar::KEYS_SIDECAR || name == DIRNAME_SIDECAR {
continue;
}
out.push(name);
}
}
fn count(dir: &Path, files: &mut usize, dirs: &mut usize) {
*dirs = dirs.saturating_add(1);
let Ok(rd) = std::fs::read_dir(dir) else { return };
for e in rd.flatten() {
if e.path().is_dir() {
count(&e.path(), files, dirs);
continue;
}
let name = e.file_name().to_string_lossy().into_owned();
if name == crate::dirsidecar::KEYS_SIDECAR || name == DIRNAME_SIDECAR {
continue;
}
*files = files.saturating_add(1);
}
}
fn count_sidecars(dir: &Path, n: &mut usize) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
for e in rd.flatten() {
if e.path().is_dir() {
count_sidecars(&e.path(), n);
} else if e.file_name().to_string_lossy() == crate::dirsidecar::KEYS_SIDECAR {
*n = n.saturating_add(1);
}
}
}
}