Skip to main content

neuron_encrypt_core/
crypto.rs

1// crypto.rs - standalone cryptographic library
2// Zero egui imports. Zero GUI logic. Zero platform-specific behavior in the API.
3
4use 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
26// ── V2 (legacy) constants ──
27pub 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
34// ── V3 (streaming) constants ──
35/// VAULTX03 file header layout (31 bytes total):
36///
37/// | Offset | Length | Field          | Description                                    |
38/// |--------|--------|----------------|------------------------------------------------|
39/// | 0      | 8      | Magic Bytes    | Literal `b"VAULTX03"` identifies format version |
40/// | 8      | 16     | Salt           | Fresh `OsRng`-generated per file (128-bit entropy) |
41/// | 24     | 7      | Stream Nonce   | Fresh `OsRng`-generated per file; EncryptorBE32 appends an internal 5-byte counter/flag |
42///
43/// Body follows immediately after the header: a sequence of AES-256-GCM-SIV
44/// encrypted chunks via the BE32 streaming API. Each chunk carries a 16-byte
45/// authentication tag. The final chunk is sealed with `encrypt_last`/`decrypt_last`
46/// to prevent truncation attacks.
47///
48/// Memory profile: Argon2id (256 MB) during key derivation, then ~2 MB constant
49/// during 1 MB chunk streaming — peak ~258 MB regardless of file size.
50pub const MAGIC_V3: &[u8; 8] = b"VAULTX03";
51/// 16-byte salt — 128-bit entropy, recommended minimum for Argon2.
52pub const SALT_V3_LEN: usize = 16;
53/// EncryptorBE32 uses 5 bytes of the 12-byte nonce (4-byte counter + 1-byte flag).
54pub const STREAM_NONCE_LEN: usize = 7;
55/// V3 header: 8 (magic) + 16 (salt) + 7 (stream_nonce) = 31 bytes.
56pub const HEADER_V3_LEN: usize = 8 + SALT_V3_LEN + STREAM_NONCE_LEN;
57/// 1 MB chunks — balances memory usage vs per-chunk overhead.
58pub const CHUNK_SIZE: usize = 1_048_576;
59
60/// EncryptorBE32 uses a 4-byte (32-bit) counter, so the maximum number of
61/// intermediate chunks is 2^32. At 1 MiB per chunk, the maximum safe file
62/// size before counter wraparound is 2^32 * 1 MiB = 2^52 bytes = 4 PiB.
63/// Encrypting a file larger than this would cause counter wraparound,
64/// reusing (key, nonce, counter) triples and catastrophically breaking
65/// GCM-SIV confidentiality and integrity.
66pub const MAX_V3_FILE_SIZE: u64 = (1u64 << 32) * CHUNK_SIZE as u64;
67
68// ── V4 (current) constants ──
69//
70// V4 is identical to V3 in every way (same cipher, KDF, chunking, header
71// layout, file-size limit) EXCEPT that every encrypt_next / encrypt_last /
72// decrypt_next / decrypt_last call passes the header bytes
73// (MAGIC_V4 || salt || stream_nonce) as Additional Authenticated Data (AAD).
74//
75// This closes the defensive-in-depth gap identified in audit finding 5-1:
76// V3 does not authenticate the salt or stream_nonce, so an attacker who
77// tampers with them in transit causes a silent DoS (wrong key derived →
78// all chunk tags fail → DecryptionFailed). V4 makes any tampering with
79// the header detectable at the AEAD layer on every chunk, not just via
80// the indirect effect on the derived key.
81//
82// V3 files remain fully readable for backward compatibility. New encryptions
83// default to V4. There is no V3->V4 auto-migration; users who want V4 must
84// re-encrypt.
85pub const MAGIC_V4: &[u8; 8] = b"VAULTX04";
86/// V4 reuses the V3 header layout: 8 (magic) + 16 (salt) + 7 (stream_nonce) = 31 bytes.
87pub const HEADER_V4_LEN: usize = HEADER_V3_LEN;
88/// V4 file-size limit is the same as V3 (same BE32 counter constraint).
89pub const MAX_V4_FILE_SIZE: u64 = MAX_V3_FILE_SIZE;
90
91// ── V2 legacy limits (kept for backward-compat decryption) ──
92pub 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
95/// Minimum passphrase length for security.
96pub const MIN_PASSWORD_LEN: usize = 8;
97
98/// Receives best-effort progress updates from long-running crypto operations.
99pub 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
109/// Return the default encrypted output file name for `path`.
110pub fn default_encrypt_output_name(path: &Path) -> String {
111    format!("{}{}", source_file_name(path), EXTENSION)
112}
113
114/// Return the default decrypted output file name for `path`.
115///
116/// The function strips exactly one trailing `.vx2` suffix case-insensitively.
117/// If that would produce an empty file name, it returns `decrypted` instead.
118pub 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
134/// A reporter that only forwards materially different updates.
135pub 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
209/// V3 key derivation — same Argon2id + HKDF pipeline, different HKDF info
210/// for cryptographic domain separation from V2.
211fn 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
227/// V4 key derivation — same Argon2id + HKDF pipeline as V3, but with a
228/// distinct HKDF info string so V3 and V4 keys are cryptographically
229/// independent even for identical (password, salt) pairs.
230fn 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
253/// V4 AAD builder — authenticates the full header (magic || salt || stream_nonce)
254/// so any tampering with header bytes is detected at the AEAD layer on every chunk.
255fn 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    // Guard against counter-wraparound in EncryptorBE32 (4-byte counter at 1 MiB chunks).
472    // V4 uses the same BE32 stream cipher as V3, so the limit is identical.
473    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    // P1-6: V4 uses a distinct HKDF info string for cross-version domain separation.
487    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    // P1-6: V4 AAD authenticates the full header (magic || salt || stream_nonce)
493    // so any tampering with header bytes is detected at the AEAD layer on every chunk.
494    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        // Write V4 header
514        out.write_all(MAGIC_V4)?;
515        out.write_all(&salt)?;
516        out.write_all(&stream_nonce)?;
517
518        // Stream encryption in 1 MB chunks
519        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; // ~100 MB between flushes
523
524        loop {
525            // P1-2: check for user cancellation at the top of each chunk iteration.
526            // Cleanup the .tmp file and return Cancelled so the caller can show
527            // an appropriate message.
528            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                // Empty file or exact chunk boundary — finalize with empty last
538                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            // Check if there is more data after this chunk
556            let mut peek = [0u8; 1];
557            let peeked = file.read(&mut peek)?;
558
559            if peeked == 0 {
560                // This is the last chunk
561                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                // More data follows — encrypt as intermediate chunk
572                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                // I/O Governor: periodically flush dirty pages to prevent OS lag.
583                if chunk_counter.is_multiple_of(IO_GOVENER_INTERVAL) {
584                    out.sync_data()?;
585                    std::thread::sleep(Duration::from_millis(5));
586                }
587
588                // Put the peeked byte back by seeking back 1
589                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    // A2-4 fix: re-check cancel AFTER the loop completes but BEFORE persisting
603    // the temp file. Without this, if cancel fires after encrypt_last succeeds
604    // but before persist_temp_file renames, the user sees "cancelled" but the
605    // output file is still written — contradicting the cancel message.
606    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
618/// Read up to `buf.len()` bytes, returning how many were read (may be less at EOF).
619fn 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    // Check for cancellation before starting work.
645    if let Some(flag) = cancel_flag {
646        if flag.load(Ordering::SeqCst) {
647            return Err(CryptoError::Cancelled);
648        }
649    }
650
651    // Read magic bytes to determine format version
652    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        // P1-6: V4 path — AAD-authenticated streaming format (current default).
663        decrypt_file_streaming_v4(src, dest, overwrite, password, reporter, cancel_flag)
664    } else if &magic_buf == MAGIC_V3 {
665        // V3 path — backward compat, no AAD on header.
666        decrypt_file_streaming(src, dest, overwrite, password, reporter, cancel_flag)
667    } else {
668        Err(CryptoError::InvalidMagic)
669    }
670}
671
672/// Legacy V2 decryption — loads entire file into RAM.
673fn 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    // Check for cancellation before starting V2 decryption (Argon2id is expensive).
698    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    // A2-4 fix: re-check cancel before persisting the temp file.
759    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
782/// V3 streaming decryption — constant memory usage.
783fn 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    // Skip magic (already read by dispatcher), read salt + nonce
805    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        // Read all chunks, processing intermediate ones immediately
842        loop {
843            // P1-2: check for user cancellation. Clean up the .tmp file and return.
844            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 we had a pending chunk, it wasn't the last — decrypt it now
857            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        // Process the final chunk
873        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    // A2-4 fix: re-check cancel before persisting the temp file.
893    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
916/// V4 streaming decryption — same chunking as V3 but with AAD covering the header.
917/// Used for files with the VAULTX04 magic bytes (the current default format).
918fn 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    // Skip magic (already read by dispatcher), read salt + nonce
940    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    // P1-6: V4 AAD authenticates the full header. Every chunk's tag is computed
953    // over (plaintext, AAD, counter, flag) — any tampering with the header bytes
954    // is detected on the first chunk's tag verification.
955    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        // Read all chunks, processing intermediate ones immediately
982        loop {
983            // P1-2: check for user cancellation. Clean up the .tmp file and return.
984            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 we had a pending chunk, it wasn't the last — decrypt it now
997            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        // Process the final chunk
1016        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    // A2-4 fix: re-check cancel before persisting the temp file.
1039    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
1062/// Attempts a 3-pass random overwrite, rename, and deletion of `path`.
1063///
1064/// WARNING: This is not cryptographically guaranteed on SSDs, APFS, Btrfs, ZFS,
1065/// or NTFS with shadow copies enabled.
1066pub 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
1125/// Stream-encrypt from any reader to any writer.
1126/// Used by the CLI for stdin/stdout piping. Does not create temp files or handle renaming.
1127pub 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    // Guard against counter-wraparound if the caller knows the size.
1139    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    // P1-6: V4 uses a distinct HKDF info string for cross-version domain separation.
1155    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    // P1-6: V4 AAD authenticates the full header.
1161    let aad = build_aad_v4(&salt, &stream_nonce);
1162
1163    // Write V4 header
1164    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            // A2-1 fix: runtime counter guard — even when source_size=None
1222            // (e.g. caller doesn't know the size), we still prevent counter
1223            // wraparound. 2^32 intermediate chunks is the BE32 limit.
1224            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
1245/// Stream-decrypt from any reader to any writer.
1246/// Used by the CLI for stdin/stdout piping. Does not create temp files or handle renaming.
1247pub 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        // P1-6: V4 path — AAD-authenticated streaming format (current default).
1268        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
1346/// V4 stream decryption — same chunking as V3 but with AAD covering the header.
1347fn 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    // P1-6: V4 AAD authenticates the full header.
1366    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        // Create a file larger than CHUNK_SIZE (2.5 MB)
1687        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        // Verify the file starts with VAULTX04 (V4 is the current default format).
1704        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        // Exactly 1 chunk
1734        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        // Manually create a V2-format encrypted file and verify decrypt_file can handle it
1822        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    // ── P2-1 regression tests — edge cases, tamper, reorder, format-confusion ──
1857
1858    #[test]
1859    fn test_v3_empty_file_roundtrip() {
1860        // Encrypt a 0-byte file; should produce a 47-byte .vx2 (31 header + 16 tag)
1861        // and decrypt back to a 0-byte file.
1862        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        // Truncating after the first chunk should make decrypt_last treat the
1900        // first chunk as the last — flag mismatch → DecryptionFailed.
1901        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        // Truncate: keep 31-byte header + first chunk only (CHUNK_SIZE + TAG_LEN bytes)
1914        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        // Flip a bit in the salt (bytes 8..24)
1946        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        // Flip a bit in the stream_nonce (bytes 24..31)
1971        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        // Flip a bit in the body (after header)
1996        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        // Attacker rewrites VAULTX04 → VAULTX02. Dispatcher should route to V2 path,
2013        // which fails because the V4 layout is incompatible with V2 expectations.
2014        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); // overwrite VAULTX04 with VAULTX02
2026        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        // Build a 3-chunk file, swap chunks 0 and 1, expect DecryptionFailed.
2039        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        // Swap chunks 0 and 1 using split_at_mut to avoid overlapping borrows.
2058        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        // Appending bytes after the last chunk should promote the original last
2084        // (sealed with encrypt_last, flag=1) to decrypt_next (flag=0) → fail.
2085        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        // 7-byte password should be rejected; 8-byte should succeed.
2110        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        // File of exactly CHUNK_SIZE - 1 bytes (just under one chunk).
2130        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        // File of exactly CHUNK_SIZE + 1 bytes (just over one chunk).
2150        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        // V2 file fed to decrypt_stream should route to decrypt_stream_v2,
2170        // which we just exercise for a non-V2 body to confirm dispatch logic.
2171        let password = "x".repeat(MIN_PASSWORD_LEN + 4);
2172        let reporter = TestReporter;
2173
2174        // Build a tiny "V2" file with garbage body (just to test dispatch)
2175        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]); // fake ciphertext+tag
2180
2181        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        // Sanity: MAX_V3_FILE_SIZE = 2^32 * 1MiB = 4 PiB. Just verify the constant.
2196        assert_eq!(MAX_V3_FILE_SIZE, (1u64 << 32) * CHUNK_SIZE as u64);
2197        // 4 PiB expressed in bytes:
2198        assert_eq!(MAX_V3_FILE_SIZE, 1u64 << 52);
2199    }
2200
2201    // ── P1-6 V4 format tests ──
2202
2203    #[test]
2204    fn test_v4_constants_sanity() {
2205        // V4 reuses V3 header layout and file-size limit.
2206        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        // V3 and V4 magics must be distinct (else dispatch is ambiguous).
2210        assert_ne!(MAGIC_V3, MAGIC_V4);
2211    }
2212
2213    #[test]
2214    fn test_v4_encrypt_produces_v4_magic() {
2215        // New encryptions must default to V4.
2216        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        // V4 encrypt -> decrypt roundtrip with a 2.5 MB file (multiple chunks).
2237        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        // V4 AAD covers the salt. Tampering with the salt byte must cause
2263        // DecryptionFailed (either because the derived key changes OR because
2264        // the AAD no longer matches what was used at encrypt time).
2265        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; // flip a bit in salt[0]
2277        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        // V4 AAD covers the stream_nonce. Tampering must cause DecryptionFailed.
2290        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; // flip a bit in stream_nonce[0]
2302        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        // V4 AAD covers MAGIC_V4. If an attacker changes magic to anything else
2315        // (e.g., MAGIC_V3 to try the V3 path), the V3 path will run derive_key_v3
2316        // (wrong key, because V4 used derive_key_v4) and fail. So this is
2317        // defense-in-depth: even if dispatcher misroutes, decryption fails.
2318        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); // try to force V3 path on a V4 file
2330        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        // V3 path will derive a V3 key (different HKDF info) and fail at tag verification.
2336        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        // V4 encrypt -> decrypt of a 0-byte file.
2344        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        // 31-byte header + 16-byte tag for empty last chunk = 47 bytes total.
2355        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        // V4 encrypt_stream -> decrypt_stream roundtrip (CLI pipe path).
2385        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        // Verify V4 magic.
2401        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}