1use std::fs;
5use std::io::{Read, Seek, Write};
6use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
8use std::sync::{Arc, Mutex};
9#[cfg(target_os = "windows")]
10use std::time::SystemTime;
11use std::time::{Duration, Instant};
12
13use aead::stream::{DecryptorBE32, EncryptorBE32};
14use aes_gcm_siv::{
15 aead::{Aead, KeyInit, Payload},
16 Aes256GcmSiv, Nonce,
17};
18use argon2::{Algorithm, Argon2, Params, Version};
19use hkdf::Hkdf;
20use rand_core::{OsRng, RngCore};
21use sha2::Sha512;
22use zeroize::{Zeroize, Zeroizing};
23
24use crate::error::{CryptoError, CryptoResult};
25
26pub const SALT_LEN: usize = 32;
28pub const NONCE_LEN: usize = 12;
29pub const HEADER_LEN: usize = 8 + SALT_LEN + NONCE_LEN;
30pub const MAGIC: &[u8; 8] = b"VAULTX02";
31pub const TAG_LEN: usize = 16;
32pub const EXTENSION: &str = ".vx2";
33
34pub const MAGIC_V3: &[u8; 8] = b"VAULTX03";
51pub const SALT_V3_LEN: usize = 16;
53pub const STREAM_NONCE_LEN: usize = 7;
55pub const HEADER_V3_LEN: usize = 8 + SALT_V3_LEN + STREAM_NONCE_LEN;
57pub const CHUNK_SIZE: usize = 1_048_576;
59
60pub const MAX_V3_FILE_SIZE: u64 = (1u64 << 32) * CHUNK_SIZE as u64;
67
68pub const MAGIC_V4: &[u8; 8] = b"VAULTX04";
86pub const HEADER_V4_LEN: usize = HEADER_V3_LEN;
88pub const MAX_V4_FILE_SIZE: u64 = MAX_V3_FILE_SIZE;
90
91pub const MAX_FILE_SIZE: u64 = 8_000_000_000;
93pub const MAX_ENCRYPTED_FILE_SIZE: u64 = MAX_FILE_SIZE + HEADER_LEN as u64 + TAG_LEN as u64;
94
95pub const MIN_PASSWORD_LEN: usize = 8;
97
98pub trait ProgressReporter: Send + Sync {
100 fn report(&self, progress: f32, message: &str);
101}
102
103fn source_file_name(path: &Path) -> String {
104 path.file_name()
105 .map(|name| name.to_string_lossy().into_owned())
106 .unwrap_or_else(|| String::from("file"))
107}
108
109pub fn default_encrypt_output_name(path: &Path) -> String {
111 format!("{}{}", source_file_name(path), EXTENSION)
112}
113
114pub fn default_decrypt_output_name(path: &Path) -> String {
119 let name = source_file_name(path);
120 if name.len() >= EXTENSION.len()
121 && name[name.len() - EXTENSION.len()..].eq_ignore_ascii_case(EXTENSION)
122 {
123 let stripped = &name[..name.len() - EXTENSION.len()];
124 if stripped.is_empty() {
125 String::from("decrypted")
126 } else {
127 stripped.to_owned()
128 }
129 } else {
130 name
131 }
132}
133
134pub struct ThrottledReporter<'a> {
136 inner: &'a dyn ProgressReporter,
137 last_progress: AtomicU32,
138 last_time: Mutex<Option<Instant>>,
139 last_message: Mutex<String>,
140}
141
142impl<'a> ThrottledReporter<'a> {
143 pub fn new(inner: &'a dyn ProgressReporter) -> Self {
144 Self {
145 inner,
146 last_progress: AtomicU32::new(f32::to_bits(-1.0)),
147 last_time: Mutex::new(None),
148 last_message: Mutex::new(String::new()),
149 }
150 }
151}
152
153impl<'a> ProgressReporter for ThrottledReporter<'a> {
154 fn report(&self, progress: f32, message: &str) {
155 let now = Instant::now();
156 let last_progress = f32::from_bits(self.last_progress.load(Ordering::Relaxed));
157
158 let (last_message, last_time) = {
159 let message_guard = self.last_message.lock().unwrap_or_else(|e| e.into_inner());
160 let time_guard = self.last_time.lock().unwrap_or_else(|e| e.into_inner());
161 (message_guard.clone(), *time_guard)
162 };
163
164 let should_report = match last_time {
165 Some(last_time) => {
166 let elapsed_ms = now.duration_since(last_time).as_millis();
167 (progress - last_progress).abs() > 0.01
168 || elapsed_ms > 100
169 || last_message != message
170 }
171 None => true,
172 };
173
174 if should_report {
175 self.inner.report(progress, message);
176 *self
177 .last_message
178 .lock()
179 .unwrap_or_else(|poison| poison.into_inner()) = message.to_owned();
180 *self
181 .last_time
182 .lock()
183 .unwrap_or_else(|poison| poison.into_inner()) = Some(now);
184 self.last_progress
185 .store(progress.to_bits(), Ordering::Relaxed);
186 }
187 }
188}
189
190fn derive_key(password: &[u8], salt: &[u8]) -> CryptoResult<Zeroizing<Vec<u8>>> {
191 let mut final_key = Zeroizing::new(vec![0u8; 32]);
192
193 let mut intermediate = Zeroizing::new(vec![0u8; 64]);
194 let params = Params::new(262_144, 3, 4, Some(64))
195 .map_err(|error| CryptoError::Argon2Failed(error.to_string()))?;
196 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
197 argon2
198 .hash_password_into(password, salt, &mut intermediate)
199 .map_err(|error| CryptoError::Argon2Failed(error.to_string()))?;
200
201 let hkdf = Hkdf::<Sha512>::new(None, &intermediate);
202 hkdf.expand(b"VAULTX02_AES_256_GCM_SIV", &mut final_key)
203 .map_err(|error| CryptoError::HkdfFailed(error.to_string()))?;
204 intermediate.zeroize();
205
206 Ok(final_key)
207}
208
209fn derive_key_v3(password: &[u8], salt: &[u8]) -> CryptoResult<Zeroizing<Vec<u8>>> {
212 let mut final_key = Zeroizing::new(vec![0u8; 32]);
213 let mut intermediate = Zeroizing::new(vec![0u8; 64]);
214 let params = Params::new(262_144, 3, 4, Some(64))
215 .map_err(|e| CryptoError::Argon2Failed(e.to_string()))?;
216 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
217 argon2
218 .hash_password_into(password, salt, &mut intermediate)
219 .map_err(|e| CryptoError::Argon2Failed(e.to_string()))?;
220 let hkdf = Hkdf::<Sha512>::new(None, &intermediate);
221 hkdf.expand(b"VAULTX03_AES_256_GCM_SIV_STREAM", &mut final_key)
222 .map_err(|e| CryptoError::HkdfFailed(e.to_string()))?;
223 intermediate.zeroize();
224 Ok(final_key)
225}
226
227fn derive_key_v4(password: &[u8], salt: &[u8]) -> CryptoResult<Zeroizing<Vec<u8>>> {
231 let mut final_key = Zeroizing::new(vec![0u8; 32]);
232 let mut intermediate = Zeroizing::new(vec![0u8; 64]);
233 let params = Params::new(262_144, 3, 4, Some(64))
234 .map_err(|e| CryptoError::Argon2Failed(e.to_string()))?;
235 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
236 argon2
237 .hash_password_into(password, salt, &mut intermediate)
238 .map_err(|e| CryptoError::Argon2Failed(e.to_string()))?;
239 let hkdf = Hkdf::<Sha512>::new(None, &intermediate);
240 hkdf.expand(b"VAULTX04_AES_256_GCM_SIV_STREAM_AAD", &mut final_key)
241 .map_err(|e| CryptoError::HkdfFailed(e.to_string()))?;
242 intermediate.zeroize();
243 Ok(final_key)
244}
245
246fn build_aad(salt: &[u8], nonce: &[u8]) -> Vec<u8> {
247 let mut aad = Vec::with_capacity(SALT_LEN + NONCE_LEN);
248 aad.extend_from_slice(salt);
249 aad.extend_from_slice(nonce);
250 aad
251}
252
253fn build_aad_v4(salt: &[u8], stream_nonce: &[u8]) -> Vec<u8> {
256 let mut aad = Vec::with_capacity(8 + SALT_V3_LEN + STREAM_NONCE_LEN);
257 aad.extend_from_slice(MAGIC_V4);
258 aad.extend_from_slice(salt);
259 aad.extend_from_slice(stream_nonce);
260 aad
261}
262
263fn open_regular_file(path: &Path) -> CryptoResult<(fs::File, fs::Metadata)> {
264 let file = fs::File::open(path)?;
265 let metadata = file.metadata()?;
266 if !metadata.is_file() {
267 return Err(CryptoError::NotAFile(path.to_path_buf()));
268 }
269
270 Ok((file, metadata))
271}
272
273fn normalize_destination_path(path: &Path) -> CryptoResult<PathBuf> {
274 let file_name = path
275 .file_name()
276 .ok_or_else(|| CryptoError::InvalidDestination(path.to_path_buf()))?;
277 let parent = path
278 .parent()
279 .filter(|parent| !parent.as_os_str().is_empty())
280 .unwrap_or(Path::new("."));
281 let canonical_parent = fs::canonicalize(parent)?;
282
283 if !canonical_parent.is_dir() {
284 return Err(CryptoError::InvalidDestination(path.to_path_buf()));
285 }
286
287 Ok(canonical_parent.join(file_name))
288}
289
290#[cfg(target_os = "windows")]
291fn same_path(a: &Path, b: &Path) -> bool {
292 a.to_string_lossy()
293 .eq_ignore_ascii_case(&b.to_string_lossy())
294}
295
296#[cfg(not(target_os = "windows"))]
297fn same_path(a: &Path, b: &Path) -> bool {
298 a == b
299}
300
301fn validate_destination_path(src: &Path, dest: &Path, overwrite: bool) -> CryptoResult<PathBuf> {
302 let canonical_src = fs::canonicalize(src)?;
303 let canonical_dest = normalize_destination_path(dest)?;
304
305 if same_path(&canonical_src, &canonical_dest) {
306 return Err(CryptoError::SourceAndDestinationSame(canonical_dest));
307 }
308
309 match fs::symlink_metadata(&canonical_dest) {
310 Ok(_) if overwrite => fs::remove_file(&canonical_dest)?,
311 Ok(_) => return Err(CryptoError::FileAlreadyExists(canonical_dest)),
312 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
313 Err(error) => return Err(error.into()),
314 }
315
316 Ok(canonical_dest)
317}
318
319fn read_limited_file(file: fs::File, limit: u64) -> std::io::Result<Vec<u8>> {
320 let mut reader = file.take(limit + 1);
321 let mut buffer = Vec::new();
322 reader.read_to_end(&mut buffer)?;
323 Ok(buffer)
324}
325
326fn persist_temp_file(tmp: &Path, dest: &Path) -> CryptoResult<()> {
327 #[cfg(unix)]
328 {
329 match fs::hard_link(tmp, dest) {
330 Ok(()) => {
331 let _ = fs::remove_file(tmp);
332 Ok(())
333 }
334 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
335 let _ = fs::remove_file(tmp);
336 Err(CryptoError::FileAlreadyExists(dest.to_path_buf()))
337 }
338 Err(error) => {
339 let _ = fs::remove_file(tmp);
340 Err(error.into())
341 }
342 }
343 }
344
345 #[cfg(not(unix))]
346 {
347 match fs::rename(tmp, dest) {
348 Ok(()) => Ok(()),
349 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
350 let _ = fs::remove_file(tmp);
351 Err(CryptoError::FileAlreadyExists(dest.to_path_buf()))
352 }
353 Err(error) => {
354 let _ = fs::remove_file(tmp);
355 Err(error.into())
356 }
357 }
358 }
359}
360
361fn tmp_path(dest: &Path) -> PathBuf {
362 let mut rng_bytes = [0u8; 8];
363 OsRng.fill_bytes(&mut rng_bytes);
364 let suffix = hex::encode(rng_bytes);
365 let mut tmp = dest.to_path_buf();
366 let name = dest.file_name().unwrap_or_default().to_string_lossy();
367 tmp.set_file_name(format!("{name}.{suffix}.tmp"));
368 tmp
369}
370
371#[allow(clippy::type_complexity)]
372pub fn encrypt_bytes(
373 plaintext: &[u8],
374 password: &[u8],
375) -> CryptoResult<(Zeroizing<Vec<u8>>, [u8; SALT_LEN], [u8; NONCE_LEN])> {
376 if password.len() < MIN_PASSWORD_LEN {
377 return Err(CryptoError::PassphraseTooShort(MIN_PASSWORD_LEN));
378 }
379
380 let mut salt = [0u8; SALT_LEN];
381 let mut nonce_bytes = [0u8; NONCE_LEN];
382 OsRng.fill_bytes(&mut salt);
383 OsRng.fill_bytes(&mut nonce_bytes);
384
385 let key = derive_key(password, &salt)?;
386 let cipher = Aes256GcmSiv::new(key.as_slice().into());
387 let nonce = Nonce::from_slice(&nonce_bytes);
388 let aad = build_aad(&salt, &nonce_bytes);
389 let ciphertext = cipher
390 .encrypt(
391 nonce,
392 Payload {
393 msg: plaintext,
394 aad: &aad,
395 },
396 )
397 .map_err(|error| CryptoError::EncryptionFailed(error.to_string()))?;
398
399 Ok((Zeroizing::new(ciphertext), salt, nonce_bytes))
400}
401
402pub fn decrypt_bytes(
403 ciphertext: &[u8],
404 password: &[u8],
405 salt: &[u8],
406 nonce_bytes: &[u8],
407) -> CryptoResult<Zeroizing<Vec<u8>>> {
408 if password.len() < MIN_PASSWORD_LEN {
409 return Err(CryptoError::PassphraseTooShort(MIN_PASSWORD_LEN));
410 }
411 if salt.len() != SALT_LEN {
412 return Err(CryptoError::InvalidSaltLength {
413 expected: SALT_LEN,
414 actual: salt.len(),
415 });
416 }
417 if nonce_bytes.len() != NONCE_LEN {
418 return Err(CryptoError::InvalidNonceLength {
419 expected: NONCE_LEN,
420 actual: nonce_bytes.len(),
421 });
422 }
423
424 let key = derive_key(password, salt)?;
425 let cipher = Aes256GcmSiv::new(key.as_slice().into());
426 let nonce = Nonce::from_slice(nonce_bytes);
427 let aad = build_aad(salt, nonce_bytes);
428 let plaintext = cipher
429 .decrypt(
430 nonce,
431 Payload {
432 msg: ciphertext,
433 aad: &aad,
434 },
435 )
436 .map_err(|_| CryptoError::DecryptionFailed)?;
437
438 Ok(Zeroizing::new(plaintext))
439}
440
441pub fn parse_header(raw: &[u8]) -> CryptoResult<(&[u8], &[u8], &[u8])> {
442 if raw.len() < HEADER_LEN + TAG_LEN {
443 return Err(CryptoError::FileTooSmall);
444 }
445 if &raw[..8] != MAGIC {
446 return Err(CryptoError::InvalidMagic);
447 }
448
449 let salt = &raw[8..40];
450 let nonce = &raw[40..52];
451 let ciphertext = &raw[52..];
452 Ok((salt, nonce, ciphertext))
453}
454
455pub fn encrypt_file(
456 src: &Path,
457 dest: &Path,
458 overwrite: bool,
459 password: &[u8],
460 reporter: &dyn ProgressReporter,
461 cancel_flag: Option<&Arc<AtomicBool>>,
462) -> CryptoResult<PathBuf> {
463 if password.len() < MIN_PASSWORD_LEN {
464 return Err(CryptoError::PassphraseTooShort(MIN_PASSWORD_LEN));
465 }
466
467 let (mut file, source_metadata) = open_regular_file(src)?;
468 let dest = validate_destination_path(src, dest, overwrite)?;
469 let source_len = source_metadata.len();
470
471 if source_len > MAX_V4_FILE_SIZE {
474 return Err(CryptoError::FileTooLarge {
475 size_gb: source_len as f64 / 1_000_000_000.0,
476 max_gb: MAX_V4_FILE_SIZE as f64 / 1_000_000_000.0,
477 });
478 }
479
480 let mut salt = [0u8; SALT_V3_LEN];
481 let mut stream_nonce = [0u8; STREAM_NONCE_LEN];
482 OsRng.fill_bytes(&mut salt);
483 OsRng.fill_bytes(&mut stream_nonce);
484
485 reporter.report(0.05, "Deriving encryption key (Argon2id)...");
486 let key = derive_key_v4(password, &salt)?;
488
489 let cipher = Aes256GcmSiv::new(key.as_slice().into());
490 let mut encryptor = EncryptorBE32::from_aead(cipher, (&stream_nonce).into());
491
492 let aad = build_aad_v4(&salt, &stream_nonce);
495
496 let tmp = tmp_path(&dest);
497 let write_result = (|| -> CryptoResult<()> {
498 #[cfg(unix)]
499 let mut out = {
500 use std::os::unix::fs::OpenOptionsExt;
501 fs::OpenOptions::new()
502 .write(true)
503 .create_new(true)
504 .mode(0o600)
505 .open(&tmp)?
506 };
507 #[cfg(not(unix))]
508 let mut out = fs::OpenOptions::new()
509 .write(true)
510 .create_new(true)
511 .open(&tmp)?;
512
513 out.write_all(MAGIC_V4)?;
515 out.write_all(&salt)?;
516 out.write_all(&stream_nonce)?;
517
518 let mut buf = Zeroizing::new(vec![0u8; CHUNK_SIZE]);
520 let mut bytes_done: u64 = 0;
521 let mut chunk_counter: u64 = 0;
522 const IO_GOVENER_INTERVAL: u64 = 100; loop {
525 if let Some(flag) = cancel_flag {
529 if flag.load(Ordering::SeqCst) {
530 let _ = fs::remove_file(&tmp);
531 return Err(CryptoError::Cancelled);
532 }
533 }
534
535 let n = read_exact_or_eof(&mut file, buf.as_mut_slice())?;
536 if n == 0 {
537 let ct = encryptor
539 .encrypt_last(Payload {
540 msg: &[][..],
541 aad: &aad,
542 })
543 .map_err(|e| CryptoError::EncryptionFailed(e.to_string()))?;
544 out.write_all(&ct)?;
545 break;
546 }
547
548 bytes_done += n as u64;
549 let frac = if source_len > 0 {
550 0.10 + 0.85 * (bytes_done as f32 / source_len as f32)
551 } else {
552 0.95
553 };
554
555 let mut peek = [0u8; 1];
557 let peeked = file.read(&mut peek)?;
558
559 if peeked == 0 {
560 reporter.report(frac, "Encrypting final chunk...");
562 let ct = encryptor
563 .encrypt_last(Payload {
564 msg: &buf[..n],
565 aad: &aad,
566 })
567 .map_err(|e| CryptoError::EncryptionFailed(e.to_string()))?;
568 out.write_all(&ct)?;
569 break;
570 } else {
571 reporter.report(frac, "Encrypting...");
573 let ct = encryptor
574 .encrypt_next(Payload {
575 msg: &buf[..n],
576 aad: &aad,
577 })
578 .map_err(|e| CryptoError::EncryptionFailed(e.to_string()))?;
579 out.write_all(&ct)?;
580 chunk_counter += 1;
581
582 if chunk_counter.is_multiple_of(IO_GOVENER_INTERVAL) {
584 out.sync_data()?;
585 std::thread::sleep(Duration::from_millis(5));
586 }
587
588 file.seek(std::io::SeekFrom::Current(-1))?;
590 }
591 }
592
593 out.sync_all()?;
594 Ok(())
595 })();
596
597 if let Err(error) = write_result {
598 let _ = fs::remove_file(&tmp);
599 return Err(error);
600 }
601
602 if let Some(flag) = cancel_flag {
607 if flag.load(Ordering::SeqCst) {
608 let _ = fs::remove_file(&tmp);
609 return Err(CryptoError::Cancelled);
610 }
611 }
612
613 persist_temp_file(&tmp, &dest)?;
614 reporter.report(1.00, "Encryption complete.");
615 Ok(dest)
616}
617
618fn read_exact_or_eof(reader: &mut impl Read, buf: &mut [u8]) -> std::io::Result<usize> {
620 let mut total = 0;
621 while total < buf.len() {
622 match reader.read(&mut buf[total..]) {
623 Ok(0) => break,
624 Ok(n) => total += n,
625 Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
626 Err(e) => return Err(e),
627 }
628 }
629 Ok(total)
630}
631
632pub fn decrypt_file(
633 src: &Path,
634 dest: &Path,
635 overwrite: bool,
636 password: &[u8],
637 reporter: &dyn ProgressReporter,
638 cancel_flag: Option<&Arc<AtomicBool>>,
639) -> CryptoResult<PathBuf> {
640 if password.len() < MIN_PASSWORD_LEN {
641 return Err(CryptoError::PassphraseTooShort(MIN_PASSWORD_LEN));
642 }
643
644 if let Some(flag) = cancel_flag {
646 if flag.load(Ordering::SeqCst) {
647 return Err(CryptoError::Cancelled);
648 }
649 }
650
651 let mut magic_buf = [0u8; 8];
653 {
654 let mut f = fs::File::open(src)?;
655 f.read_exact(&mut magic_buf)
656 .map_err(|_| CryptoError::FileTooSmall)?;
657 }
658
659 if &magic_buf == MAGIC {
660 decrypt_file_legacy(src, dest, overwrite, password, reporter, cancel_flag)
661 } else if &magic_buf == MAGIC_V4 {
662 decrypt_file_streaming_v4(src, dest, overwrite, password, reporter, cancel_flag)
664 } else if &magic_buf == MAGIC_V3 {
665 decrypt_file_streaming(src, dest, overwrite, password, reporter, cancel_flag)
667 } else {
668 Err(CryptoError::InvalidMagic)
669 }
670}
671
672fn decrypt_file_legacy(
674 src: &Path,
675 dest: &Path,
676 overwrite: bool,
677 password: &[u8],
678 reporter: &dyn ProgressReporter,
679 cancel_flag: Option<&Arc<AtomicBool>>,
680) -> CryptoResult<PathBuf> {
681 let (file, source_metadata) = open_regular_file(src)?;
682 let dest = validate_destination_path(src, dest, overwrite)?;
683 let source_len = source_metadata.len();
684 #[cfg(target_os = "windows")]
685 let (src_accessed, src_modified) = (
686 source_metadata.accessed().ok(),
687 source_metadata.modified().ok(),
688 );
689
690 if source_len > MAX_ENCRYPTED_FILE_SIZE {
691 return Err(CryptoError::FileTooLarge {
692 size_gb: source_len as f64 / 1_000_000_000.0,
693 max_gb: MAX_ENCRYPTED_FILE_SIZE as f64 / 1_000_000_000.0,
694 });
695 }
696
697 if let Some(flag) = cancel_flag {
699 if flag.load(Ordering::SeqCst) {
700 return Err(CryptoError::Cancelled);
701 }
702 }
703
704 eprintln!("[INFO] VAULTX02 (V2) format detected. Consider re-encrypting with V3 for streaming support and no memory limits.");
705 reporter.report(0.10, "Reading encrypted file (legacy V2)...");
706 let raw = read_limited_file(file, 1_073_741_824u64)?;
707 if raw.len() as u64 > 1_073_741_824u64 {
708 return Err(CryptoError::LegacyFileTooLarge);
709 }
710
711 let (salt, nonce, ciphertext) = parse_header(&raw)?;
712
713 reporter.report(0.20, "Deriving decryption key (Argon2id)...");
714 let key = derive_key(password, salt)?;
715
716 reporter.report(0.50, "Decrypting data (AES-256-GCM-SIV)...");
717 let cipher = Aes256GcmSiv::new(key.as_slice().into());
718 let aad = build_aad(salt, nonce);
719 let plaintext = Zeroizing::new(
720 cipher
721 .decrypt(
722 Nonce::from_slice(nonce),
723 Payload {
724 msg: ciphertext,
725 aad: &aad,
726 },
727 )
728 .map_err(|_| CryptoError::DecryptionFailed)?,
729 );
730
731 let tmp = tmp_path(&dest);
732 reporter.report(0.75, "Writing decrypted file...");
733 let write_result = (|| -> CryptoResult<()> {
734 #[cfg(unix)]
735 let mut file = {
736 use std::os::unix::fs::OpenOptionsExt;
737 fs::OpenOptions::new()
738 .write(true)
739 .create_new(true)
740 .mode(0o600)
741 .open(&tmp)?
742 };
743 #[cfg(not(unix))]
744 let mut file = fs::OpenOptions::new()
745 .write(true)
746 .create_new(true)
747 .open(&tmp)?;
748 file.write_all(&plaintext)?;
749 file.sync_all()?;
750 Ok(())
751 })();
752
753 if let Err(error) = write_result {
754 let _ = fs::remove_file(&tmp);
755 return Err(error);
756 }
757
758 if let Some(flag) = cancel_flag {
760 if flag.load(Ordering::SeqCst) {
761 let _ = fs::remove_file(&tmp);
762 return Err(CryptoError::Cancelled);
763 }
764 }
765
766 persist_temp_file(&tmp, &dest)?;
767
768 #[cfg(target_os = "windows")]
769 {
770 if let Ok(file) = fs::File::open(&dest) {
771 let times = fs::FileTimes::new()
772 .set_accessed(src_accessed.unwrap_or(SystemTime::now()))
773 .set_modified(src_modified.unwrap_or(SystemTime::now()));
774 let _ = file.set_times(times);
775 }
776 }
777
778 reporter.report(1.00, "Decryption complete.");
779 Ok(dest)
780}
781
782fn decrypt_file_streaming(
784 src: &Path,
785 dest: &Path,
786 overwrite: bool,
787 password: &[u8],
788 reporter: &dyn ProgressReporter,
789 cancel_flag: Option<&Arc<AtomicBool>>,
790) -> CryptoResult<PathBuf> {
791 let (mut file, source_metadata) = open_regular_file(src)?;
792 let dest = validate_destination_path(src, dest, overwrite)?;
793 let file_len = source_metadata.len();
794 #[cfg(target_os = "windows")]
795 let (src_accessed, src_modified) = (
796 source_metadata.accessed().ok(),
797 source_metadata.modified().ok(),
798 );
799
800 if file_len < HEADER_V3_LEN as u64 + TAG_LEN as u64 {
801 return Err(CryptoError::FileTooSmall);
802 }
803
804 file.seek(std::io::SeekFrom::Start(8))?;
806 let mut salt = [0u8; SALT_V3_LEN];
807 let mut stream_nonce = [0u8; STREAM_NONCE_LEN];
808 file.read_exact(&mut salt)?;
809 file.read_exact(&mut stream_nonce)?;
810
811 reporter.report(0.05, "Deriving decryption key (Argon2id)...");
812 let key = derive_key_v3(password, &salt)?;
813
814 let cipher = Aes256GcmSiv::new(key.as_slice().into());
815 let mut decryptor = DecryptorBE32::from_aead(cipher, (&stream_nonce).into());
816
817 let encrypted_body = file_len - HEADER_V3_LEN as u64;
818 let enc_chunk_size = CHUNK_SIZE + TAG_LEN;
819
820 let tmp = tmp_path(&dest);
821 let write_result = (|| -> CryptoResult<()> {
822 #[cfg(unix)]
823 let mut out = {
824 use std::os::unix::fs::OpenOptionsExt;
825 fs::OpenOptions::new()
826 .write(true)
827 .create_new(true)
828 .mode(0o600)
829 .open(&tmp)?
830 };
831 #[cfg(not(unix))]
832 let mut out = fs::OpenOptions::new()
833 .write(true)
834 .create_new(true)
835 .open(&tmp)?;
836
837 let mut buf = Zeroizing::new(vec![0u8; enc_chunk_size]);
838 let mut bytes_done: u64 = 0;
839 let mut last_chunk: Option<Zeroizing<Vec<u8>>> = None;
840
841 loop {
843 if let Some(flag) = cancel_flag {
845 if flag.load(Ordering::SeqCst) {
846 let _ = fs::remove_file(&tmp);
847 return Err(CryptoError::Cancelled);
848 }
849 }
850
851 let n = read_exact_or_eof(&mut file, &mut buf)?;
852 if n == 0 {
853 break;
854 }
855
856 if let Some(prev) = last_chunk.take() {
858 let frac = 0.10 + 0.85 * (bytes_done as f32 / encrypted_body as f32);
859 reporter.report(frac, "Decrypting...");
860 let pt = Zeroizing::new(
861 decryptor
862 .decrypt_next(prev.as_slice())
863 .map_err(|_| CryptoError::DecryptionFailed)?,
864 );
865 out.write_all(pt.as_slice())?;
866 }
867
868 bytes_done += n as u64;
869 last_chunk = Some(Zeroizing::new(buf[..n].to_vec()));
870 }
871
872 if let Some(final_data) = last_chunk {
874 reporter.report(0.95, "Decrypting final chunk...");
875 let pt = Zeroizing::new(
876 decryptor
877 .decrypt_last(final_data.as_slice())
878 .map_err(|_| CryptoError::DecryptionFailed)?,
879 );
880 out.write_all(pt.as_slice())?;
881 }
882
883 out.sync_all()?;
884 Ok(())
885 })();
886
887 if let Err(error) = write_result {
888 let _ = fs::remove_file(&tmp);
889 return Err(error);
890 }
891
892 if let Some(flag) = cancel_flag {
894 if flag.load(Ordering::SeqCst) {
895 let _ = fs::remove_file(&tmp);
896 return Err(CryptoError::Cancelled);
897 }
898 }
899
900 persist_temp_file(&tmp, &dest)?;
901
902 #[cfg(target_os = "windows")]
903 {
904 if let Ok(file) = fs::File::open(&dest) {
905 let times = fs::FileTimes::new()
906 .set_accessed(src_accessed.unwrap_or(SystemTime::now()))
907 .set_modified(src_modified.unwrap_or(SystemTime::now()));
908 let _ = file.set_times(times);
909 }
910 }
911
912 reporter.report(1.00, "Decryption complete.");
913 Ok(dest)
914}
915
916fn decrypt_file_streaming_v4(
919 src: &Path,
920 dest: &Path,
921 overwrite: bool,
922 password: &[u8],
923 reporter: &dyn ProgressReporter,
924 cancel_flag: Option<&Arc<AtomicBool>>,
925) -> CryptoResult<PathBuf> {
926 let (mut file, source_metadata) = open_regular_file(src)?;
927 let dest = validate_destination_path(src, dest, overwrite)?;
928 let file_len = source_metadata.len();
929 #[cfg(target_os = "windows")]
930 let (src_accessed, src_modified) = (
931 source_metadata.accessed().ok(),
932 source_metadata.modified().ok(),
933 );
934
935 if file_len < HEADER_V4_LEN as u64 + TAG_LEN as u64 {
936 return Err(CryptoError::FileTooSmall);
937 }
938
939 file.seek(std::io::SeekFrom::Start(8))?;
941 let mut salt = [0u8; SALT_V3_LEN];
942 let mut stream_nonce = [0u8; STREAM_NONCE_LEN];
943 file.read_exact(&mut salt)?;
944 file.read_exact(&mut stream_nonce)?;
945
946 reporter.report(0.05, "Deriving decryption key (Argon2id)...");
947 let key = derive_key_v4(password, &salt)?;
948
949 let cipher = Aes256GcmSiv::new(key.as_slice().into());
950 let mut decryptor = DecryptorBE32::from_aead(cipher, (&stream_nonce).into());
951
952 let aad = build_aad_v4(&salt, &stream_nonce);
956
957 let encrypted_body = file_len - HEADER_V4_LEN as u64;
958 let enc_chunk_size = CHUNK_SIZE + TAG_LEN;
959
960 let tmp = tmp_path(&dest);
961 let write_result = (|| -> CryptoResult<()> {
962 #[cfg(unix)]
963 let mut out = {
964 use std::os::unix::fs::OpenOptionsExt;
965 fs::OpenOptions::new()
966 .write(true)
967 .create_new(true)
968 .mode(0o600)
969 .open(&tmp)?
970 };
971 #[cfg(not(unix))]
972 let mut out = fs::OpenOptions::new()
973 .write(true)
974 .create_new(true)
975 .open(&tmp)?;
976
977 let mut buf = Zeroizing::new(vec![0u8; enc_chunk_size]);
978 let mut bytes_done: u64 = 0;
979 let mut last_chunk: Option<Zeroizing<Vec<u8>>> = None;
980
981 loop {
983 if let Some(flag) = cancel_flag {
985 if flag.load(Ordering::SeqCst) {
986 let _ = fs::remove_file(&tmp);
987 return Err(CryptoError::Cancelled);
988 }
989 }
990
991 let n = read_exact_or_eof(&mut file, &mut buf)?;
992 if n == 0 {
993 break;
994 }
995
996 if let Some(prev) = last_chunk.take() {
998 let frac = 0.10 + 0.85 * (bytes_done as f32 / encrypted_body as f32);
999 reporter.report(frac, "Decrypting...");
1000 let pt = Zeroizing::new(
1001 decryptor
1002 .decrypt_next(Payload {
1003 msg: prev.as_slice(),
1004 aad: &aad,
1005 })
1006 .map_err(|_| CryptoError::DecryptionFailed)?,
1007 );
1008 out.write_all(pt.as_slice())?;
1009 }
1010
1011 bytes_done += n as u64;
1012 last_chunk = Some(Zeroizing::new(buf[..n].to_vec()));
1013 }
1014
1015 if let Some(final_data) = last_chunk {
1017 reporter.report(0.95, "Decrypting final chunk...");
1018 let pt = Zeroizing::new(
1019 decryptor
1020 .decrypt_last(Payload {
1021 msg: final_data.as_slice(),
1022 aad: &aad,
1023 })
1024 .map_err(|_| CryptoError::DecryptionFailed)?,
1025 );
1026 out.write_all(pt.as_slice())?;
1027 }
1028
1029 out.sync_all()?;
1030 Ok(())
1031 })();
1032
1033 if let Err(error) = write_result {
1034 let _ = fs::remove_file(&tmp);
1035 return Err(error);
1036 }
1037
1038 if let Some(flag) = cancel_flag {
1040 if flag.load(Ordering::SeqCst) {
1041 let _ = fs::remove_file(&tmp);
1042 return Err(CryptoError::Cancelled);
1043 }
1044 }
1045
1046 persist_temp_file(&tmp, &dest)?;
1047
1048 #[cfg(target_os = "windows")]
1049 {
1050 if let Ok(file) = fs::File::open(&dest) {
1051 let times = fs::FileTimes::new()
1052 .set_accessed(src_accessed.unwrap_or(SystemTime::now()))
1053 .set_modified(src_modified.unwrap_or(SystemTime::now()));
1054 let _ = file.set_times(times);
1055 }
1056 }
1057
1058 reporter.report(1.00, "Decryption complete.");
1059 Ok(dest)
1060}
1061
1062pub fn secure_wipe(path: &Path) -> CryptoResult<()> {
1067 let metadata = match fs::symlink_metadata(path) {
1068 Ok(metadata) => metadata,
1069 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1070 Err(error) => return Err(error.into()),
1071 };
1072 if !metadata.is_file() {
1073 return Err(CryptoError::NotAFile(path.to_path_buf()));
1074 }
1075
1076 let len = metadata.len();
1077 let mut file = fs::OpenOptions::new().write(true).open(path)?;
1078
1079 let mut buffer = vec![0u8; 64 * 1024];
1080 for _ in 0..3 {
1081 file.seek(std::io::SeekFrom::Start(0))?;
1082 let mut written = 0;
1083
1084 while written < len {
1085 let to_write = std::cmp::min(buffer.len() as u64, len - written) as usize;
1086 OsRng.fill_bytes(&mut buffer[..to_write]);
1087 file.write_all(&buffer[..to_write])?;
1088 written += to_write as u64;
1089 }
1090
1091 file.sync_all()?;
1092 }
1093
1094 #[cfg(target_os = "windows")]
1095 {
1096 let epoch = SystemTime::UNIX_EPOCH;
1097 let times = fs::FileTimes::new().set_accessed(epoch).set_modified(epoch);
1098 let _ = file.set_times(times);
1099 }
1100
1101 drop(file);
1102
1103 for _ in 0..8 {
1104 let mut random_name = [0u8; 16];
1105 OsRng.fill_bytes(&mut random_name);
1106 let renamed_path = path.with_file_name(hex::encode(random_name));
1107
1108 match fs::rename(path, &renamed_path) {
1109 Ok(()) => {
1110 fs::remove_file(renamed_path)?;
1111 return Ok(());
1112 }
1113 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
1114 Err(error) => return Err(error.into()),
1115 }
1116 }
1117
1118 Err(std::io::Error::new(
1119 std::io::ErrorKind::AlreadyExists,
1120 "failed to generate a unique wipe filename",
1121 )
1122 .into())
1123}
1124
1125pub fn encrypt_stream<R: Read + Seek, W: Write>(
1128 reader: &mut R,
1129 writer: &mut W,
1130 password: &[u8],
1131 source_size: Option<u64>,
1132 reporter: &dyn ProgressReporter,
1133) -> CryptoResult<()> {
1134 if password.len() < MIN_PASSWORD_LEN {
1135 return Err(CryptoError::PassphraseTooShort(MIN_PASSWORD_LEN));
1136 }
1137
1138 if let Some(size) = source_size {
1140 if size > MAX_V4_FILE_SIZE {
1141 return Err(CryptoError::FileTooLarge {
1142 size_gb: size as f64 / 1_000_000_000.0,
1143 max_gb: MAX_V4_FILE_SIZE as f64 / 1_000_000_000.0,
1144 });
1145 }
1146 }
1147
1148 let mut salt = [0u8; SALT_V3_LEN];
1149 let mut stream_nonce = [0u8; STREAM_NONCE_LEN];
1150 OsRng.fill_bytes(&mut salt);
1151 OsRng.fill_bytes(&mut stream_nonce);
1152
1153 reporter.report(0.05, "Deriving encryption key (Argon2id)...");
1154 let key = derive_key_v4(password, &salt)?;
1156
1157 let cipher = Aes256GcmSiv::new(key.as_slice().into());
1158 let mut encryptor = EncryptorBE32::from_aead(cipher, (&stream_nonce).into());
1159
1160 let aad = build_aad_v4(&salt, &stream_nonce);
1162
1163 writer.write_all(MAGIC_V4)?;
1165 writer.write_all(&salt)?;
1166 writer.write_all(&stream_nonce)?;
1167
1168 let mut buf = Zeroizing::new(vec![0u8; CHUNK_SIZE]);
1169 let mut bytes_done: u64 = 0;
1170 let mut chunk_counter: u64 = 0;
1171 const IO_GOVERNOR_INTERVAL: u64 = 100;
1172
1173 loop {
1174 let n = read_exact_or_eof(reader, buf.as_mut_slice())?;
1175 if n == 0 {
1176 let ct = encryptor
1177 .encrypt_last(Payload {
1178 msg: &[][..],
1179 aad: &aad,
1180 })
1181 .map_err(|e| CryptoError::EncryptionFailed(e.to_string()))?;
1182 writer.write_all(&ct)?;
1183 break;
1184 }
1185
1186 bytes_done += n as u64;
1187 let frac = if let Some(total) = source_size {
1188 if total > 0 {
1189 0.10 + 0.85 * (bytes_done as f32 / total as f32)
1190 } else {
1191 0.95
1192 }
1193 } else {
1194 0.50
1195 };
1196
1197 let mut peek = [0u8; 1];
1198 let peeked = reader.read(&mut peek)?;
1199
1200 if peeked == 0 {
1201 reporter.report(frac, "Encrypting final chunk...");
1202 let ct = encryptor
1203 .encrypt_last(Payload {
1204 msg: &buf[..n],
1205 aad: &aad,
1206 })
1207 .map_err(|e| CryptoError::EncryptionFailed(e.to_string()))?;
1208 writer.write_all(&ct)?;
1209 break;
1210 } else {
1211 reporter.report(frac, "Encrypting...");
1212 let ct = encryptor
1213 .encrypt_next(Payload {
1214 msg: &buf[..n],
1215 aad: &aad,
1216 })
1217 .map_err(|e| CryptoError::EncryptionFailed(e.to_string()))?;
1218 writer.write_all(&ct)?;
1219 chunk_counter += 1;
1220
1221 if chunk_counter >= u32::MAX as u64 {
1225 return Err(CryptoError::FileTooLarge {
1226 size_gb: (chunk_counter * CHUNK_SIZE as u64) as f64 / 1_000_000_000.0,
1227 max_gb: MAX_V4_FILE_SIZE as f64 / 1_000_000_000.0,
1228 });
1229 }
1230
1231 if chunk_counter.is_multiple_of(IO_GOVERNOR_INTERVAL) {
1232 writer.flush()?;
1233 std::thread::sleep(Duration::from_millis(5));
1234 }
1235
1236 reader.seek(std::io::SeekFrom::Current(-1))?;
1237 }
1238 }
1239
1240 writer.flush()?;
1241 reporter.report(1.00, "Encryption complete.");
1242 Ok(())
1243}
1244
1245pub fn decrypt_stream<R: Read, W: Write>(
1248 reader: &mut R,
1249 writer: &mut W,
1250 password: &[u8],
1251 total_size: Option<u64>,
1252 reporter: &dyn ProgressReporter,
1253) -> CryptoResult<()> {
1254 if password.len() < MIN_PASSWORD_LEN {
1255 return Err(CryptoError::PassphraseTooShort(MIN_PASSWORD_LEN));
1256 }
1257
1258 let mut magic = [0u8; 8];
1259 reader
1260 .read_exact(&mut magic)
1261 .map_err(|error| match error.kind() {
1262 std::io::ErrorKind::UnexpectedEof => CryptoError::FileTooSmall,
1263 _ => CryptoError::Io(error),
1264 })?;
1265
1266 if &magic == MAGIC_V4 {
1267 decrypt_stream_v4(reader, writer, password, total_size, reporter)
1269 } else if &magic == MAGIC_V3 {
1270 decrypt_stream_v3(reader, writer, password, total_size, reporter)
1271 } else if &magic == MAGIC {
1272 decrypt_stream_v2(reader, writer, password, reporter)
1273 } else {
1274 Err(CryptoError::InvalidMagic)
1275 }
1276}
1277
1278fn decrypt_stream_v3<R: Read, W: Write>(
1279 reader: &mut R,
1280 writer: &mut W,
1281 password: &[u8],
1282 total_size: Option<u64>,
1283 reporter: &dyn ProgressReporter,
1284) -> CryptoResult<()> {
1285 let mut salt = [0u8; SALT_V3_LEN];
1286 let mut stream_nonce = [0u8; STREAM_NONCE_LEN];
1287 reader.read_exact(&mut salt)?;
1288 reader.read_exact(&mut stream_nonce)?;
1289
1290 reporter.report(0.05, "Deriving decryption key (Argon2id)...");
1291 let key = derive_key_v3(password, &salt)?;
1292
1293 let cipher = Aes256GcmSiv::new(key.as_slice().into());
1294 let mut decryptor = DecryptorBE32::from_aead(cipher, (&stream_nonce).into());
1295
1296 let mut buf = Zeroizing::new(vec![0u8; CHUNK_SIZE + TAG_LEN]);
1297 let mut bytes_done: u64 = 0;
1298 let mut last_chunk: Option<Zeroizing<Vec<u8>>> = None;
1299
1300 loop {
1301 let n = read_exact_or_eof(reader, &mut buf)?;
1302 if n == 0 {
1303 break;
1304 }
1305
1306 if let Some(prev) = last_chunk.take() {
1307 let frac = if let Some(total) = total_size {
1308 if total > 0 {
1309 0.10 + 0.85 * (bytes_done as f32 / total as f32)
1310 } else {
1311 0.50
1312 }
1313 } else {
1314 0.50
1315 };
1316 reporter.report(frac, "Decrypting...");
1317 let pt = Zeroizing::new(
1318 decryptor
1319 .decrypt_next(prev.as_slice())
1320 .map_err(|_| CryptoError::DecryptionFailed)?,
1321 );
1322 writer.write_all(pt.as_slice())?;
1323 }
1324
1325 bytes_done += n as u64;
1326 last_chunk = Some(Zeroizing::new(buf[..n].to_vec()));
1327 }
1328
1329 if let Some(final_data) = last_chunk {
1330 reporter.report(0.95, "Decrypting final chunk...");
1331 let pt = Zeroizing::new(
1332 decryptor
1333 .decrypt_last(final_data.as_slice())
1334 .map_err(|_| CryptoError::DecryptionFailed)?,
1335 );
1336 writer.write_all(pt.as_slice())?;
1337 } else {
1338 return Err(CryptoError::FileTooSmall);
1339 }
1340
1341 writer.flush()?;
1342 reporter.report(1.00, "Decryption complete.");
1343 Ok(())
1344}
1345
1346fn decrypt_stream_v4<R: Read, W: Write>(
1348 reader: &mut R,
1349 writer: &mut W,
1350 password: &[u8],
1351 total_size: Option<u64>,
1352 reporter: &dyn ProgressReporter,
1353) -> CryptoResult<()> {
1354 let mut salt = [0u8; SALT_V3_LEN];
1355 let mut stream_nonce = [0u8; STREAM_NONCE_LEN];
1356 reader.read_exact(&mut salt)?;
1357 reader.read_exact(&mut stream_nonce)?;
1358
1359 reporter.report(0.05, "Deriving decryption key (Argon2id)...");
1360 let key = derive_key_v4(password, &salt)?;
1361
1362 let cipher = Aes256GcmSiv::new(key.as_slice().into());
1363 let mut decryptor = DecryptorBE32::from_aead(cipher, (&stream_nonce).into());
1364
1365 let aad = build_aad_v4(&salt, &stream_nonce);
1367
1368 let mut buf = Zeroizing::new(vec![0u8; CHUNK_SIZE + TAG_LEN]);
1369 let mut bytes_done: u64 = 0;
1370 let mut last_chunk: Option<Zeroizing<Vec<u8>>> = None;
1371
1372 loop {
1373 let n = read_exact_or_eof(reader, &mut buf)?;
1374 if n == 0 {
1375 break;
1376 }
1377
1378 if let Some(prev) = last_chunk.take() {
1379 let frac = if let Some(total) = total_size {
1380 if total > 0 {
1381 0.10 + 0.85 * (bytes_done as f32 / total as f32)
1382 } else {
1383 0.50
1384 }
1385 } else {
1386 0.50
1387 };
1388 reporter.report(frac, "Decrypting...");
1389 let pt = Zeroizing::new(
1390 decryptor
1391 .decrypt_next(Payload {
1392 msg: prev.as_slice(),
1393 aad: &aad,
1394 })
1395 .map_err(|_| CryptoError::DecryptionFailed)?,
1396 );
1397 writer.write_all(pt.as_slice())?;
1398 }
1399
1400 bytes_done += n as u64;
1401 last_chunk = Some(Zeroizing::new(buf[..n].to_vec()));
1402 }
1403
1404 if let Some(final_data) = last_chunk {
1405 reporter.report(0.95, "Decrypting final chunk...");
1406 let pt = Zeroizing::new(
1407 decryptor
1408 .decrypt_last(Payload {
1409 msg: final_data.as_slice(),
1410 aad: &aad,
1411 })
1412 .map_err(|_| CryptoError::DecryptionFailed)?,
1413 );
1414 writer.write_all(pt.as_slice())?;
1415 } else {
1416 return Err(CryptoError::FileTooSmall);
1417 }
1418
1419 writer.flush()?;
1420 reporter.report(1.00, "Decryption complete.");
1421 Ok(())
1422}
1423
1424fn decrypt_stream_v2<R: Read, W: Write>(
1425 reader: &mut R,
1426 writer: &mut W,
1427 password: &[u8],
1428 reporter: &dyn ProgressReporter,
1429) -> CryptoResult<()> {
1430 eprintln!("[INFO] VAULTX02 (V2) format detected. Consider re-encrypting with V3 for streaming support and no memory limits.");
1431 let mut salt = [0u8; SALT_LEN];
1432 let mut nonce = [0u8; NONCE_LEN];
1433 reader.read_exact(&mut salt)?;
1434 reader.read_exact(&mut nonce)?;
1435
1436 reporter.report(0.05, "Deriving decryption key (Argon2id)...");
1437 let key = derive_key(password, &salt)?;
1438
1439 let cipher = Aes256GcmSiv::new(key.as_slice().into());
1440 const V2_STREAM_MAX_BYTES: u64 = 1_073_741_824;
1441 let mut ciphertext = Vec::new();
1442 reader
1443 .take(V2_STREAM_MAX_BYTES + 1)
1444 .read_to_end(&mut ciphertext)?;
1445 if ciphertext.len() as u64 > V2_STREAM_MAX_BYTES {
1446 return Err(CryptoError::LegacyFileTooLarge);
1447 }
1448
1449 let aad = build_aad(&salt, &nonce);
1450 reporter.report(0.50, "Decrypting data (AES-256-GCM-SIV)...");
1451 let plaintext = Zeroizing::new(
1452 cipher
1453 .decrypt(
1454 Nonce::from_slice(&nonce),
1455 Payload {
1456 msg: &ciphertext,
1457 aad: &aad,
1458 },
1459 )
1460 .map_err(|_| CryptoError::DecryptionFailed)?,
1461 );
1462 writer.write_all(plaintext.as_slice())?;
1463
1464 writer.flush()?;
1465 reporter.report(1.00, "Decryption complete.");
1466 Ok(())
1467}
1468
1469#[cfg(test)]
1470mod tests {
1471 use super::*;
1472
1473 struct TestReporter;
1474
1475 impl ProgressReporter for TestReporter {
1476 fn report(&self, _progress: f32, _message: &str) {}
1477 }
1478
1479 fn unique_test_dir() -> PathBuf {
1480 std::env::temp_dir().join(format!(
1481 "neuron_test_{}",
1482 rand_core::RngCore::next_u64(&mut rand_core::OsRng)
1483 ))
1484 }
1485
1486 #[test]
1487 fn test_encrypt_decrypt_bytes() {
1488 let plaintext = b"Hello, security audit!";
1489 let password = b"supersecretpassword";
1490
1491 let (ciphertext, salt, nonce) = encrypt_bytes(plaintext, password).unwrap();
1492 let decrypted = decrypt_bytes(&ciphertext, password, &salt, &nonce).unwrap();
1493
1494 assert_eq!(plaintext, decrypted.as_slice());
1495 }
1496
1497 #[test]
1498 fn test_encrypt_decrypt_file() {
1499 let tmp_dir = unique_test_dir();
1500 fs::create_dir_all(&tmp_dir).unwrap();
1501
1502 let src_path = tmp_dir.join("src.txt");
1503 let encrypted_path = tmp_dir.join("dest.vx2");
1504 let decrypted_path = tmp_dir.join("final.txt");
1505 let password = "x".repeat(MIN_PASSWORD_LEN + 4);
1506 let reporter = TestReporter;
1507
1508 fs::write(&src_path, b"File content for testing").unwrap();
1509
1510 encrypt_file(
1511 &src_path,
1512 &encrypted_path,
1513 false,
1514 password.as_bytes(),
1515 &reporter,
1516 None,
1517 )
1518 .unwrap();
1519 assert!(encrypted_path.exists());
1520
1521 decrypt_file(
1522 &encrypted_path,
1523 &decrypted_path,
1524 false,
1525 password.as_bytes(),
1526 &reporter,
1527 None,
1528 )
1529 .unwrap();
1530 assert!(decrypted_path.exists());
1531
1532 let final_content = fs::read(decrypted_path).unwrap();
1533 assert_eq!(final_content, b"File content for testing");
1534
1535 let _ = fs::remove_dir_all(tmp_dir);
1536 }
1537
1538 #[test]
1539 fn test_password_too_short() {
1540 let plaintext = b"some data";
1541 let password = b"short";
1542 let result = encrypt_bytes(plaintext, password);
1543 assert!(matches!(result, Err(CryptoError::PassphraseTooShort(_))));
1544 }
1545
1546 #[test]
1547 fn test_invalid_magic() {
1548 let mut raw = vec![0u8; 100];
1549 raw[0..8].copy_from_slice(b"NOTMAGIC");
1550 let result = parse_header(&raw);
1551 assert!(matches!(result, Err(CryptoError::InvalidMagic)));
1552 }
1553
1554 #[test]
1555 fn test_default_output_names_strip_one_vx2_suffix() {
1556 assert_eq!(
1557 default_encrypt_output_name(Path::new("report.pdf")),
1558 "report.pdf.vx2"
1559 );
1560 assert_eq!(
1561 default_decrypt_output_name(Path::new("report.pdf.vx2")),
1562 "report.pdf"
1563 );
1564 assert_eq!(
1565 default_decrypt_output_name(Path::new("archive.vx2.vx2")),
1566 "archive.vx2"
1567 );
1568 assert_eq!(default_decrypt_output_name(Path::new(".vx2")), "decrypted");
1569 }
1570
1571 #[test]
1572 fn test_decrypt_bytes_rejects_invalid_nonce_length() {
1573 let result = decrypt_bytes(
1574 b"ciphertext",
1575 b"supersecretpassword",
1576 &[1u8; SALT_LEN],
1577 &[0u8; 8],
1578 );
1579 assert!(matches!(
1580 result,
1581 Err(CryptoError::InvalidNonceLength {
1582 expected: NONCE_LEN,
1583 actual: 8
1584 })
1585 ));
1586 }
1587
1588 #[test]
1589 fn test_decrypt_bytes_rejects_short_password() {
1590 let result = decrypt_bytes(b"ciphertext", b"short", &[1u8; SALT_LEN], &[0u8; NONCE_LEN]);
1591 assert!(matches!(result, Err(CryptoError::PassphraseTooShort(_))));
1592 }
1593
1594 #[test]
1595 fn test_encrypt_file_rejects_existing_destination() {
1596 let tmp_dir = unique_test_dir();
1597 fs::create_dir_all(&tmp_dir).unwrap();
1598
1599 let src_path = tmp_dir.join("src.txt");
1600 let dest_path = tmp_dir.join("dest.vx2");
1601 let password = "x".repeat(MIN_PASSWORD_LEN + 4);
1602 let reporter = TestReporter;
1603
1604 fs::write(&src_path, b"secret").unwrap();
1605 fs::write(&dest_path, b"already here").unwrap();
1606
1607 let result = encrypt_file(
1608 &src_path,
1609 &dest_path,
1610 false,
1611 password.as_bytes(),
1612 &reporter,
1613 None,
1614 );
1615 assert!(matches!(result, Err(CryptoError::FileAlreadyExists(_))));
1616
1617 let _ = fs::remove_dir_all(tmp_dir);
1618 }
1619
1620 #[test]
1621 fn test_encrypt_file_overwrites_existing_destination_with_force() {
1622 let tmp_dir = unique_test_dir();
1623 fs::create_dir_all(&tmp_dir).unwrap();
1624
1625 let src_path = tmp_dir.join("src.txt");
1626 let dest_path = tmp_dir.join("dest.vx2");
1627 let password = "x".repeat(MIN_PASSWORD_LEN + 4);
1628 let reporter = TestReporter;
1629
1630 fs::write(&src_path, b"secret").unwrap();
1631 fs::write(&dest_path, b"already here").unwrap();
1632
1633 let result = encrypt_file(
1634 &src_path,
1635 &dest_path,
1636 true,
1637 password.as_bytes(),
1638 &reporter,
1639 None,
1640 );
1641 assert!(result.is_ok());
1642 assert!(dest_path.exists());
1643 assert_ne!(fs::read(&dest_path).unwrap(), b"already here");
1644
1645 let _ = fs::remove_dir_all(tmp_dir);
1646 }
1647
1648 #[test]
1649 fn test_encrypt_file_rejects_same_source_and_destination() {
1650 let tmp_dir = unique_test_dir();
1651 fs::create_dir_all(&tmp_dir).unwrap();
1652
1653 let src_path = tmp_dir.join("src.txt");
1654 let password = "x".repeat(MIN_PASSWORD_LEN + 4);
1655 let reporter = TestReporter;
1656
1657 fs::write(&src_path, b"secret").unwrap();
1658
1659 let result = encrypt_file(
1660 &src_path,
1661 &src_path,
1662 false,
1663 password.as_bytes(),
1664 &reporter,
1665 None,
1666 );
1667 assert!(matches!(
1668 result,
1669 Err(CryptoError::SourceAndDestinationSame(_))
1670 ));
1671
1672 let _ = fs::remove_dir_all(tmp_dir);
1673 }
1674
1675 #[test]
1676 fn test_v3_stream_roundtrip_multi_chunk() {
1677 let tmp_dir = unique_test_dir();
1678 fs::create_dir_all(&tmp_dir).unwrap();
1679
1680 let src_path = tmp_dir.join("big.bin");
1681 let encrypted_path = tmp_dir.join("big.vx2");
1682 let decrypted_path = tmp_dir.join("big_dec.bin");
1683 let password = "x".repeat(MIN_PASSWORD_LEN + 4);
1684 let reporter = TestReporter;
1685
1686 let data: Vec<u8> = (0u8..=255)
1688 .cycle()
1689 .take(CHUNK_SIZE * 2 + CHUNK_SIZE / 2)
1690 .collect();
1691 fs::write(&src_path, &data).unwrap();
1692
1693 encrypt_file(
1694 &src_path,
1695 &encrypted_path,
1696 false,
1697 password.as_bytes(),
1698 &reporter,
1699 None,
1700 )
1701 .unwrap();
1702
1703 let header = fs::read(&encrypted_path).unwrap();
1705 assert_eq!(&header[..8], MAGIC_V4);
1706
1707 decrypt_file(
1708 &encrypted_path,
1709 &decrypted_path,
1710 false,
1711 password.as_bytes(),
1712 &reporter,
1713 None,
1714 )
1715 .unwrap();
1716 let result = fs::read(&decrypted_path).unwrap();
1717 assert_eq!(result, data);
1718
1719 let _ = fs::remove_dir_all(tmp_dir);
1720 }
1721
1722 #[test]
1723 fn test_v3_stream_roundtrip_exact_chunk() {
1724 let tmp_dir = unique_test_dir();
1725 fs::create_dir_all(&tmp_dir).unwrap();
1726
1727 let src_path = tmp_dir.join("exact.bin");
1728 let encrypted_path = tmp_dir.join("exact.vx2");
1729 let decrypted_path = tmp_dir.join("exact_dec.bin");
1730 let password = "x".repeat(MIN_PASSWORD_LEN + 4);
1731 let reporter = TestReporter;
1732
1733 let data = vec![42u8; CHUNK_SIZE];
1735 fs::write(&src_path, &data).unwrap();
1736
1737 encrypt_file(
1738 &src_path,
1739 &encrypted_path,
1740 false,
1741 password.as_bytes(),
1742 &reporter,
1743 None,
1744 )
1745 .unwrap();
1746 decrypt_file(
1747 &encrypted_path,
1748 &decrypted_path,
1749 false,
1750 password.as_bytes(),
1751 &reporter,
1752 None,
1753 )
1754 .unwrap();
1755 assert_eq!(fs::read(&decrypted_path).unwrap(), data);
1756
1757 let _ = fs::remove_dir_all(tmp_dir);
1758 }
1759
1760 #[test]
1761 fn test_v3_wrong_password() {
1762 let tmp_dir = unique_test_dir();
1763 fs::create_dir_all(&tmp_dir).unwrap();
1764
1765 let src_path = tmp_dir.join("secret.txt");
1766 let encrypted_path = tmp_dir.join("secret.vx2");
1767 let decrypted_path = tmp_dir.join("secret_dec.txt");
1768 let password = "x".repeat(MIN_PASSWORD_LEN + 4);
1769 let wrong = "y".repeat(MIN_PASSWORD_LEN + 4);
1770 let reporter = TestReporter;
1771
1772 fs::write(&src_path, b"top secret data").unwrap();
1773 encrypt_file(
1774 &src_path,
1775 &encrypted_path,
1776 false,
1777 password.as_bytes(),
1778 &reporter,
1779 None,
1780 )
1781 .unwrap();
1782
1783 let result = decrypt_file(
1784 &encrypted_path,
1785 &decrypted_path,
1786 false,
1787 wrong.as_bytes(),
1788 &reporter,
1789 None,
1790 );
1791 assert!(result.is_err());
1792
1793 let _ = fs::remove_dir_all(tmp_dir);
1794 }
1795
1796 #[test]
1797 fn test_v3_stream_decrypt_rejects_header_only_input() {
1798 let password = "x".repeat(MIN_PASSWORD_LEN + 4);
1799 let reporter = TestReporter;
1800 let mut input = Vec::new();
1801 input.extend_from_slice(MAGIC_V3);
1802 input.extend_from_slice(&[0u8; SALT_V3_LEN]);
1803 input.extend_from_slice(&[0u8; STREAM_NONCE_LEN]);
1804
1805 let mut reader = std::io::Cursor::new(input);
1806 let mut output = Vec::new();
1807 let result = decrypt_stream(
1808 &mut reader,
1809 &mut output,
1810 password.as_bytes(),
1811 None,
1812 &reporter,
1813 );
1814
1815 assert!(matches!(result, Err(CryptoError::FileTooSmall)));
1816 assert!(output.is_empty());
1817 }
1818
1819 #[test]
1820 fn test_v2_backward_compat() {
1821 let tmp_dir = unique_test_dir();
1823 fs::create_dir_all(&tmp_dir).unwrap();
1824
1825 let plaintext = b"Legacy V2 content";
1826 let password = "x".repeat(MIN_PASSWORD_LEN + 4);
1827 let reporter = TestReporter;
1828
1829 let (ciphertext, salt, nonce) = encrypt_bytes(plaintext, password.as_bytes()).unwrap();
1830
1831 let v2_path = tmp_dir.join("legacy.vx2");
1832 {
1833 let mut f = fs::File::create(&v2_path).unwrap();
1834 f.write_all(MAGIC).unwrap();
1835 f.write_all(&salt).unwrap();
1836 f.write_all(&nonce).unwrap();
1837 f.write_all(&ciphertext).unwrap();
1838 f.sync_all().unwrap();
1839 }
1840
1841 let dec_path = tmp_dir.join("legacy_dec.txt");
1842 decrypt_file(
1843 &v2_path,
1844 &dec_path,
1845 false,
1846 password.as_bytes(),
1847 &reporter,
1848 None,
1849 )
1850 .unwrap();
1851 assert_eq!(fs::read(&dec_path).unwrap(), plaintext);
1852
1853 let _ = fs::remove_dir_all(tmp_dir);
1854 }
1855
1856 #[test]
1859 fn test_v3_empty_file_roundtrip() {
1860 let tmp_dir = unique_test_dir();
1863 fs::create_dir_all(&tmp_dir).unwrap();
1864 let src = tmp_dir.join("empty.bin");
1865 let enc = tmp_dir.join("empty.vx2");
1866 let dec = tmp_dir.join("empty_dec.bin");
1867 let password = "x".repeat(MIN_PASSWORD_LEN + 4);
1868 let reporter = TestReporter;
1869
1870 fs::write(&src, b"").unwrap();
1871 encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
1872 assert_eq!(fs::metadata(&enc).unwrap().len(), 47);
1873 decrypt_file(&enc, &dec, false, password.as_bytes(), &reporter, None).unwrap();
1874 assert_eq!(fs::read(&dec).unwrap(), b"");
1875
1876 let _ = fs::remove_dir_all(tmp_dir);
1877 }
1878
1879 #[test]
1880 fn test_v3_single_byte_roundtrip() {
1881 let tmp_dir = unique_test_dir();
1882 fs::create_dir_all(&tmp_dir).unwrap();
1883 let src = tmp_dir.join("one.bin");
1884 let enc = tmp_dir.join("one.vx2");
1885 let dec = tmp_dir.join("one_dec.bin");
1886 let password = "x".repeat(MIN_PASSWORD_LEN + 4);
1887 let reporter = TestReporter;
1888
1889 fs::write(&src, b"A").unwrap();
1890 encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
1891 decrypt_file(&enc, &dec, false, password.as_bytes(), &reporter, None).unwrap();
1892 assert_eq!(fs::read(&dec).unwrap(), b"A");
1893
1894 let _ = fs::remove_dir_all(tmp_dir);
1895 }
1896
1897 #[test]
1898 fn test_v3_truncated_at_chunk_boundary_fails() {
1899 let tmp_dir = unique_test_dir();
1902 fs::create_dir_all(&tmp_dir).unwrap();
1903 let src = tmp_dir.join("big.bin");
1904 let enc = tmp_dir.join("big.vx2");
1905 let truncated = tmp_dir.join("trunc.vx2");
1906 let password = "x".repeat(MIN_PASSWORD_LEN + 4);
1907 let reporter = TestReporter;
1908
1909 let data: Vec<u8> = (0u8..=255).cycle().take(CHUNK_SIZE * 2 + 100).collect();
1910 fs::write(&src, &data).unwrap();
1911 encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
1912
1913 let full = fs::read(&enc).unwrap();
1915 let cut_len = HEADER_V3_LEN + CHUNK_SIZE + TAG_LEN;
1916 assert!(full.len() > cut_len);
1917 fs::write(&truncated, &full[..cut_len]).unwrap();
1918
1919 let dec = tmp_dir.join("dec.bin");
1920 let result = decrypt_file(
1921 &truncated,
1922 &dec,
1923 false,
1924 password.as_bytes(),
1925 &reporter,
1926 None,
1927 );
1928 assert!(matches!(result, Err(CryptoError::DecryptionFailed)));
1929
1930 let _ = fs::remove_dir_all(tmp_dir);
1931 }
1932
1933 #[test]
1934 fn test_v3_tampered_salt_fails() {
1935 let tmp_dir = unique_test_dir();
1936 fs::create_dir_all(&tmp_dir).unwrap();
1937 let src = tmp_dir.join("plain.txt");
1938 let enc = tmp_dir.join("plain.vx2");
1939 let password = "x".repeat(MIN_PASSWORD_LEN + 4);
1940 let reporter = TestReporter;
1941
1942 fs::write(&src, b"sensitive data").unwrap();
1943 encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
1944
1945 let mut bytes = fs::read(&enc).unwrap();
1947 bytes[8] ^= 0x01;
1948 let tampered = tmp_dir.join("tampered.vx2");
1949 fs::write(&tampered, &bytes).unwrap();
1950
1951 let dec = tmp_dir.join("dec.txt");
1952 let result = decrypt_file(&tampered, &dec, false, password.as_bytes(), &reporter, None);
1953 assert!(matches!(result, Err(CryptoError::DecryptionFailed)));
1954
1955 let _ = fs::remove_dir_all(tmp_dir);
1956 }
1957
1958 #[test]
1959 fn test_v3_tampered_nonce_fails() {
1960 let tmp_dir = unique_test_dir();
1961 fs::create_dir_all(&tmp_dir).unwrap();
1962 let src = tmp_dir.join("plain.txt");
1963 let enc = tmp_dir.join("plain.vx2");
1964 let password = "x".repeat(MIN_PASSWORD_LEN + 4);
1965 let reporter = TestReporter;
1966
1967 fs::write(&src, b"sensitive data").unwrap();
1968 encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
1969
1970 let mut bytes = fs::read(&enc).unwrap();
1972 bytes[24] ^= 0x80;
1973 let tampered = tmp_dir.join("tampered.vx2");
1974 fs::write(&tampered, &bytes).unwrap();
1975
1976 let dec = tmp_dir.join("dec.txt");
1977 let result = decrypt_file(&tampered, &dec, false, password.as_bytes(), &reporter, None);
1978 assert!(matches!(result, Err(CryptoError::DecryptionFailed)));
1979
1980 let _ = fs::remove_dir_all(tmp_dir);
1981 }
1982
1983 #[test]
1984 fn test_v3_tampered_chunk_byte_fails() {
1985 let tmp_dir = unique_test_dir();
1986 fs::create_dir_all(&tmp_dir).unwrap();
1987 let src = tmp_dir.join("plain.txt");
1988 let enc = tmp_dir.join("plain.vx2");
1989 let password = "x".repeat(MIN_PASSWORD_LEN + 4);
1990 let reporter = TestReporter;
1991
1992 fs::write(&src, b"sensitive data here").unwrap();
1993 encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
1994
1995 let mut bytes = fs::read(&enc).unwrap();
1997 let body_start = HEADER_V3_LEN;
1998 assert!(bytes.len() > body_start + 4);
1999 bytes[body_start + 4] ^= 0x10;
2000 let tampered = tmp_dir.join("tampered.vx2");
2001 fs::write(&tampered, &bytes).unwrap();
2002
2003 let dec = tmp_dir.join("dec.txt");
2004 let result = decrypt_file(&tampered, &dec, false, password.as_bytes(), &reporter, None);
2005 assert!(matches!(result, Err(CryptoError::DecryptionFailed)));
2006
2007 let _ = fs::remove_dir_all(tmp_dir);
2008 }
2009
2010 #[test]
2011 fn test_v4_magic_swap_v4_to_v2_fails() {
2012 let tmp_dir = unique_test_dir();
2015 fs::create_dir_all(&tmp_dir).unwrap();
2016 let src = tmp_dir.join("plain.txt");
2017 let enc = tmp_dir.join("plain.vx2");
2018 let password = "x".repeat(MIN_PASSWORD_LEN + 4);
2019 let reporter = TestReporter;
2020
2021 fs::write(&src, b"sensitive data").unwrap();
2022 encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
2023
2024 let mut bytes = fs::read(&enc).unwrap();
2025 bytes[..8].copy_from_slice(MAGIC); let swapped = tmp_dir.join("swapped.vx2");
2027 fs::write(&swapped, &bytes).unwrap();
2028
2029 let dec = tmp_dir.join("dec.txt");
2030 let result = decrypt_file(&swapped, &dec, false, password.as_bytes(), &reporter, None);
2031 assert!(result.is_err());
2032
2033 let _ = fs::remove_dir_all(tmp_dir);
2034 }
2035
2036 #[test]
2037 fn test_v3_reordered_chunks_fail() {
2038 let tmp_dir = unique_test_dir();
2040 fs::create_dir_all(&tmp_dir).unwrap();
2041 let src = tmp_dir.join("big.bin");
2042 let enc = tmp_dir.join("big.vx2");
2043 let password = "x".repeat(MIN_PASSWORD_LEN + 4);
2044 let reporter = TestReporter;
2045
2046 let data: Vec<u8> = (0u8..=255)
2047 .cycle()
2048 .take(CHUNK_SIZE * 2 + CHUNK_SIZE / 2)
2049 .collect();
2050 fs::write(&src, &data).unwrap();
2051 encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
2052
2053 let mut bytes = fs::read(&enc).unwrap();
2054 let body = &mut bytes[HEADER_V3_LEN..];
2055 let chunk_stride = CHUNK_SIZE + TAG_LEN;
2056 assert!(body.len() >= 2 * chunk_stride);
2057 let (first_half, _second_half) = body.split_at_mut(2 * chunk_stride);
2059 let (chunk0, chunk1) = first_half.split_at_mut(chunk_stride);
2060 for i in 0..chunk_stride {
2061 std::mem::swap(&mut chunk0[i], &mut chunk1[i]);
2062 }
2063
2064 let reordered = tmp_dir.join("reordered.vx2");
2065 fs::write(&reordered, &bytes).unwrap();
2066
2067 let dec = tmp_dir.join("dec.bin");
2068 let result = decrypt_file(
2069 &reordered,
2070 &dec,
2071 false,
2072 password.as_bytes(),
2073 &reporter,
2074 None,
2075 );
2076 assert!(matches!(result, Err(CryptoError::DecryptionFailed)));
2077
2078 let _ = fs::remove_dir_all(tmp_dir);
2079 }
2080
2081 #[test]
2082 fn test_v3_extended_with_garbage_fails() {
2083 let tmp_dir = unique_test_dir();
2086 fs::create_dir_all(&tmp_dir).unwrap();
2087 let src = tmp_dir.join("plain.txt");
2088 let enc = tmp_dir.join("plain.vx2");
2089 let password = "x".repeat(MIN_PASSWORD_LEN + 4);
2090 let reporter = TestReporter;
2091
2092 fs::write(&src, b"sensitive data here").unwrap();
2093 encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
2094
2095 let mut bytes = fs::read(&enc).unwrap();
2096 bytes.extend_from_slice(&[0xABu8; 64]);
2097 let extended = tmp_dir.join("extended.vx2");
2098 fs::write(&extended, &bytes).unwrap();
2099
2100 let dec = tmp_dir.join("dec.txt");
2101 let result = decrypt_file(&extended, &dec, false, password.as_bytes(), &reporter, None);
2102 assert!(matches!(result, Err(CryptoError::DecryptionFailed)));
2103
2104 let _ = fs::remove_dir_all(tmp_dir);
2105 }
2106
2107 #[test]
2108 fn test_v3_password_length_boundary() {
2109 let tmp_dir = unique_test_dir();
2111 fs::create_dir_all(&tmp_dir).unwrap();
2112 let src = tmp_dir.join("plain.txt");
2113 let enc = tmp_dir.join("plain.vx2");
2114 let reporter = TestReporter;
2115
2116 fs::write(&src, b"data").unwrap();
2117
2118 let r1 = encrypt_file(&src, &enc, false, b"1234567", &reporter, None);
2119 assert!(matches!(r1, Err(CryptoError::PassphraseTooShort(_))));
2120
2121 let r2 = encrypt_file(&src, &enc, false, b"12345678", &reporter, None);
2122 assert!(r2.is_ok());
2123
2124 let _ = fs::remove_dir_all(tmp_dir);
2125 }
2126
2127 #[test]
2128 fn test_v3_chunk_minus_one_roundtrip() {
2129 let tmp_dir = unique_test_dir();
2131 fs::create_dir_all(&tmp_dir).unwrap();
2132 let src = tmp_dir.join("under.bin");
2133 let enc = tmp_dir.join("under.vx2");
2134 let dec = tmp_dir.join("under_dec.bin");
2135 let password = "x".repeat(MIN_PASSWORD_LEN + 4);
2136 let reporter = TestReporter;
2137
2138 let data: Vec<u8> = (0u8..=255).cycle().take(CHUNK_SIZE - 1).collect();
2139 fs::write(&src, &data).unwrap();
2140 encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
2141 decrypt_file(&enc, &dec, false, password.as_bytes(), &reporter, None).unwrap();
2142 assert_eq!(fs::read(&dec).unwrap(), data);
2143
2144 let _ = fs::remove_dir_all(tmp_dir);
2145 }
2146
2147 #[test]
2148 fn test_v3_chunk_plus_one_roundtrip() {
2149 let tmp_dir = unique_test_dir();
2151 fs::create_dir_all(&tmp_dir).unwrap();
2152 let src = tmp_dir.join("over.bin");
2153 let enc = tmp_dir.join("over.vx2");
2154 let dec = tmp_dir.join("over_dec.bin");
2155 let password = "x".repeat(MIN_PASSWORD_LEN + 4);
2156 let reporter = TestReporter;
2157
2158 let data: Vec<u8> = (0u8..=255).cycle().take(CHUNK_SIZE + 1).collect();
2159 fs::write(&src, &data).unwrap();
2160 encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
2161 decrypt_file(&enc, &dec, false, password.as_bytes(), &reporter, None).unwrap();
2162 assert_eq!(fs::read(&dec).unwrap(), data);
2163
2164 let _ = fs::remove_dir_all(tmp_dir);
2165 }
2166
2167 #[test]
2168 fn test_v3_stream_decrypt_rejects_v2_magic_via_stream() {
2169 let password = "x".repeat(MIN_PASSWORD_LEN + 4);
2172 let reporter = TestReporter;
2173
2174 let mut input = Vec::new();
2176 input.extend_from_slice(MAGIC);
2177 input.extend_from_slice(&[0u8; SALT_LEN]);
2178 input.extend_from_slice(&[0u8; NONCE_LEN]);
2179 input.extend_from_slice(&[0u8; TAG_LEN + 16]); let mut reader = std::io::Cursor::new(input);
2182 let mut output = Vec::new();
2183 let result = decrypt_stream(
2184 &mut reader,
2185 &mut output,
2186 password.as_bytes(),
2187 None,
2188 &reporter,
2189 );
2190 assert!(matches!(result, Err(CryptoError::DecryptionFailed)));
2191 }
2192
2193 #[test]
2194 fn test_v3_rejects_oversize_file_via_constant() {
2195 assert_eq!(MAX_V3_FILE_SIZE, (1u64 << 32) * CHUNK_SIZE as u64);
2197 assert_eq!(MAX_V3_FILE_SIZE, 1u64 << 52);
2199 }
2200
2201 #[test]
2204 fn test_v4_constants_sanity() {
2205 assert_eq!(HEADER_V4_LEN, HEADER_V3_LEN);
2207 assert_eq!(MAX_V4_FILE_SIZE, MAX_V3_FILE_SIZE);
2208 assert_eq!(MAGIC_V4, b"VAULTX04");
2209 assert_ne!(MAGIC_V3, MAGIC_V4);
2211 }
2212
2213 #[test]
2214 fn test_v4_encrypt_produces_v4_magic() {
2215 let tmp_dir = unique_test_dir();
2217 fs::create_dir_all(&tmp_dir).unwrap();
2218 let src = tmp_dir.join("plain.txt");
2219 let enc = tmp_dir.join("plain.vx2");
2220 let password = "x".repeat(MIN_PASSWORD_LEN + 4);
2221 let reporter = TestReporter;
2222
2223 fs::write(&src, b"hello v4").unwrap();
2224 encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
2225
2226 let bytes = fs::read(&enc).unwrap();
2227 assert_eq!(&bytes[..8], MAGIC_V4);
2228 assert_ne!(&bytes[..8], MAGIC_V3);
2229 assert_ne!(&bytes[..8], MAGIC);
2230
2231 let _ = fs::remove_dir_all(tmp_dir);
2232 }
2233
2234 #[test]
2235 fn test_v4_roundtrip_multi_chunk() {
2236 let tmp_dir = unique_test_dir();
2238 fs::create_dir_all(&tmp_dir).unwrap();
2239 let src = tmp_dir.join("big.bin");
2240 let enc = tmp_dir.join("big.vx2");
2241 let dec = tmp_dir.join("big_dec.bin");
2242 let password = "x".repeat(MIN_PASSWORD_LEN + 4);
2243 let reporter = TestReporter;
2244
2245 let data: Vec<u8> = (0u8..=255)
2246 .cycle()
2247 .take(CHUNK_SIZE * 2 + CHUNK_SIZE / 2)
2248 .collect();
2249 fs::write(&src, &data).unwrap();
2250
2251 encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
2252 assert_eq!(&fs::read(&enc).unwrap()[..8], MAGIC_V4);
2253
2254 decrypt_file(&enc, &dec, false, password.as_bytes(), &reporter, None).unwrap();
2255 assert_eq!(fs::read(&dec).unwrap(), data);
2256
2257 let _ = fs::remove_dir_all(tmp_dir);
2258 }
2259
2260 #[test]
2261 fn test_v4_tampered_salt_fails_at_aad() {
2262 let tmp_dir = unique_test_dir();
2266 fs::create_dir_all(&tmp_dir).unwrap();
2267 let src = tmp_dir.join("plain.txt");
2268 let enc = tmp_dir.join("plain.vx2");
2269 let password = "x".repeat(MIN_PASSWORD_LEN + 4);
2270 let reporter = TestReporter;
2271
2272 fs::write(&src, b"sensitive data").unwrap();
2273 encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
2274
2275 let mut bytes = fs::read(&enc).unwrap();
2276 bytes[8] ^= 0x01; let tampered = tmp_dir.join("tampered.vx2");
2278 fs::write(&tampered, &bytes).unwrap();
2279
2280 let dec = tmp_dir.join("dec.txt");
2281 let result = decrypt_file(&tampered, &dec, false, password.as_bytes(), &reporter, None);
2282 assert!(matches!(result, Err(CryptoError::DecryptionFailed)));
2283
2284 let _ = fs::remove_dir_all(tmp_dir);
2285 }
2286
2287 #[test]
2288 fn test_v4_tampered_nonce_fails_at_aad() {
2289 let tmp_dir = unique_test_dir();
2291 fs::create_dir_all(&tmp_dir).unwrap();
2292 let src = tmp_dir.join("plain.txt");
2293 let enc = tmp_dir.join("plain.vx2");
2294 let password = "x".repeat(MIN_PASSWORD_LEN + 4);
2295 let reporter = TestReporter;
2296
2297 fs::write(&src, b"sensitive data").unwrap();
2298 encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
2299
2300 let mut bytes = fs::read(&enc).unwrap();
2301 bytes[24] ^= 0x80; let tampered = tmp_dir.join("tampered.vx2");
2303 fs::write(&tampered, &bytes).unwrap();
2304
2305 let dec = tmp_dir.join("dec.txt");
2306 let result = decrypt_file(&tampered, &dec, false, password.as_bytes(), &reporter, None);
2307 assert!(matches!(result, Err(CryptoError::DecryptionFailed)));
2308
2309 let _ = fs::remove_dir_all(tmp_dir);
2310 }
2311
2312 #[test]
2313 fn test_v4_tampered_magic_fails() {
2314 let tmp_dir = unique_test_dir();
2319 fs::create_dir_all(&tmp_dir).unwrap();
2320 let src = tmp_dir.join("plain.txt");
2321 let enc = tmp_dir.join("plain.vx2");
2322 let password = "x".repeat(MIN_PASSWORD_LEN + 4);
2323 let reporter = TestReporter;
2324
2325 fs::write(&src, b"sensitive data").unwrap();
2326 encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
2327
2328 let mut bytes = fs::read(&enc).unwrap();
2329 bytes[..8].copy_from_slice(MAGIC_V3); let swapped = tmp_dir.join("swapped.vx2");
2331 fs::write(&swapped, &bytes).unwrap();
2332
2333 let dec = tmp_dir.join("dec.txt");
2334 let result = decrypt_file(&swapped, &dec, false, password.as_bytes(), &reporter, None);
2335 assert!(matches!(result, Err(CryptoError::DecryptionFailed)));
2337
2338 let _ = fs::remove_dir_all(tmp_dir);
2339 }
2340
2341 #[test]
2342 fn test_v4_empty_file_roundtrip() {
2343 let tmp_dir = unique_test_dir();
2345 fs::create_dir_all(&tmp_dir).unwrap();
2346 let src = tmp_dir.join("empty.bin");
2347 let enc = tmp_dir.join("empty.vx2");
2348 let dec = tmp_dir.join("empty_dec.bin");
2349 let password = "x".repeat(MIN_PASSWORD_LEN + 4);
2350 let reporter = TestReporter;
2351
2352 fs::write(&src, b"").unwrap();
2353 encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
2354 assert_eq!(fs::metadata(&enc).unwrap().len(), 47);
2356 decrypt_file(&enc, &dec, false, password.as_bytes(), &reporter, None).unwrap();
2357 assert_eq!(fs::read(&dec).unwrap(), b"");
2358
2359 let _ = fs::remove_dir_all(tmp_dir);
2360 }
2361
2362 #[test]
2363 fn test_v4_wrong_password_fails() {
2364 let tmp_dir = unique_test_dir();
2365 fs::create_dir_all(&tmp_dir).unwrap();
2366 let src = tmp_dir.join("secret.txt");
2367 let enc = tmp_dir.join("secret.vx2");
2368 let dec = tmp_dir.join("dec.txt");
2369 let password = "x".repeat(MIN_PASSWORD_LEN + 4);
2370 let wrong = "y".repeat(MIN_PASSWORD_LEN + 4);
2371 let reporter = TestReporter;
2372
2373 fs::write(&src, b"top secret").unwrap();
2374 encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
2375
2376 let result = decrypt_file(&enc, &dec, false, wrong.as_bytes(), &reporter, None);
2377 assert!(matches!(result, Err(CryptoError::DecryptionFailed)));
2378
2379 let _ = fs::remove_dir_all(tmp_dir);
2380 }
2381
2382 #[test]
2383 fn test_v4_stream_roundtrip_via_pipe() {
2384 let password = "x".repeat(MIN_PASSWORD_LEN + 4);
2386 let reporter = TestReporter;
2387 let data: Vec<u8> = (0u8..=255).cycle().take(CHUNK_SIZE + 100).collect();
2388
2389 let mut encrypted = Vec::new();
2390 let mut reader = std::io::Cursor::new(&data);
2391 encrypt_stream(
2392 &mut reader,
2393 &mut encrypted,
2394 password.as_bytes(),
2395 Some(data.len() as u64),
2396 &reporter,
2397 )
2398 .unwrap();
2399
2400 assert_eq!(&encrypted[..8], MAGIC_V4);
2402
2403 let mut decrypted = Vec::new();
2404 let mut enc_reader = std::io::Cursor::new(&encrypted);
2405 decrypt_stream(
2406 &mut enc_reader,
2407 &mut decrypted,
2408 password.as_bytes(),
2409 Some(encrypted.len() as u64),
2410 &reporter,
2411 )
2412 .unwrap();
2413
2414 assert_eq!(decrypted, data);
2415 }
2416}