1use std::{
52 fs,
53 io::{Read, Seek, SeekFrom, Write},
54 path::{Path, PathBuf},
55 sync::{
56 Arc, OnceLock,
57 atomic::{AtomicUsize, Ordering},
58 },
59};
60
61use argon2::Argon2;
62use chacha20poly1305::{
63 XChaCha20Poly1305, XNonce,
64 aead::{Aead, KeyInit, Payload},
65};
66use dashmap::DashMap;
67use log::{debug, warn};
68use pathdiff::diff_paths;
69use rand::prelude::*;
70use rayon::prelude::*;
71use tempfile::NamedTempFile;
72use zeroize::Zeroizing;
73
74use crate::{
75 error::{Error, Result},
76 repo::Repo,
77 salt_cache::{self, CacheRef, CachedEntry},
78 utils::{
79 Progress, is_file_encrypted, print_post_report, print_pre_report, resolve_target_files,
80 },
81};
82
83pub const MAGIC: &[u8; 5] = b"GITSE";
86pub const VERSION: u8 = 3;
88const FLAG_COMPRESSED: u8 = 1 << 0; const ENC_ALGO: u8 = 1; pub const SALT_LEN: usize = 16;
93pub const FILE_ID_LEN: usize = 16;
94pub const NONCE_LEN: usize = 24; pub const HEADER_LEN: usize = 64;
96const RESERVED_LEN: usize = HEADER_LEN - (MAGIC.len() + 1 + 1 + 1 + SALT_LEN + FILE_ID_LEN); const CHUNK_SIZE: usize = 65536; #[inline]
103#[must_use]
104pub const fn is_encrypted_version(v: u8) -> bool {
105 v == VERSION
106}
107
108#[repr(C)]
121#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122pub struct FileHeader {
123 magic: [u8; 5],
124 version: u8,
125 flags: u8,
126 enc_algo: u8,
127 salt: [u8; SALT_LEN],
128 file_id: [u8; FILE_ID_LEN],
129 reserved: [u8; RESERVED_LEN],
130}
131
132const _: () = assert!(std::mem::size_of::<FileHeader>() == HEADER_LEN);
134const _: () = assert!(std::mem::align_of::<FileHeader>() == 1);
135
136impl FileHeader {
137 #[must_use]
141 pub const fn new(compressed: bool, salt: [u8; SALT_LEN], file_id: [u8; FILE_ID_LEN]) -> Self {
142 let mut flags = 0u8;
143 if compressed {
144 flags |= FLAG_COMPRESSED;
145 }
146
147 Self {
148 magic: *MAGIC,
149 version: VERSION,
150 flags,
151 enc_algo: ENC_ALGO,
152 salt,
153 file_id,
154 reserved: [0u8; RESERVED_LEN],
155 }
156 }
157
158 #[must_use]
160 pub fn generate_file_id() -> [u8; FILE_ID_LEN] {
161 let mut rng = rand::rng();
162 let mut id = [0u8; FILE_ID_LEN];
163 rng.fill_bytes(&mut id);
164 id
165 }
166
167 pub fn from_bytes(bytes: &[u8; HEADER_LEN]) -> Result<&Self> {
171 let header: &Self = unsafe { &*(bytes.as_ptr().cast()) };
175
176 if &header.magic != MAGIC {
177 return Err(Error::InvalidMagic);
178 }
179 if !is_encrypted_version(header.version) {
180 return Err(Error::UnsupportedVersion(header.version));
181 }
182 if header.enc_algo != ENC_ALGO {
183 return Err(Error::UnsupportedAlgo(header.enc_algo));
184 }
185
186 Ok(header)
187 }
188
189 pub fn read_from<R: Read>(reader: &mut R) -> Result<Self> {
194 let mut buf = [0u8; HEADER_LEN];
195 reader.read_exact(&mut buf)?;
196 Ok(*Self::from_bytes(&buf)?)
197 }
198
199 pub fn write_to<W: Write>(&self, writer: &mut W) -> Result<()> {
201 writer.write_all(self.as_bytes())?;
202 Ok(())
203 }
204
205 #[must_use]
209 #[inline]
210 pub const fn as_bytes(&self) -> &[u8; HEADER_LEN] {
211 unsafe { &*std::ptr::from_ref::<Self>(self).cast() }
214 }
215
216 #[must_use]
217 pub const fn is_compressed(&self) -> bool {
218 (self.flags & FLAG_COMPRESSED) != 0
219 }
220}
221
222fn derive_key(password: &[u8], salt: &[u8]) -> Result<Zeroizing<[u8; 32]>> {
228 let mut key = Zeroizing::new([0u8; 32]);
229 Argon2::default()
230 .hash_password_into(password, salt, &mut *key)
231 .map_err(|e| Error::Argon2(e.to_string()))?;
232 Ok(key)
233}
234
235fn split_keys(master_key: &[u8; 32]) -> (Zeroizing<[u8; 32]>, Zeroizing<[u8; 32]>) {
238 let key_enc = blake3::derive_key("git-simple-encrypt-enc", master_key);
239 let key_mac = blake3::derive_key("git-simple-encrypt-mac", master_key);
240 (Zeroizing::new(key_enc), Zeroizing::new(key_mac))
241}
242
243fn derive_nonce(
253 key_mac: &[u8; 32],
254 file_id: &[u8; FILE_ID_LEN],
255 plaintext: &[u8],
256 chunk_idx: u64,
257) -> [u8; NONCE_LEN] {
258 let mut hasher = blake3::Hasher::new_keyed(key_mac);
259 hasher.update(file_id);
260 hasher.update(plaintext);
261 hasher.update(&chunk_idx.to_le_bytes());
262 let hash = hasher.finalize();
263 let mut nonce = [0u8; NONCE_LEN];
264 nonce.copy_from_slice(&hash.as_bytes()[..NONCE_LEN]);
265 nonce
266}
267
268fn atomic_write_with_metadata(original_path: &Path, temp_file: NamedTempFile) -> Result<()> {
271 if let Err(e) = copy_metadata::copy_metadata(original_path, temp_file.path()) {
274 warn!(
275 "Could not copy metadata for {}: {}",
276 original_path.display(),
277 e
278 );
279 }
280 temp_file
281 .persist(original_path)
282 .map_err(|e| Error::AtomicPersist(original_path.to_path_buf(), e.to_string()))?;
283 Ok(())
284}
285
286fn cache_key(file_path: &Path, repo_path: &Path) -> Vec<u8> {
294 let relative = if file_path.is_absolute() {
295 diff_paths(file_path, repo_path).unwrap_or_else(|| file_path.to_path_buf())
296 } else {
297 file_path.to_path_buf()
298 };
299 let mut bytes = relative.into_os_string().into_encoded_bytes();
300 for b in &mut bytes {
301 if *b == b'\\' {
302 *b = b'/';
303 }
304 }
305 bytes
306}
307
308type KeyCache = DashMap<[u8; SALT_LEN], Arc<OnceLock<Result<Zeroizing<[u8; 32]>, String>>>>;
321
322fn get_or_derive_key(
329 key_cache: &KeyCache,
330 master_key: &[u8],
331 salt: &[u8; SALT_LEN],
332) -> Result<Zeroizing<[u8; 32]>> {
333 let lock = {
338 let guard = key_cache
339 .entry(*salt)
340 .or_insert_with(|| Arc::new(OnceLock::new()));
341 Arc::clone(&*guard)
342 };
343
344 match lock.get_or_init(|| derive_key(master_key, salt).map_err(|e| e.to_string())) {
347 Ok(key) => Ok(key.clone()),
348 Err(msg) => Err(Error::Argon2(msg.clone())),
349 }
350}
351
352pub fn encrypt_file(
359 path: &Path,
360 derived_key: &[u8; 32],
361 salt: &[u8; SALT_LEN],
362 file_id: Option<[u8; FILE_ID_LEN]>,
363 zstd: Option<u8>,
364) -> Result<Option<FileHeader>> {
365 let mut file = fs::File::open(path)?;
366
367 let mut header_bytes = [0u8; HEADER_LEN];
369 if file.read_exact(&mut header_bytes).is_ok()
370 && &header_bytes[0..5] == MAGIC
371 && is_encrypted_version(header_bytes[5])
372 {
373 warn!("File already encrypted, skipping: {}", path.display());
374 return Ok(None);
375 }
376 file.seek(SeekFrom::Start(0))?; debug!("Encrypting: {}", path.display());
379
380 let file_id = file_id.unwrap_or_else(FileHeader::generate_file_id);
382 let header = FileHeader::new(zstd.is_some(), *salt, file_id);
383 let parent_dir = path.parent().unwrap_or_else(|| Path::new("."));
384 let mut temp_file = NamedTempFile::new_in(parent_dir)?;
385
386 header.write_to(&mut temp_file)?;
387
388 let (key_enc, key_mac) = split_keys(derived_key);
390 let cipher = XChaCha20Poly1305::new(key_enc.as_ref().into());
391
392 let mut reader: Box<dyn Read> = if let Some(zstd_level) = zstd {
394 Box::new(zstd::stream::read::Encoder::new(
395 file,
396 i32::from(zstd_level),
397 )?)
398 } else {
399 Box::new(file)
400 };
401
402 let mut buffer = Zeroizing::new(vec![0u8; CHUNK_SIZE]);
406 let mut out_buf: Vec<u8> = Vec::with_capacity(NONCE_LEN + CHUNK_SIZE + 16);
407 let mut aad = {
408 let mut aad = [0u8; HEADER_LEN + 9];
409 aad[..HEADER_LEN].copy_from_slice(header.as_bytes());
410 aad
411 };
412 let mut chunk_idx = 0u64;
413
414 loop {
415 let mut bytes_read = 0;
416 while bytes_read < CHUNK_SIZE {
417 let n = reader.read(&mut buffer[bytes_read..])?;
418 if n == 0 {
419 break;
420 }
421 bytes_read += n;
422 }
423
424 let is_last_chunk = bytes_read < CHUNK_SIZE;
425 aad[HEADER_LEN..HEADER_LEN + 8].copy_from_slice(&chunk_idx.to_le_bytes());
427 aad[HEADER_LEN + 8] = u8::from(is_last_chunk);
428
429 let nonce_bytes = derive_nonce(&key_mac, &file_id, &buffer[..bytes_read], chunk_idx);
431 let nonce = XNonce::from(nonce_bytes);
432
433 let payload = Payload {
434 msg: &buffer[..bytes_read],
435 aad: &aad,
436 };
437
438 let ciphertext = cipher
439 .encrypt(&nonce, payload)
440 .map_err(|e| Error::EncryptFailed(e.to_string()))?;
441
442 out_buf.clear();
444 out_buf.extend_from_slice(&nonce_bytes);
445 out_buf.extend_from_slice(&ciphertext);
446 temp_file.write_all(&out_buf)?;
447
448 chunk_idx += 1;
449
450 if is_last_chunk {
451 break;
452 }
453 }
454
455 drop(reader);
457 atomic_write_with_metadata(path, temp_file)?;
458
459 Ok(Some(header))
460}
461
462pub fn decrypt_file(path: &Path, master_key: &[u8]) -> Result<()> {
464 let key_cache: KeyCache = DashMap::new();
465 decrypt_file_with_cache(path, &key_cache, None, master_key)
466}
467
468pub fn decrypt_file_with_cache(
474 path: &Path,
475 key_cache: &KeyCache,
476 cache: Option<CacheRef<'_>>,
477 master_key: &[u8],
478) -> Result<()> {
479 let mut file = fs::File::open(path)?;
480
481 let mut header_bytes = [0u8; HEADER_LEN];
483 if file.read_exact(&mut header_bytes).is_err() {
484 debug!(
485 "File too small to be encrypted, skipping: {}",
486 path.display()
487 );
488 return Ok(());
489 }
490 if &header_bytes[0..5] != MAGIC || !is_encrypted_version(header_bytes[5]) {
491 debug!(
492 "File not encrypted (no magic), skipping: {}",
493 path.display()
494 );
495 return Ok(());
496 }
497
498 debug!("Decrypting: {}", path.display());
499 let header = *FileHeader::from_bytes(&header_bytes)?;
500
501 if let Some(cache) = cache {
504 cache.sender.insert(
505 cache.key,
506 CachedEntry {
507 salt: header.salt,
508 file_id: header.file_id,
509 },
510 );
511 }
512
513 let derived_key = get_or_derive_key(key_cache, master_key, &header.salt)?;
515
516 let (key_enc, _key_mac) = split_keys(&derived_key);
518 let cipher = XChaCha20Poly1305::new(key_enc.as_ref().into());
519 let parent_dir = path.parent().unwrap_or_else(|| Path::new("."));
520 let mut temp_file = NamedTempFile::new_in(parent_dir)?;
521
522 if header.is_compressed() {
524 let mut decoder = zstd::stream::write::Decoder::new(&mut temp_file)?.auto_flush();
525 decrypt_chunks(&mut file, &mut decoder, &cipher, header.as_bytes())?;
526 decoder.flush()?;
527 } else {
528 decrypt_chunks(&mut file, &mut temp_file, &cipher, header.as_bytes())?;
529 }
530 drop(file);
531
532 atomic_write_with_metadata(path, temp_file)?;
534
535 Ok(())
536}
537
538fn decrypt_chunks(
547 file: &mut fs::File,
548 writer: &mut dyn Write,
549 cipher: &XChaCha20Poly1305,
550 header_bytes: &[u8; HEADER_LEN],
551) -> Result<()> {
552 let mut nonce_buf = [0u8; NONCE_LEN];
553 let mut ct_buffer = Zeroizing::new(vec![0u8; CHUNK_SIZE + 16]); let ct_len = ct_buffer.len();
556 let mut aad = {
559 let mut aad = [0u8; HEADER_LEN + 9];
560 aad[..HEADER_LEN].copy_from_slice(header_bytes);
561 aad
562 };
563 let mut last_chunk_was_final = false;
564 let mut chunk_idx = 0u64;
565
566 loop {
567 match file.read_exact(&mut nonce_buf) {
569 Ok(()) => {}
570 Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
571 Err(e) => return Err(e.into()),
572 }
573
574 let mut bytes_read = 0;
576 while bytes_read < ct_len {
577 let n = file.read(&mut ct_buffer[bytes_read..])?;
578 if n == 0 {
579 break;
580 }
581 bytes_read += n;
582 }
583
584 if bytes_read == 0 {
585 return Err(Error::TruncatedChunk);
586 }
587
588 let is_last_chunk = bytes_read < ct_len;
589
590 aad[HEADER_LEN..HEADER_LEN + 8].copy_from_slice(&chunk_idx.to_le_bytes());
592 aad[HEADER_LEN + 8] = u8::from(is_last_chunk);
593
594 let nonce = XNonce::from(nonce_buf);
595 let payload = chacha20poly1305::aead::Payload {
596 msg: &ct_buffer[..bytes_read],
597 aad: &aad,
598 };
599
600 let plaintext = Zeroizing::new(
601 cipher
602 .decrypt(&nonce, payload)
603 .map_err(|e| Error::DecryptFailed(e.to_string()))?,
604 );
605
606 writer.write_all(&plaintext)?;
607
608 chunk_idx += 1;
609
610 if is_last_chunk {
611 last_chunk_was_final = true;
612 break;
613 }
614 }
615
616 if !last_chunk_was_final {
617 return Err(Error::FileTruncated);
618 }
619
620 Ok(())
621}
622
623pub fn encrypt_repo(repo: &Repo, paths: &[PathBuf]) -> Result<()> {
638 let key = repo.get_key()?;
639 if key.is_empty() {
640 return Err(Error::EmptyKey);
641 }
642
643 let target_files = resolve_target_files(paths, &repo.conf.crypt_list, repo.path());
644 if target_files.is_empty() {
645 return Err(Error::NoFile("encrypt"));
646 }
647
648 print_pre_report("Encrypting", &target_files, repo.path());
650
651 let reader = salt_cache::SaltCacheReader::load(repo.path());
653
654 let key_cache: KeyCache = DashMap::new();
655
656 let mut batch_salt = [0u8; SALT_LEN];
658 rand::rng().fill_bytes(&mut batch_salt);
659
660 let pb = Progress::new(target_files.len(), "Encrypt");
661 let skipped = AtomicUsize::new(0);
662 let failed = AtomicUsize::new(0);
663
664 let result = {
665 let errors: parking_lot::Mutex<Vec<Error>> = parking_lot::Mutex::new(Vec::new());
666 target_files.par_iter().for_each(|f| {
667 let relative_key = cache_key(f, repo.path());
668 let (salt, cached_file_id) = reader
669 .get(&relative_key)
670 .map_or((batch_salt, None), |entry| {
671 (entry.salt, Some(entry.file_id))
672 });
673
674 let derived_key = match get_or_derive_key(&key_cache, key.as_bytes(), &salt) {
676 Ok(k) => k,
677 Err(e) => {
678 failed.fetch_add(1, Ordering::Relaxed);
679 errors.lock().push(e);
680 pb.inc(1);
681 return;
682 }
683 };
684
685 let r = encrypt_file(
686 f,
687 &derived_key,
688 &salt,
689 cached_file_id,
690 repo.conf.use_zstd.then_some(repo.conf.zstd_level),
691 )
692 .map_err(|e| Error::Other(format!("Failed to encrypt {}: {e}", f.display())));
693
694 match r {
695 Ok(Some(_)) => {}
696 Ok(None) => {
697 skipped.fetch_add(1, Ordering::Relaxed);
698 }
699 Err(e) => {
700 failed.fetch_add(1, Ordering::Relaxed);
701 errors.lock().push(e);
702 }
703 }
704
705 pb.inc(1);
706 });
707 errors.into_inner()
708 };
709
710 pb.finish_and_clear();
711
712 print_post_report(
713 "Encrypt",
714 target_files.len(),
715 skipped.load(Ordering::Relaxed),
716 failed.load(Ordering::Relaxed),
717 );
718
719 if let Some(first) = result.into_iter().next() {
720 return Err(first);
721 }
722
723 Ok(())
724}
725
726pub fn decrypt_repo(repo: &Repo, paths: &[PathBuf]) -> Result<()> {
733 let key = repo.get_key()?;
734 if key.is_empty() {
735 return Err(Error::EmptyKey);
736 }
737
738 let target_files = resolve_target_files(paths, &repo.conf.crypt_list, repo.path());
739 if target_files.is_empty() {
740 return Err(Error::NoFile("decrypt"));
741 }
742
743 print_pre_report("Decrypting", &target_files, repo.path());
745
746 let key_cache: KeyCache = DashMap::new();
747
748 let (sender, saver) = salt_cache::create_writer(repo.path());
750
751 let pb = Progress::new(target_files.len(), "Decrypt");
752 let skipped = AtomicUsize::new(0);
753 let failed = AtomicUsize::new(0);
754
755 let result = {
756 let errors: parking_lot::Mutex<Vec<Error>> = parking_lot::Mutex::new(Vec::new());
757 target_files.par_iter().for_each(|f| {
758 match is_file_encrypted(f) {
759 Ok(true) => {}
760 Ok(false) => {
761 skipped.fetch_add(1, Ordering::Relaxed);
762 pb.inc(1);
763 return;
764 }
765 Err(e) => {
766 failed.fetch_add(1, Ordering::Relaxed);
767 errors.lock().push(e);
768 pb.inc(1);
769 return;
770 }
771 }
772
773 let relative_key = cache_key(f, repo.path());
774
775 let r = decrypt_file_with_cache(
776 f,
777 &key_cache,
778 Some(CacheRef {
779 sender: &sender,
780 key: &relative_key,
781 }),
782 key.as_bytes(),
783 )
784 .map_err(|e| Error::Other(format!("Failed to decrypt {}: {e}", f.display())));
785
786 if let Err(e) = r {
787 failed.fetch_add(1, Ordering::Relaxed);
788 errors.lock().push(e);
789 }
790
791 pb.inc(1);
792 });
793 errors.into_inner()
794 };
795
796 drop(sender);
798 saver.save();
799
800 pb.finish_and_clear();
801
802 print_post_report(
803 "Decrypt",
804 target_files.len(),
805 skipped.load(Ordering::Relaxed),
806 failed.load(Ordering::Relaxed),
807 );
808
809 if let Some(first) = result.into_iter().next() {
810 return Err(first);
811 }
812
813 Ok(())
814}
815
816#[cfg(test)]
817mod tests {
818 use std::io::{Read, Write};
819
820 use tempfile::{NamedTempFile, TempPath};
821
822 use super::*;
823
824 fn get_test_key_and_salt() -> ([u8; 32], [u8; SALT_LEN]) {
827 let password = b"super_secret_password";
828 let mut salt = [0u8; SALT_LEN];
829 rand::rng().fill_bytes(&mut salt);
830 let derived = derive_key(password, &salt).unwrap();
831 let mut key = [0u8; 32];
832 key.copy_from_slice(&*derived);
833 (key, salt)
834 }
835
836 fn create_temp_file(content: &[u8]) -> TempPath {
837 let mut file = NamedTempFile::new().unwrap();
838 file.write_all(content).unwrap();
839 file.flush().unwrap();
840 file.into_temp_path()
841 }
842
843 #[test]
846 fn test_header_serialization() {
847 let salt = [0xAB; SALT_LEN];
848 let file_id = FileHeader::generate_file_id();
849 let header = FileHeader::new(true, salt, file_id);
850
851 let mut buf = Vec::new();
853 header.write_to(&mut buf).unwrap();
854 assert_eq!(buf.len(), HEADER_LEN);
855
856 let raw: &[u8; HEADER_LEN] = buf.as_slice().try_into().unwrap();
858 let decoded = FileHeader::from_bytes(raw).unwrap();
859
860 assert_eq!(decoded.magic, *MAGIC);
861 assert_eq!(decoded.version, VERSION);
862 assert_eq!(decoded.flags, FLAG_COMPRESSED);
863 assert_eq!(decoded.enc_algo, ENC_ALGO);
864 assert_eq!(decoded.salt, salt);
865 assert_eq!(decoded.file_id, header.file_id);
866 assert_eq!(decoded.reserved, [0u8; RESERVED_LEN]);
867 assert!(decoded.is_compressed());
868 }
869
870 #[test]
871 fn test_nonce_derivation_deterministic() {
872 let key_mac = [0x42u8; 32];
873 let file_id = [0x99u8; FILE_ID_LEN];
874 let plaintext = b"hello world";
875
876 let nonce0_a = derive_nonce(&key_mac, &file_id, plaintext, 0);
878 let nonce0_b = derive_nonce(&key_mac, &file_id, plaintext, 0);
879 assert_eq!(nonce0_a, nonce0_b);
880
881 let nonce1 = derive_nonce(&key_mac, &file_id, plaintext, 1);
883 assert_ne!(nonce0_a, nonce1);
884
885 let other_plaintext = b"hello world!";
887 let nonce_other = derive_nonce(&key_mac, &file_id, other_plaintext, 0);
888 assert_ne!(nonce0_a, nonce_other);
889
890 let key_mac2 = [0x43u8; 32];
892 let nonce_key2 = derive_nonce(&key_mac2, &file_id, plaintext, 0);
893 assert_ne!(nonce0_a, nonce_key2);
894
895 let file_id2 = [0xAAu8; FILE_ID_LEN];
897 let nonce_file2 = derive_nonce(&key_mac, &file_id2, plaintext, 0);
898 assert_ne!(nonce0_a, nonce_file2);
899
900 let nonce_empty = derive_nonce(&key_mac, &file_id, b"", 0);
902 assert_ne!(nonce_empty, [0u8; NONCE_LEN]);
903 }
904
905 #[test]
906 fn test_encrypt_decrypt_basic_no_compression() {
907 let plaintext = b"Hello, World! This is a test without compression.";
908 let path = create_temp_file(plaintext);
909
910 let (key, salt) = get_test_key_and_salt();
911 let master_key = b"super_secret_password";
912
913 encrypt_file(&path, &key, &salt, None, None).unwrap();
915
916 let mut encrypted_content = Vec::new();
918 fs::File::open(&path)
919 .unwrap()
920 .read_to_end(&mut encrypted_content)
921 .unwrap();
922 assert_ne!(encrypted_content, plaintext);
923 assert_eq!(&encrypted_content[0..5], MAGIC);
924 assert_eq!(encrypted_content[5], VERSION);
925
926 decrypt_file(&path, master_key).unwrap();
928
929 let mut decrypted_content = Vec::new();
931 fs::File::open(path)
932 .unwrap()
933 .read_to_end(&mut decrypted_content)
934 .unwrap();
935 assert_eq!(decrypted_content, plaintext);
936 }
937
938 #[test]
939 fn test_encrypt_decrypt_with_compression() {
940 let plaintext = b"A".repeat(10000);
942 let path = create_temp_file(&plaintext);
943
944 let (key, salt) = get_test_key_and_salt();
945 let master_key = b"super_secret_password";
946
947 encrypt_file(&path, &key, &salt, None, Some(3)).unwrap();
949
950 let encrypted_meta = fs::metadata(&path).unwrap();
953 assert!(encrypted_meta.len() < 5000);
954
955 decrypt_file(&path, master_key).unwrap();
957
958 let mut decrypted_content = Vec::new();
960 fs::File::open(path)
961 .unwrap()
962 .read_to_end(&mut decrypted_content)
963 .unwrap();
964 assert_eq!(decrypted_content, plaintext);
965 }
966
967 #[test]
968 #[allow(clippy::cast_possible_truncation)]
969 #[allow(clippy::cast_sign_loss)]
970 fn test_chunked_encryption_large_file() {
971 let plaintext = {
973 let mut data = Vec::with_capacity(100_000);
974 for i in 0..100_000 {
975 data.push((i % 256) as u8);
976 }
977 data
978 };
979
980 let path = create_temp_file(&plaintext);
981
982 let (key, salt) = get_test_key_and_salt();
983 let master_key = b"super_secret_password";
984
985 encrypt_file(&path, &key, &salt, None, None).unwrap();
987
988 decrypt_file(&path, master_key).unwrap();
990
991 let mut decrypted_content = Vec::new();
993 fs::File::open(path)
994 .unwrap()
995 .read_to_end(&mut decrypted_content)
996 .unwrap();
997 assert_eq!(decrypted_content, plaintext);
998 }
999
1000 #[test]
1001 fn test_tamper_resistance() {
1002 let plaintext = b"Sensitive data that should not be tampered with.";
1003 let path = create_temp_file(plaintext);
1004
1005 let (key, salt) = get_test_key_and_salt();
1006 let master_key = b"super_secret_password";
1007
1008 encrypt_file(&path, &key, &salt, None, None).unwrap();
1010
1011 let mut encrypted_content = Vec::new();
1013 let mut f = fs::OpenOptions::new()
1014 .read(true)
1015 .write(true)
1016 .open(&path)
1017 .unwrap();
1018 f.read_to_end(&mut encrypted_content).unwrap();
1019
1020 encrypted_content[HEADER_LEN + 5] ^= 0xFF;
1022
1023 f.seek(std::io::SeekFrom::Start(0)).unwrap();
1024 f.write_all(&encrypted_content).unwrap();
1025 drop(f);
1026
1027 let result = decrypt_file(&path, master_key);
1029
1030 assert!(result.is_err());
1031 assert!(
1032 result
1033 .unwrap_err()
1034 .to_string()
1035 .to_lowercase()
1036 .contains("decryption failed")
1037 );
1038 }
1039
1040 #[test]
1041 fn test_header_tamper_detected() {
1042 let plaintext = b"Test data with header integrity check.";
1043 let path = create_temp_file(plaintext);
1044
1045 let (key, salt) = get_test_key_and_salt();
1046 let master_key = b"super_secret_password";
1047
1048 encrypt_file(&path, &key, &salt, None, None).unwrap();
1050
1051 let mut encrypted_content = Vec::new();
1053 let mut f = fs::OpenOptions::new()
1054 .read(true)
1055 .write(true)
1056 .open(&path)
1057 .unwrap();
1058 f.read_to_end(&mut encrypted_content).unwrap();
1059
1060 encrypted_content[6] ^= FLAG_COMPRESSED;
1061
1062 f.seek(std::io::SeekFrom::Start(0)).unwrap();
1063 f.write_all(&encrypted_content).unwrap();
1064 drop(f);
1065
1066 let result = decrypt_file(&path, master_key);
1069 assert!(result.is_err());
1070 assert!(
1071 result
1072 .unwrap_err()
1073 .to_string()
1074 .to_lowercase()
1075 .contains("decryption failed")
1076 );
1077 }
1078
1079 #[test]
1080 fn test_deterministic_encrypt_with_fixed_salt_file_id() {
1081 let plaintext = b"Deterministic encryption test data.";
1082
1083 let password = b"test_password";
1084 let salt = [0x42; SALT_LEN];
1085 let file_id = [0x13; FILE_ID_LEN];
1086 let derived = derive_key(password, &salt).unwrap();
1087 let mut key = [0u8; 32];
1088 key.copy_from_slice(&*derived);
1089
1090 let path1 = create_temp_file(plaintext);
1092 let path2 = create_temp_file(plaintext);
1093
1094 encrypt_file(&path1, &key, &salt, Some(file_id), None).unwrap();
1095 encrypt_file(&path2, &key, &salt, Some(file_id), None).unwrap();
1096
1097 let ct1 = fs::read(&path1).unwrap();
1098 let ct2 = fs::read(&path2).unwrap();
1099 assert_eq!(
1100 ct1, ct2,
1101 "Same plaintext + same salt+file_id must produce identical ciphertext"
1102 );
1103
1104 decrypt_file(&path1, password).unwrap();
1106 assert_eq!(fs::read(&path1).unwrap(), plaintext);
1107 }
1108
1109 #[test]
1110 fn test_deterministic_encrypt_multi_chunk() {
1111 #[allow(clippy::cast_possible_truncation)]
1113 let plaintext = {
1114 let mut data = Vec::with_capacity(CHUNK_SIZE * 2 + 1000);
1115 for i in 0..(CHUNK_SIZE * 2 + 1000) {
1116 data.push(i as u8);
1117 }
1118 data
1119 };
1120
1121 let password = b"test_password";
1122 let salt = [0x42; SALT_LEN];
1123 let file_id = [0x13; FILE_ID_LEN];
1124 let derived = derive_key(password, &salt).unwrap();
1125 let mut key = [0u8; 32];
1126 key.copy_from_slice(&*derived);
1127
1128 let path1 = create_temp_file(&plaintext);
1129 let path2 = create_temp_file(&plaintext);
1130
1131 encrypt_file(&path1, &key, &salt, Some(file_id), None).unwrap();
1132 encrypt_file(&path2, &key, &salt, Some(file_id), None).unwrap();
1133
1134 let ct1 = fs::read(&path1).unwrap();
1135 let ct2 = fs::read(&path2).unwrap();
1136 assert_eq!(
1137 ct1, ct2,
1138 "Same multi-chunk plaintext + same salt+file_id must produce identical ciphertext"
1139 );
1140
1141 decrypt_file(&path1, password).unwrap();
1143 assert_eq!(fs::read(&path1).unwrap(), plaintext);
1144 }
1145
1146 #[test]
1147 fn test_different_file_id_produces_different_ciphertext() {
1148 let plaintext = b"Same content, different file.";
1151
1152 let password = b"test_password";
1153 let salt = [0x42; SALT_LEN];
1154 let derived = derive_key(password, &salt).unwrap();
1155 let mut key = [0u8; 32];
1156 key.copy_from_slice(&*derived);
1157
1158 let path1 = create_temp_file(plaintext);
1159 let path2 = create_temp_file(plaintext);
1160
1161 let file_id1 = [0x01; FILE_ID_LEN];
1162 let file_id2 = [0x02; FILE_ID_LEN];
1163
1164 encrypt_file(&path1, &key, &salt, Some(file_id1), None).unwrap();
1165 encrypt_file(&path2, &key, &salt, Some(file_id2), None).unwrap();
1166
1167 let ct1 = fs::read(&path1).unwrap();
1168 let ct2 = fs::read(&path2).unwrap();
1169 assert_ne!(
1170 ct1, ct2,
1171 "Same plaintext with different File_IDs must produce different ciphertext"
1172 );
1173
1174 decrypt_file(&path1, password).unwrap();
1176 assert_eq!(fs::read(&path1).unwrap(), plaintext);
1177 decrypt_file(&path2, password).unwrap();
1178 assert_eq!(fs::read(&path2).unwrap(), plaintext);
1179 }
1180
1181 #[cfg(unix)]
1182 #[test]
1183 fn test_metadata_preservation() {
1184 use std::os::unix::fs::PermissionsExt;
1185
1186 let plaintext = b"Executable script content";
1187 let file = create_temp_file(plaintext);
1188 let path = file.path();
1189
1190 let mut perms = fs::metadata(path).unwrap().permissions();
1192 perms.set_mode(0o755);
1193 fs::set_permissions(path, perms).unwrap();
1194
1195 let (key, salt) = get_test_key_and_salt();
1196 let master_key = b"super_secret_password";
1197
1198 encrypt_file(path, &key, &salt, None, None).unwrap();
1200
1201 let encrypted_perms = fs::metadata(path).unwrap().permissions();
1203 assert_eq!(encrypted_perms.mode() & 0o777, 0o755);
1204
1205 let key_cache: KeyCache = DashMap::new();
1207 decrypt_file_with_cache(path, &key_cache, None, master_key).unwrap();
1208
1209 let decrypted_perms = fs::metadata(path).unwrap().permissions();
1211 assert_eq!(decrypted_perms.mode() & 0o777, 0o755);
1212 }
1213
1214 #[test]
1215 fn test_empty_file_roundtrip() {
1216 let plaintext = b"";
1219 let path = create_temp_file(plaintext);
1220
1221 let (key, salt) = get_test_key_and_salt();
1222 let master_key = b"super_secret_password";
1223
1224 encrypt_file(&path, &key, &salt, None, None).unwrap();
1225
1226 let enc = fs::read(&path).unwrap();
1228 assert_eq!(enc.len(), HEADER_LEN + NONCE_LEN + 16);
1229
1230 decrypt_file(&path, master_key).unwrap();
1231 assert_eq!(fs::read(&path).unwrap(), plaintext);
1232 }
1233
1234 #[test]
1235 fn test_wrong_password_decrypt_fails() {
1236 let plaintext = b"data encrypted under one password";
1237 let path = create_temp_file(plaintext);
1238
1239 let (key, salt) = get_test_key_and_salt();
1240 encrypt_file(&path, &key, &salt, None, None).unwrap();
1241
1242 let result = decrypt_file(&path, b"a_completely_different_password");
1244 assert!(matches!(result, Err(Error::DecryptFailed(_))));
1245
1246 let bytes = fs::read(&path).unwrap();
1248 assert_eq!(&bytes[..MAGIC.len()], MAGIC);
1249 }
1250
1251 #[test]
1252 fn test_truncated_ciphertext_after_nonce() {
1253 let plaintext = b"abc";
1255 let path = create_temp_file(plaintext);
1256 let (key, salt) = get_test_key_and_salt();
1257 encrypt_file(&path, &key, &salt, None, None).unwrap();
1258
1259 let trunc_len = HEADER_LEN + NONCE_LEN;
1261 let f = fs::OpenOptions::new().write(true).open(&path).unwrap();
1262 f.set_len(trunc_len as u64).unwrap();
1263 drop(f);
1264
1265 let result = decrypt_file(&path, b"super_secret_password");
1266 assert!(matches!(result, Err(Error::TruncatedChunk)));
1267 }
1268
1269 #[test]
1270 fn test_truncated_before_first_nonce() {
1271 let path = create_temp_file(b"tiny");
1274 let key_cache: KeyCache = DashMap::new();
1275 let res = decrypt_file_with_cache(&path, &key_cache, None, b"any");
1276 assert!(res.is_ok());
1277 assert_eq!(fs::read(&path).unwrap(), b"tiny");
1279 }
1280}