1use 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#[derive(Debug, Default)]
54pub struct TreeReport {
55 pub root: PathBuf,
57 pub files: usize,
59 pub dirs: usize,
61 pub long_names: usize,
66 pub skipped: Vec<SkippedEntry>,
68}
69
70impl TreeReport {
71 #[must_use]
77 pub const fn leak_notice() -> &'static str {
78 "逐个加密模式下,别人能看到你有多少文件、每个文件多大、目录有几层,但看不到文件名和内容。"
79 }
80}
81
82pub type MediaExtrasFn<'a> =
96 &'a mut dyn FnMut(&[u8], &str) -> (Option<Vec<u8>>, Option<Vec<u8>>, Option<Vec<u8>>);
97
98pub 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
126pub 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 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 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 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 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 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 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 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 let mut fopts = opts.clone();
253 fopts.filename = Some(name.clone());
254 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 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
285fn 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 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
315fn 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 let raw = std::fs::read(&path).ok();
333 let was_managed = raw.as_deref().is_some_and(crate::dirsidecar::is_managed);
334 let old = match others {
336 crate::keyslot::OtherSlots::Carry => raw,
337 crate::keyslot::OtherSlots::Discard => None,
338 };
339 let blob = if was_managed {
343 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
371fn 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#[derive(Debug)]
390pub struct TreeSlots {
391 pub managed: bool,
393 pub directory: Option<crate::slotdir::SlotDirectory>,
395 pub current: usize,
397}
398
399pub 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
423pub 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 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
498fn 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 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
511pub 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 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
552fn 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 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 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 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
627fn 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 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
653fn random_nonce() -> [u8; NONCE_LEN] {
658 let mut n = [0u8; NONCE_LEN];
659 crate::util::fill_random(&mut n);
660 n
661}
662
663#[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
693fn find_sample(dir: &Path, damaged: &mut Option<PathBuf>) -> Option<PathBuf> {
695 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 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
721fn 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#[derive(Debug, Default)]
746pub struct RekeyReport {
747 pub changed: usize,
749 pub dirs_renamed: usize,
751 pub root: PathBuf,
757 pub failed: Vec<(PathBuf, String)>,
763 pub payload_rewritten: bool,
765 pub bytes_rewritten: u64,
767}
768
769impl RekeyReport {
770 #[must_use]
772 pub const fn is_complete(&self) -> bool {
773 self.failed.is_empty()
774 }
775}
776
777pub 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
839pub type RekeyProgressFn<'a> = &'a mut dyn FnMut(&str, usize, usize, u64, u64);
845
846#[derive(Debug, Clone, Copy)]
852pub struct RekeyParams<'a> {
853 pub vault_salt: &'a [u8; 16],
855 pub cipher: CipherId,
857 pub rotate: bool,
859 pub others: crate::keyslot::OtherSlots,
861}
862
863pub 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 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 let dk = read_keys_sidecar(root, unlock, vault_salt, cipher)?;
912
913 let mut rep = RekeyReport { payload_rewritten: rotate, ..RekeyReport::default() };
914 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 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 rep.root = root.to_path_buf();
937 Ok(rep)
938}
939
940pub 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 Ok(rep)
993}
994
995struct RekeyCtx<'a> {
1013 unlock: &'a [Kek],
1014 keep: &'a [Kek],
1015 dk: &'a crate::dirsidecar::DirKey,
1018 vault_salt: &'a [u8; 16],
1019 cipher: CipherId,
1020 others: crate::keyslot::OtherSlots,
1026 rotate: bool,
1027 total: usize,
1029 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 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 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 for sub in subdirs {
1079 rekey_into(&sub, ctx, progress, rep);
1080 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
1092fn 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
1113fn 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 let (bytes, written) = if rotate {
1128 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 let out = crate::keyslot::rewrite_slots(&data, unlock, keep, others)
1164 .map_err(|e| e.to_string())?;
1165 (out.bytes, 0)
1166 };
1167
1168 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
1181fn hex16(b: &[u8; 16]) -> String {
1183 let mut s = String::with_capacity(32);
1184 for byte in b {
1185 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 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 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 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 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 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 assert_eq!(rep.root, enc_root, "换密码不该改目录名");
1330 assert!(rep.root.is_dir(), "根路径应当仍然有效");
1331 let enc_root = rep.root.clone();
1332
1333 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 assert!(opened.filename().unwrap().ends_with(".txt"));
1340 checked += 1;
1341 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 let (root, enc_root, salt, cipher) = tree_fixture("payload");
1357 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 assert_eq!(rep.dirs_renamed, 0, "不该再有改名发生");
1605
1606 for (p, _) in &rep.failed {
1607 assert!(p.exists(), "报告里的失败路径不存在:{p:?}");
1608 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 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 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 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 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 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 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 #[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 let (root, enc_root, _salt, _cipher) = tree_fixture("skipbad");
1725
1726 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 let data = std::fs::read(&found).unwrap();
1734 assert!(
1735 crate::file::peek_header(&data).is_ok(),
1736 "选中的样本头部也解析不过:{found:?}"
1737 );
1738
1739 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 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 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 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 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 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 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 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 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 assert_ne!(seen[0].1, seen[1].1, "两个文件报了同一个序号");
1902 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 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 assert_eq!(rep.root, enc_root, "目录名与密码无关,任何时候都不该变");
1927
1928 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 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 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 assert_eq!(rk.dirs_renamed, 0, "两层结构下换密码不该改名");
1955 assert_eq!(rk.root, enc_root, "根路径不该变");
1956
1957 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 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 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 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 fn long_dir_name(unit: &str) -> String {
2029 let unit_len = unit.len();
2030 assert!(unit_len > 0, "重复单元不能为空");
2031 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 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 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 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 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 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 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 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 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 let mut sidecars = 0;
2221 count_sidecars(&rep.root, &mut sidecars);
2222 assert_eq!(sidecars, dirs, "每个密文目录都该有一份边车");
2223
2224 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 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 let root = std::env::temp_dir().join("omy-tree-long");
2300 let _ = std::fs::remove_dir_all(&root);
2301 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 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 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 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 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 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 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 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 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 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 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 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 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}