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 anyhow::{Context, Result, anyhow, ensure};
62use argon2::Argon2;
63use chacha20poly1305::{
64 XChaCha20Poly1305, XNonce,
65 aead::{Aead, KeyInit, Payload},
66};
67use dashmap::DashMap;
68use log::{debug, warn};
69use path_absolutize::Absolutize as _;
70use pathdiff::diff_paths;
71use rand::prelude::*;
72use rayon::prelude::*;
73use tempfile::NamedTempFile;
74use zeroize::Zeroizing;
75
76use crate::{
77 Repo,
78 salt_cache::{self, CachedEntry, SaltCacheSender},
79 utils::{
80 create_progress_bar, is_file_encrypted, print_post_report, print_pre_report,
81 resolve_target_files,
82 },
83};
84
85pub const MAGIC: &[u8; 5] = b"GITSE";
88pub const VERSION: u8 = 3;
90const FLAG_COMPRESSED: u8 = 1 << 0; const ENC_ALGO: u8 = 1; pub const SALT_LEN: usize = 16;
95pub const FILE_ID_LEN: usize = 16;
96pub const NONCE_LEN: usize = 24; pub const HEADER_LEN: usize = 64;
98const RESERVED_LEN: usize = HEADER_LEN - (MAGIC.len() + 1 + 1 + 1 + SALT_LEN + FILE_ID_LEN); const CHUNK_SIZE: usize = 65536; #[inline]
105#[must_use]
106pub const fn is_encrypted_version(v: u8) -> bool {
107 v == VERSION
108}
109
110#[repr(C)]
123#[derive(Clone, Copy, Debug, PartialEq, Eq)]
124pub struct FileHeader {
125 magic: [u8; 5],
126 version: u8,
127 flags: u8,
128 enc_algo: u8,
129 salt: [u8; SALT_LEN],
130 file_id: [u8; FILE_ID_LEN],
131 reserved: [u8; RESERVED_LEN],
132}
133
134const _: () = assert!(std::mem::size_of::<FileHeader>() == HEADER_LEN);
136const _: () = assert!(std::mem::align_of::<FileHeader>() == 1);
137
138impl FileHeader {
139 #[must_use]
140 pub fn new(compressed: bool, salt: [u8; SALT_LEN], file_id: Option<[u8; FILE_ID_LEN]>) -> Self {
141 let file_id = file_id.unwrap_or_else(|| {
142 let mut rng = rand::rng();
143 let mut id = [0u8; FILE_ID_LEN];
144 rng.fill_bytes(&mut id);
145 id
146 });
147
148 let mut flags = 0u8;
149 if compressed {
150 flags |= FLAG_COMPRESSED;
151 }
152
153 Self {
154 magic: *MAGIC,
155 version: VERSION,
156 flags,
157 enc_algo: ENC_ALGO,
158 salt,
159 file_id,
160 reserved: [0u8; RESERVED_LEN],
161 }
162 }
163
164 pub fn from_bytes(bytes: &[u8; HEADER_LEN]) -> Result<&Self> {
168 let header: &Self = unsafe { &*(bytes.as_ptr().cast()) };
172
173 if &header.magic != MAGIC {
174 return Err(anyhow!("Invalid magic bytes"));
175 }
176 if !is_encrypted_version(header.version) {
177 return Err(anyhow!("Unsupported version: {}", header.version));
178 }
179 if header.enc_algo != ENC_ALGO {
180 return Err(anyhow!(
181 "Unsupported encryption algorithm: {}",
182 header.enc_algo
183 ));
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
196 .read_exact(&mut buf)
197 .context("Failed to read header")?;
198 Ok(*Self::from_bytes(&buf)?)
199 }
200
201 pub fn write_to<W: Write>(&self, writer: &mut W) -> Result<()> {
203 writer.write_all(self.as_bytes())?;
204 Ok(())
205 }
206
207 #[must_use]
211 #[inline]
212 pub const fn as_bytes(&self) -> &[u8; HEADER_LEN] {
213 unsafe { &*std::ptr::from_ref::<Self>(self).cast() }
216 }
217
218 #[must_use]
219 pub const fn is_compressed(&self) -> bool {
220 (self.flags & FLAG_COMPRESSED) != 0
221 }
222}
223
224fn derive_key(password: &[u8], salt: &[u8]) -> Result<Zeroizing<[u8; 32]>> {
230 let mut key = Zeroizing::new([0u8; 32]);
231 Argon2::default()
232 .hash_password_into(password, salt, &mut *key)
233 .map_err(|e| anyhow!("Argon2 key derivation failed: {e}"))?;
234 Ok(key)
235}
236
237fn split_keys(master_key: &[u8; 32]) -> (Zeroizing<[u8; 32]>, Zeroizing<[u8; 32]>) {
240 let key_enc = blake3::derive_key("git-simple-encrypt-enc", master_key);
241 let key_mac = blake3::derive_key("git-simple-encrypt-mac", master_key);
242 (Zeroizing::new(key_enc), Zeroizing::new(key_mac))
243}
244
245fn derive_nonce(
255 key_mac: &[u8; 32],
256 file_id: &[u8; FILE_ID_LEN],
257 plaintext: &[u8],
258 chunk_idx: u64,
259) -> [u8; NONCE_LEN] {
260 let mut hasher = blake3::Hasher::new_keyed(key_mac);
261 hasher.update(file_id);
262 hasher.update(plaintext);
263 hasher.update(&chunk_idx.to_le_bytes());
264 let hash = hasher.finalize();
265 let mut nonce = [0u8; NONCE_LEN];
266 nonce.copy_from_slice(&hash.as_bytes()[..NONCE_LEN]);
267 nonce
268}
269
270fn atomic_write_with_metadata(original_path: &Path, temp_file: NamedTempFile) -> Result<()> {
273 if let Err(e) = copy_metadata::copy_metadata(original_path, temp_file.path()) {
276 warn!(
277 "Could not copy metadata for {}: {}",
278 original_path.display(),
279 e
280 );
281 }
282 temp_file.persist(original_path).with_context(|| {
283 format!(
284 "Failed to persist atomic write to {}",
285 original_path.display()
286 )
287 })?;
288 Ok(())
289}
290
291fn cache_key(file_path: &Path, repo_path: &Path) -> Vec<u8> {
298 let abs_path = if file_path.is_absolute() {
299 file_path.into()
300 } else {
301 file_path
302 .absolutize_from(repo_path)
303 .unwrap_or_else(|_| file_path.into())
304 };
305 let relative =
306 diff_paths(abs_path.as_ref(), repo_path).unwrap_or_else(|| abs_path.to_path_buf());
307 let mut bytes = relative.into_os_string().into_encoded_bytes();
308 for b in &mut bytes {
309 if *b == b'\\' {
310 *b = b'/';
311 }
312 }
313 bytes
314}
315
316type KeyCache = DashMap<[u8; SALT_LEN], Arc<OnceLock<Result<Zeroizing<[u8; 32]>, String>>>>;
329
330fn get_or_derive_key(
337 key_cache: &KeyCache,
338 master_key: &[u8],
339 salt: &[u8; SALT_LEN],
340) -> Result<Zeroizing<[u8; 32]>> {
341 let lock = {
346 let guard = key_cache
347 .entry(*salt)
348 .or_insert_with(|| Arc::new(OnceLock::new()));
349 Arc::clone(&*guard)
350 };
351
352 match lock.get_or_init(|| derive_key(master_key, salt).map_err(|e| e.to_string())) {
355 Ok(key) => Ok(key.clone()),
356 Err(msg) => Err(anyhow!("{msg}")),
357 }
358}
359
360pub fn encrypt_file(
367 path: &Path,
368 derived_key: &[u8; 32],
369 salt: &[u8; SALT_LEN],
370 file_id: Option<[u8; FILE_ID_LEN]>,
371 zstd: Option<u8>,
372) -> Result<Option<FileHeader>> {
373 let mut file = fs::File::open(path)?;
374
375 let mut header_bytes = [0u8; HEADER_LEN];
377 if file.read_exact(&mut header_bytes).is_ok()
378 && &header_bytes[0..5] == MAGIC
379 && is_encrypted_version(header_bytes[5])
380 {
381 warn!("File already encrypted, skipping: {}", path.display());
382 return Ok(None);
383 }
384 file.seek(SeekFrom::Start(0))?; debug!("Encrypting: {}", path.display());
387
388 let file_id = file_id.unwrap_or_else(|| {
390 let mut rng = rand::rng();
391 let mut id = [0u8; FILE_ID_LEN];
392 rng.fill_bytes(&mut id);
393 id
394 });
395 let header = FileHeader::new(zstd.is_some(), *salt, Some(file_id));
396 let parent_dir = path.parent().unwrap_or_else(|| Path::new("."));
397 let mut temp_file = NamedTempFile::new_in(parent_dir)
398 .with_context(|| "Failed to create temp file".to_string())?;
399
400 header.write_to(&mut temp_file)?;
401
402 let (key_enc, key_mac) = split_keys(derived_key);
404 let cipher = XChaCha20Poly1305::new(key_enc.as_ref().into());
405
406 let mut reader: Box<dyn Read> = if let Some(zstd_level) = zstd {
408 Box::new(zstd::stream::read::Encoder::new(
409 file,
410 i32::from(zstd_level),
411 )?)
412 } else {
413 Box::new(file)
414 };
415
416 let mut buffer = Zeroizing::new(vec![0u8; CHUNK_SIZE]);
418 let mut chunk_idx = 0u64;
419
420 loop {
421 let mut bytes_read = 0;
422 while bytes_read < CHUNK_SIZE {
423 let n = reader.read(&mut buffer[bytes_read..])?;
424 if n == 0 {
425 break;
426 }
427 bytes_read += n;
428 }
429
430 let is_last_chunk = bytes_read < CHUNK_SIZE;
431 let mut aad = [0u8; HEADER_LEN + 9];
435 aad[..HEADER_LEN].copy_from_slice(header.as_bytes());
436 aad[HEADER_LEN..HEADER_LEN + 8].copy_from_slice(&chunk_idx.to_le_bytes());
437 aad[HEADER_LEN + 8] = u8::from(is_last_chunk);
438
439 let nonce_bytes = derive_nonce(&key_mac, &file_id, &buffer[..bytes_read], chunk_idx);
441 let nonce = XNonce::from(nonce_bytes);
442
443 let payload = Payload {
444 msg: &buffer[..bytes_read],
445 aad: &aad,
446 };
447
448 let ciphertext = cipher
449 .encrypt(&nonce, payload)
450 .map_err(|e| anyhow!("Encryption failed: {e}"))?;
451
452 temp_file.write_all(&nonce_bytes)?;
454 temp_file.write_all(&ciphertext)?;
455
456 chunk_idx += 1;
457
458 if is_last_chunk {
459 break;
460 }
461 }
462
463 drop(reader);
465 atomic_write_with_metadata(path, temp_file)?;
466
467 Ok(Some(header))
468}
469
470pub fn decrypt_file(path: &Path, master_key: &[u8]) -> Result<()> {
472 let key_cache: KeyCache = DashMap::new();
473 decrypt_file_with_cache(path, &key_cache, None, master_key)
474}
475
476pub fn decrypt_file_with_cache(
483 path: &Path,
484 key_cache: &KeyCache,
485 cache: Option<(&SaltCacheSender, &[u8])>,
486 master_key: &[u8],
487) -> Result<()> {
488 let mut file = fs::File::open(path)?;
489
490 let mut header_bytes = [0u8; HEADER_LEN];
492 if file.read_exact(&mut header_bytes).is_err() {
493 debug!(
494 "File too small to be encrypted, skipping: {}",
495 path.display()
496 );
497 return Ok(());
498 }
499 if &header_bytes[0..5] != MAGIC || !is_encrypted_version(header_bytes[5]) {
500 debug!(
501 "File not encrypted (no magic), skipping: {}",
502 path.display()
503 );
504 return Ok(());
505 }
506
507 debug!("Decrypting: {}", path.display());
508 let header = FileHeader::from_bytes(&header_bytes)
509 .with_context(|| format!("Corrupt header in {}", path.display()))?;
510
511 if let Some((sender, key)) = cache {
514 sender.insert(
515 key,
516 CachedEntry {
517 salt: header.salt,
518 file_id: header.file_id,
519 },
520 );
521 }
522
523 let derived_key = get_or_derive_key(key_cache, master_key, &header.salt)?;
525
526 let (key_enc, _key_mac) = split_keys(&derived_key);
528 let cipher = XChaCha20Poly1305::new(key_enc.as_ref().into());
529 let parent_dir = path.parent().unwrap_or_else(|| Path::new("."));
530 let mut temp_file = NamedTempFile::new_in(parent_dir)
531 .with_context(|| "Failed to create temp file".to_string())?;
532
533 if header.is_compressed() {
535 let mut decoder = zstd::stream::write::Decoder::new(&mut temp_file)?.auto_flush();
536 decrypt_chunks(&mut file, &mut decoder, &cipher, header.as_bytes())?;
537 decoder.flush()?;
538 } else {
539 decrypt_chunks(&mut file, &mut temp_file, &cipher, header.as_bytes())?;
540 }
541 drop(file);
542
543 atomic_write_with_metadata(path, temp_file)?;
545
546 Ok(())
547}
548
549fn decrypt_chunks(
558 file: &mut fs::File,
559 writer: &mut dyn Write,
560 cipher: &XChaCha20Poly1305,
561 header_bytes: &[u8; HEADER_LEN],
562) -> Result<()> {
563 let mut nonce_buf = [0u8; NONCE_LEN];
564 let mut ct_buffer = vec![0u8; CHUNK_SIZE + 16]; let mut last_chunk_was_final = false;
566 let mut chunk_idx = 0u64;
567
568 loop {
569 match file.read_exact(&mut nonce_buf) {
571 Ok(()) => {}
572 Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
573 Err(e) => return Err(e.into()),
574 }
575
576 let mut bytes_read = 0;
578 while bytes_read < ct_buffer.len() {
579 let n = file.read(&mut ct_buffer[bytes_read..])?;
580 if n == 0 {
581 break;
582 }
583 bytes_read += n;
584 }
585
586 if bytes_read == 0 {
587 return Err(anyhow!(
588 "Truncated chunk: nonce present but no ciphertext follows"
589 ));
590 }
591
592 let is_last_chunk = bytes_read < ct_buffer.len();
593
594 let mut aad = [0u8; HEADER_LEN + 9];
597 aad[..HEADER_LEN].copy_from_slice(header_bytes);
598 aad[HEADER_LEN..HEADER_LEN + 8].copy_from_slice(&chunk_idx.to_le_bytes());
599 aad[HEADER_LEN + 8] = u8::from(is_last_chunk);
600
601 let nonce = XNonce::from(nonce_buf);
602 let payload = chacha20poly1305::aead::Payload {
603 msg: &ct_buffer[..bytes_read],
604 aad: &aad,
605 };
606
607 let plaintext = Zeroizing::new(cipher.decrypt(&nonce, payload).map_err(|e| {
608 anyhow!("Decryption failed (wrong password, corrupt, or tampered data): {e}")
609 })?);
610
611 writer.write_all(&plaintext)?;
612
613 chunk_idx += 1;
614
615 if is_last_chunk {
616 last_chunk_was_final = true;
617 break;
618 }
619 }
620
621 if !last_chunk_was_final {
622 return Err(anyhow!(
623 "File truncation detected! The ciphertext is incomplete."
624 ));
625 }
626
627 Ok(())
628}
629
630pub fn encrypt_repo(repo: &'static Repo, paths: &[PathBuf]) -> Result<()> {
645 let key = repo.get_key();
646 ensure!(!key.is_empty(), "Key must not be empty");
647
648 let target_files = resolve_target_files(paths, &repo.conf.crypt_list, repo.path());
649 ensure!(!target_files.is_empty(), "No file to encrypt");
650
651 print_pre_report("Encrypting", &target_files, repo.path());
653
654 let reader = salt_cache::SaltCacheReader::load(repo.path());
656
657 let key_cache: KeyCache = DashMap::new();
658
659 let mut batch_salt = [0u8; SALT_LEN];
661 rand::rng().fill_bytes(&mut batch_salt);
662
663 let pb = create_progress_bar(target_files.len(), "Encrypt");
664 let skipped = AtomicUsize::new(0);
665 let failed = AtomicUsize::new(0);
666
667 let result = target_files.par_iter().try_for_each(|f| -> Result<()> {
668 let relative_key = cache_key(f, repo.path());
669 let (salt, cached_file_id) = reader
670 .get(&relative_key)
671 .map_or((batch_salt, None), |entry| {
672 (entry.salt, Some(entry.file_id))
673 });
674
675 let derived_key = get_or_derive_key(&key_cache, key.as_bytes(), &salt)?;
677
678 let header = encrypt_file(
679 f,
680 &derived_key,
681 &salt,
682 cached_file_id,
683 repo.conf.use_zstd.then_some(repo.conf.zstd_level),
684 )
685 .with_context(|| format!("Failed to encrypt {}", f.display()))?;
686
687 if header.is_none() {
688 skipped.fetch_add(1, Ordering::Relaxed);
689 }
690
691 pb.inc(1);
692 Ok(())
693 });
694
695 pb.finish_and_clear();
696
697 print_post_report(
698 "Encrypt",
699 target_files.len(),
700 skipped.load(Ordering::Relaxed),
701 failed.load(Ordering::Relaxed),
702 );
703
704 result?;
705
706 Ok(())
707}
708
709pub fn decrypt_repo(repo: &'static Repo, paths: &[PathBuf]) -> Result<()> {
716 let key = repo.get_key();
717 ensure!(!key.is_empty(), "Master key must not be empty");
718
719 let target_files = resolve_target_files(paths, &repo.conf.crypt_list, repo.path());
720 ensure!(!target_files.is_empty(), "No file to decrypt");
721
722 print_pre_report("Decrypting", &target_files, repo.path());
724
725 let key_cache: KeyCache = DashMap::new();
726
727 let (sender, saver) = salt_cache::create_writer(repo.path());
729
730 let pb = create_progress_bar(target_files.len(), "Decrypt");
731 let skipped = AtomicUsize::new(0);
732 let failed = AtomicUsize::new(0);
733
734 let result = target_files.par_iter().try_for_each(|f| -> Result<()> {
735 if !is_file_encrypted(f)? {
736 skipped.fetch_add(1, Ordering::Relaxed);
737 pb.inc(1);
738 return Ok(());
739 }
740
741 let relative_key = cache_key(f, repo.path());
742
743 let decrypt_result = decrypt_file_with_cache(
744 f,
745 &key_cache,
746 Some((&sender, &relative_key)),
747 key.as_bytes(),
748 )
749 .with_context(|| format!("Failed to decrypt {}", f.display()));
750
751 if decrypt_result.is_err() {
752 failed.fetch_add(1, Ordering::Relaxed);
753 }
754
755 pb.inc(1);
756 decrypt_result
757 });
758
759 drop(sender);
761 saver.save();
762
763 pb.finish_and_clear();
764
765 print_post_report(
766 "Decrypt",
767 target_files.len(),
768 skipped.load(Ordering::Relaxed),
769 failed.load(Ordering::Relaxed),
770 );
771
772 result?;
773
774 Ok(())
775}
776
777#[cfg(test)]
778mod tests {
779 use std::io::{Read, Write};
780
781 use tempfile::{NamedTempFile, TempPath};
782
783 use super::*;
784
785 fn get_test_key_and_salt() -> ([u8; 32], [u8; SALT_LEN]) {
788 let password = b"super_secret_password";
789 let mut salt = [0u8; SALT_LEN];
790 rand::rng().fill_bytes(&mut salt);
791 let derived = derive_key(password, &salt).unwrap();
792 let mut key = [0u8; 32];
793 key.copy_from_slice(&*derived);
794 (key, salt)
795 }
796
797 fn create_temp_file(content: &[u8]) -> TempPath {
798 let mut file = NamedTempFile::new().unwrap();
799 file.write_all(content).unwrap();
800 file.flush().unwrap();
801 file.into_temp_path()
802 }
803
804 #[test]
807 fn test_header_serialization() {
808 let salt = [0xAB; SALT_LEN];
809 let header = FileHeader::new(true, salt, None);
810
811 let mut buf = Vec::new();
813 header.write_to(&mut buf).unwrap();
814 assert_eq!(buf.len(), HEADER_LEN);
815
816 let raw: &[u8; HEADER_LEN] = buf.as_slice().try_into().unwrap();
818 let decoded = FileHeader::from_bytes(raw).unwrap();
819
820 assert_eq!(decoded.magic, *MAGIC);
821 assert_eq!(decoded.version, VERSION);
822 assert_eq!(decoded.flags, FLAG_COMPRESSED);
823 assert_eq!(decoded.enc_algo, ENC_ALGO);
824 assert_eq!(decoded.salt, salt);
825 assert_eq!(decoded.file_id, header.file_id);
826 assert_eq!(decoded.reserved, [0u8; RESERVED_LEN]);
827 assert!(decoded.is_compressed());
828 }
829
830 #[test]
831 fn test_nonce_derivation_deterministic() {
832 let key_mac = [0x42u8; 32];
833 let file_id = [0x99u8; FILE_ID_LEN];
834 let plaintext = b"hello world";
835
836 let nonce0_a = derive_nonce(&key_mac, &file_id, plaintext, 0);
838 let nonce0_b = derive_nonce(&key_mac, &file_id, plaintext, 0);
839 assert_eq!(nonce0_a, nonce0_b);
840
841 let nonce1 = derive_nonce(&key_mac, &file_id, plaintext, 1);
843 assert_ne!(nonce0_a, nonce1);
844
845 let other_plaintext = b"hello world!";
847 let nonce_other = derive_nonce(&key_mac, &file_id, other_plaintext, 0);
848 assert_ne!(nonce0_a, nonce_other);
849
850 let key_mac2 = [0x43u8; 32];
852 let nonce_key2 = derive_nonce(&key_mac2, &file_id, plaintext, 0);
853 assert_ne!(nonce0_a, nonce_key2);
854
855 let file_id2 = [0xAAu8; FILE_ID_LEN];
857 let nonce_file2 = derive_nonce(&key_mac, &file_id2, plaintext, 0);
858 assert_ne!(nonce0_a, nonce_file2);
859
860 let nonce_empty = derive_nonce(&key_mac, &file_id, b"", 0);
862 assert_ne!(nonce_empty, [0u8; NONCE_LEN]);
863 }
864
865 #[test]
866 fn test_encrypt_decrypt_basic_no_compression() {
867 let plaintext = b"Hello, World! This is a test without compression.";
868 let path = create_temp_file(plaintext);
869
870 let (key, salt) = get_test_key_and_salt();
871 let master_key = b"super_secret_password";
872
873 encrypt_file(&path, &key, &salt, None, None).unwrap();
875
876 let mut encrypted_content = Vec::new();
878 fs::File::open(&path)
879 .unwrap()
880 .read_to_end(&mut encrypted_content)
881 .unwrap();
882 assert_ne!(encrypted_content, plaintext);
883 assert_eq!(&encrypted_content[0..5], MAGIC);
884 assert_eq!(encrypted_content[5], VERSION);
885
886 decrypt_file(&path, master_key).unwrap();
888
889 let mut decrypted_content = Vec::new();
891 fs::File::open(path)
892 .unwrap()
893 .read_to_end(&mut decrypted_content)
894 .unwrap();
895 assert_eq!(decrypted_content, plaintext);
896 }
897
898 #[test]
899 fn test_encrypt_decrypt_with_compression() {
900 let plaintext = b"A".repeat(10000);
902 let path = create_temp_file(&plaintext);
903
904 let (key, salt) = get_test_key_and_salt();
905 let master_key = b"super_secret_password";
906
907 encrypt_file(&path, &key, &salt, None, Some(3)).unwrap();
909
910 let encrypted_meta = fs::metadata(&path).unwrap();
913 assert!(encrypted_meta.len() < 5000);
914
915 decrypt_file(&path, master_key).unwrap();
917
918 let mut decrypted_content = Vec::new();
920 fs::File::open(path)
921 .unwrap()
922 .read_to_end(&mut decrypted_content)
923 .unwrap();
924 assert_eq!(decrypted_content, plaintext);
925 }
926
927 #[test]
928 #[allow(clippy::cast_possible_truncation)]
929 #[allow(clippy::cast_sign_loss)]
930 fn test_chunked_encryption_large_file() {
931 let plaintext = {
933 let mut data = Vec::with_capacity(100_000);
934 for i in 0..100_000 {
935 data.push((i % 256) as u8);
936 }
937 data
938 };
939
940 let path = create_temp_file(&plaintext);
941
942 let (key, salt) = get_test_key_and_salt();
943 let master_key = b"super_secret_password";
944
945 encrypt_file(&path, &key, &salt, None, None).unwrap();
947
948 decrypt_file(&path, master_key).unwrap();
950
951 let mut decrypted_content = Vec::new();
953 fs::File::open(path)
954 .unwrap()
955 .read_to_end(&mut decrypted_content)
956 .unwrap();
957 assert_eq!(decrypted_content, plaintext);
958 }
959
960 #[test]
961 fn test_tamper_resistance() {
962 let plaintext = b"Sensitive data that should not be tampered with.";
963 let path = create_temp_file(plaintext);
964
965 let (key, salt) = get_test_key_and_salt();
966 let master_key = b"super_secret_password";
967
968 encrypt_file(&path, &key, &salt, None, None).unwrap();
970
971 let mut encrypted_content = Vec::new();
973 let mut f = fs::OpenOptions::new()
974 .read(true)
975 .write(true)
976 .open(&path)
977 .unwrap();
978 f.read_to_end(&mut encrypted_content).unwrap();
979
980 encrypted_content[HEADER_LEN + 5] ^= 0xFF;
982
983 f.seek(std::io::SeekFrom::Start(0)).unwrap();
984 f.write_all(&encrypted_content).unwrap();
985 drop(f);
986
987 let result = decrypt_file(&path, master_key);
989
990 assert!(result.is_err());
991 assert!(
992 result
993 .unwrap_err()
994 .to_string()
995 .contains("Decryption failed")
996 );
997 }
998
999 #[test]
1000 fn test_header_tamper_detected() {
1001 let plaintext = b"Test data with header integrity check.";
1002 let path = create_temp_file(plaintext);
1003
1004 let (key, salt) = get_test_key_and_salt();
1005 let master_key = b"super_secret_password";
1006
1007 encrypt_file(&path, &key, &salt, None, None).unwrap();
1009
1010 let mut encrypted_content = Vec::new();
1012 let mut f = fs::OpenOptions::new()
1013 .read(true)
1014 .write(true)
1015 .open(&path)
1016 .unwrap();
1017 f.read_to_end(&mut encrypted_content).unwrap();
1018
1019 encrypted_content[6] ^= FLAG_COMPRESSED;
1020
1021 f.seek(std::io::SeekFrom::Start(0)).unwrap();
1022 f.write_all(&encrypted_content).unwrap();
1023 drop(f);
1024
1025 let result = decrypt_file(&path, master_key);
1028 assert!(result.is_err());
1029 assert!(
1030 result
1031 .unwrap_err()
1032 .to_string()
1033 .contains("Decryption failed")
1034 );
1035 }
1036
1037 #[test]
1038 fn test_deterministic_encrypt_with_fixed_salt_file_id() {
1039 let plaintext = b"Deterministic encryption test data.";
1040
1041 let password = b"test_password";
1042 let salt = [0x42; SALT_LEN];
1043 let file_id = [0x13; FILE_ID_LEN];
1044 let derived = derive_key(password, &salt).unwrap();
1045 let mut key = [0u8; 32];
1046 key.copy_from_slice(&*derived);
1047
1048 let path1 = create_temp_file(plaintext);
1050 let path2 = create_temp_file(plaintext);
1051
1052 encrypt_file(&path1, &key, &salt, Some(file_id), None).unwrap();
1053 encrypt_file(&path2, &key, &salt, Some(file_id), None).unwrap();
1054
1055 let ct1 = fs::read(&path1).unwrap();
1056 let ct2 = fs::read(&path2).unwrap();
1057 assert_eq!(
1058 ct1, ct2,
1059 "Same plaintext + same salt+file_id must produce identical ciphertext"
1060 );
1061
1062 decrypt_file(&path1, password).unwrap();
1064 assert_eq!(fs::read(&path1).unwrap(), plaintext);
1065 }
1066
1067 #[test]
1068 fn test_deterministic_encrypt_multi_chunk() {
1069 #[allow(clippy::cast_possible_truncation)]
1071 let plaintext = {
1072 let mut data = Vec::with_capacity(CHUNK_SIZE * 2 + 1000);
1073 for i in 0..(CHUNK_SIZE * 2 + 1000) {
1074 data.push(i as u8);
1075 }
1076 data
1077 };
1078
1079 let password = b"test_password";
1080 let salt = [0x42; SALT_LEN];
1081 let file_id = [0x13; FILE_ID_LEN];
1082 let derived = derive_key(password, &salt).unwrap();
1083 let mut key = [0u8; 32];
1084 key.copy_from_slice(&*derived);
1085
1086 let path1 = create_temp_file(&plaintext);
1087 let path2 = create_temp_file(&plaintext);
1088
1089 encrypt_file(&path1, &key, &salt, Some(file_id), None).unwrap();
1090 encrypt_file(&path2, &key, &salt, Some(file_id), None).unwrap();
1091
1092 let ct1 = fs::read(&path1).unwrap();
1093 let ct2 = fs::read(&path2).unwrap();
1094 assert_eq!(
1095 ct1, ct2,
1096 "Same multi-chunk plaintext + same salt+file_id must produce identical ciphertext"
1097 );
1098
1099 decrypt_file(&path1, password).unwrap();
1101 assert_eq!(fs::read(&path1).unwrap(), plaintext);
1102 }
1103
1104 #[test]
1105 fn test_different_file_id_produces_different_ciphertext() {
1106 let plaintext = b"Same content, different file.";
1109
1110 let password = b"test_password";
1111 let salt = [0x42; SALT_LEN];
1112 let derived = derive_key(password, &salt).unwrap();
1113 let mut key = [0u8; 32];
1114 key.copy_from_slice(&*derived);
1115
1116 let path1 = create_temp_file(plaintext);
1117 let path2 = create_temp_file(plaintext);
1118
1119 let file_id1 = [0x01; FILE_ID_LEN];
1120 let file_id2 = [0x02; FILE_ID_LEN];
1121
1122 encrypt_file(&path1, &key, &salt, Some(file_id1), None).unwrap();
1123 encrypt_file(&path2, &key, &salt, Some(file_id2), None).unwrap();
1124
1125 let ct1 = fs::read(&path1).unwrap();
1126 let ct2 = fs::read(&path2).unwrap();
1127 assert_ne!(
1128 ct1, ct2,
1129 "Same plaintext with different File_IDs must produce different ciphertext"
1130 );
1131
1132 decrypt_file(&path1, password).unwrap();
1134 assert_eq!(fs::read(&path1).unwrap(), plaintext);
1135 decrypt_file(&path2, password).unwrap();
1136 assert_eq!(fs::read(&path2).unwrap(), plaintext);
1137 }
1138
1139 #[cfg(unix)]
1140 #[test]
1141 fn test_metadata_preservation() {
1142 use std::os::unix::fs::PermissionsExt;
1143
1144 let plaintext = b"Executable script content";
1145 let file = create_temp_file(plaintext);
1146 let path = file.path();
1147
1148 let mut perms = fs::metadata(path).unwrap().permissions();
1150 perms.set_mode(0o755);
1151 fs::set_permissions(path, perms).unwrap();
1152
1153 let (key, salt) = get_test_key_and_salt();
1154 let master_key = b"super_secret_password";
1155
1156 encrypt_file(path, &key, &salt, None, None).unwrap();
1158
1159 let encrypted_perms = fs::metadata(path).unwrap().permissions();
1161 assert_eq!(encrypted_perms.mode() & 0o777, 0o755);
1162
1163 let key_cache: KeyCache = DashMap::new();
1165 decrypt_file_with_cache(path, &key_cache, None, master_key).unwrap();
1166
1167 let decrypted_perms = fs::metadata(path).unwrap().permissions();
1169 assert_eq!(decrypted_perms.mode() & 0o777, 0o755);
1170 }
1171}