Skip to main content

git_simple_encrypt/
crypt.rs

1//! The core of this program. Encrypt/decrypt, compress/decompress files.
2//!
3//! GITSE Binary Header Layout (64 Bytes)
4//!  00          04  05  06  07           17                  27              3F
5//!  +-----------+---+---+---+-----------+-------------------+---------------+
6//!  |   MAGIC   | V | F | A |   SALT    |     `FILE_ID`     |   RESERVED    |
7//!  |  "GITSE"  |   |   |   | (16 bytes)|    (16 bytes)     |  (24 bytes)   |
8//!  +-----------+---+---+---+-----------+-------------------+---------------+
9//!    5 bytes     1   1   1    16 bytes       16 bytes          24 bytes
10//!                |   |   |
11//!     Version ---+   |   +--- Encryption Algo (1 = XChaCha20-Poly1305 Stream)
12//!                    |
13//!      Flags --------+ (Bit 0: Compression)
14//!
15//! Streaming Format:
16//! Files are processed in 64KB chunks.
17//! Each chunk is individually encrypted using XChaCha20-Poly1305.
18//!
19//! # Nonce Derivation (Content-Based with File ID)
20//!
21//! Per-chunk nonces are derived from the file's random `File_ID` and the
22//! chunk's own plaintext content using keyed Blake3:
23//!
24//! 1. A random 16-byte `File_ID` is generated once per file and stored in the
25//!    header. This ensures that even if two different files have identical
26//!    plaintext at chunk 0, they produce different nonces and ciphertexts.
27//! 2. The Argon2-derived master key is split via `blake3::derive_key` into
28//!    `Key_ENC` (for XChaCha20-Poly1305 encryption) and `Key_MAC` (for nonce
29//!    generation).
30//! 3. For each chunk `i`: `Nonce_i = Blake3_keyed(Key_MAC, File_ID || M_i ||
31//!    chunk_idx_le)[0..24]`
32//! 4. The 24-byte nonce is stored in plaintext at the head of each encrypted
33//!    chunk.
34//!
35//! Different plaintext always produces a different nonce (within the same
36//! file). The `File_ID` ensures cross-file uniqueness. The chunk index prevents
37//! reordering attacks on identical 64 KB blocks.
38//!
39//! # Authenticated Additional Data (AAD)
40//!
41//! Each chunk's AAD binds the ciphertext to the full file header so that any
42//! tampering with header fields (version, compression flag, salt, `file_id`,
43//! reserved) is detected via Poly1305 authentication failure:
44//!
45//! ```text
46//! AAD = HEADER (64B) || chunk_idx (8B LE) || is_last_chunk (1B)   // 73 bytes
47//! ```
48//!
49//! Each encrypted chunk layout: `[NONCE (24B)] [CIPHERTEXT] [TAG (16B)]`
50
51use std::{
52    fs,
53    io::{Read, Seek, SeekFrom, Write},
54    path::{Path, PathBuf},
55    sync::{
56        Arc, OnceLock,
57        atomic::{AtomicUsize, Ordering},
58    },
59};
60
61use anyhow::{Context, Result, anyhow, ensure};
62use argon2::Argon2;
63use chacha20poly1305::{
64    XChaCha20Poly1305, XNonce,
65    aead::{Aead, KeyInit, Payload},
66};
67use dashmap::DashMap;
68use log::{debug, warn};
69use path_absolutize::Absolutize as _;
70use pathdiff::diff_paths;
71use rand::prelude::*;
72use rayon::prelude::*;
73use tempfile::NamedTempFile;
74use zeroize::Zeroizing;
75
76use crate::{
77    Repo,
78    salt_cache::{self, CachedEntry, SaltCacheSender},
79    utils::{
80        create_progress_bar, is_file_encrypted, print_post_report, print_pre_report,
81        resolve_target_files,
82    },
83};
84
85// --- Constants & Header Layout ---
86
87pub const MAGIC: &[u8; 5] = b"GITSE";
88/// Current encryption format version.
89pub const VERSION: u8 = 3;
90const FLAG_COMPRESSED: u8 = 1 << 0; // Bit 0
91const ENC_ALGO: u8 = 1; // 1 = XChaCha20-Poly1305
92
93// Sizes
94pub const SALT_LEN: usize = 16;
95pub const FILE_ID_LEN: usize = 16;
96pub const NONCE_LEN: usize = 24; // XChaCha20 uses a 192-bit (24-byte) nonce
97pub const HEADER_LEN: usize = 64;
98const RESERVED_LEN: usize = HEADER_LEN - (MAGIC.len() + 1 + 1 + 1 + SALT_LEN + FILE_ID_LEN); // 24 bytes
99
100// Streaming
101const CHUNK_SIZE: usize = 65536; // 64 KB chunks
102
103/// Returns `true` if the given version byte is supported for decryption.
104#[inline]
105#[must_use]
106pub const fn is_encrypted_version(v: u8) -> bool {
107    v == VERSION
108}
109
110// --- Helper Structures ---
111
112/// Fixed-size file header stored at the beginning of every encrypted file.
113///
114/// The layout is `#[repr(C)]` with all fields being `u8` or `[u8; N]`, so:
115/// - **Alignment** = 1 (same as `u8`)
116/// - **Size** = exactly `HEADER_LEN` (64 bytes)
117/// - **No padding** — the compiler cannot insert any between `u8`-aligned
118///   fields
119///
120/// This makes zero-copy casting to/from `[u8; HEADER_LEN]` sound, verified by
121/// the compile-time assertions below.
122#[repr(C)]
123#[derive(Clone, Copy, Debug, PartialEq, Eq)]
124pub struct FileHeader {
125    magic: [u8; 5],
126    version: u8,
127    flags: u8,
128    enc_algo: u8,
129    salt: [u8; SALT_LEN],
130    file_id: [u8; FILE_ID_LEN],
131    reserved: [u8; RESERVED_LEN],
132}
133
134// Compile-time safety invariants for zero-copy casting.
135const _: () = assert!(std::mem::size_of::<FileHeader>() == HEADER_LEN);
136const _: () = assert!(std::mem::align_of::<FileHeader>() == 1);
137
138impl FileHeader {
139    #[must_use]
140    pub fn new(compressed: bool, salt: [u8; SALT_LEN], file_id: Option<[u8; FILE_ID_LEN]>) -> Self {
141        let file_id = file_id.unwrap_or_else(|| {
142            let mut rng = rand::rng();
143            let mut id = [0u8; FILE_ID_LEN];
144            rng.fill_bytes(&mut id);
145            id
146        });
147
148        let mut flags = 0u8;
149        if compressed {
150            flags |= FLAG_COMPRESSED;
151        }
152
153        Self {
154            magic: *MAGIC,
155            version: VERSION,
156            flags,
157            enc_algo: ENC_ALGO,
158            salt,
159            file_id,
160            reserved: [0u8; RESERVED_LEN],
161        }
162    }
163
164    /// Zero-copy: validate and cast `&[u8; 64]` → `&FileHeader`.
165    ///
166    /// The returned reference borrows from `bytes` — no allocation or copying.
167    pub fn from_bytes(bytes: &[u8; HEADER_LEN]) -> Result<&Self> {
168        // SAFETY: FileHeader is #[repr(C)] with only u8/[u8; N] fields.
169        // Alignment == 1, size == HEADER_LEN, no padding — verified by the
170        // compile-time assertions above.
171        let header: &Self = unsafe { &*(bytes.as_ptr().cast()) };
172
173        if &header.magic != MAGIC {
174            return Err(anyhow!("Invalid magic bytes"));
175        }
176        if !is_encrypted_version(header.version) {
177            return Err(anyhow!("Unsupported version: {}", header.version));
178        }
179        if header.enc_algo != ENC_ALGO {
180            return Err(anyhow!(
181                "Unsupported encryption algorithm: {}",
182                header.enc_algo
183            ));
184        }
185
186        Ok(header)
187    }
188
189    /// Read 64 bytes from `reader`, validate, and return an owned header.
190    ///
191    /// This is a convenience wrapper around [`from_bytes`](Self::from_bytes)
192    /// for callers that already have a `Read` impl (e.g. integration tests).
193    pub fn read_from<R: Read>(reader: &mut R) -> Result<Self> {
194        let mut buf = [0u8; HEADER_LEN];
195        reader
196            .read_exact(&mut buf)
197            .context("Failed to read header")?;
198        Ok(*Self::from_bytes(&buf)?)
199    }
200
201    /// Write the header bytes to `writer` (zero-copy via [`as_bytes`]).
202    pub fn write_to<W: Write>(&self, writer: &mut W) -> Result<()> {
203        writer.write_all(self.as_bytes())?;
204        Ok(())
205    }
206
207    /// Zero-copy: view the header as a fixed-size byte slice.
208    ///
209    /// The returned `&[u8; HEADER_LEN]` borrows from `self`.
210    #[must_use]
211    #[inline]
212    pub const fn as_bytes(&self) -> &[u8; HEADER_LEN] {
213        // SAFETY: Same reasoning as from_bytes — #[repr(C)], align 1,
214        // size == HEADER_LEN, no padding.
215        unsafe { &*std::ptr::from_ref::<Self>(self).cast() }
216    }
217
218    #[must_use]
219    pub const fn is_compressed(&self) -> bool {
220        (self.flags & FLAG_COMPRESSED) != 0
221    }
222}
223
224// --- Core Logic ---
225
226/// Derive a file-specific key using Argon2.
227/// Input: User Master Key (bytes) + File Salt.
228/// Output: 32 bytes (master key, to be split into `Key_ENC` + `Key_MAC`).
229fn derive_key(password: &[u8], salt: &[u8]) -> Result<Zeroizing<[u8; 32]>> {
230    let mut key = Zeroizing::new([0u8; 32]);
231    Argon2::default()
232        .hash_password_into(password, salt, &mut *key)
233        .map_err(|e| anyhow!("Argon2 key derivation failed: {e}"))?;
234    Ok(key)
235}
236
237/// Split the Argon2-derived master key into `Key_ENC` (encryption) and
238/// `Key_MAC` (nonce generation) using blake3's `derive_key` (HKDF-like).
239fn split_keys(master_key: &[u8; 32]) -> (Zeroizing<[u8; 32]>, Zeroizing<[u8; 32]>) {
240    let key_enc = blake3::derive_key("git-simple-encrypt-enc", master_key);
241    let key_mac = blake3::derive_key("git-simple-encrypt-mac", master_key);
242    (Zeroizing::new(key_enc), Zeroizing::new(key_mac))
243}
244
245/// Content-based nonce derivation using keyed Blake3 with `File_ID`.
246///
247/// Computes: `Blake3_keyed(Key_MAC, File_ID || plaintext ||
248/// chunk_idx_le)[0..24]`
249///
250/// The `File_ID` ensures cross-file uniqueness: even if two different files
251/// have identical plaintext at the same chunk index, they produce different
252/// nonces. The chunk index prevents reordering attacks on identical 64 KB
253/// blocks.
254fn derive_nonce(
255    key_mac: &[u8; 32],
256    file_id: &[u8; FILE_ID_LEN],
257    plaintext: &[u8],
258    chunk_idx: u64,
259) -> [u8; NONCE_LEN] {
260    let mut hasher = blake3::Hasher::new_keyed(key_mac);
261    hasher.update(file_id);
262    hasher.update(plaintext);
263    hasher.update(&chunk_idx.to_le_bytes());
264    let hash = hasher.finalize();
265    let mut nonce = [0u8; NONCE_LEN];
266    nonce.copy_from_slice(&hash.as_bytes()[..NONCE_LEN]);
267    nonce
268}
269
270/// Safely persist a temporary file while retaining the original file's metadata
271/// (permissions, timestamps).
272fn atomic_write_with_metadata(original_path: &Path, temp_file: NamedTempFile) -> Result<()> {
273    // Attempt to copy metadata. If it fails, we log a warning but proceed to avoid
274    // data loss.
275    if let Err(e) = copy_metadata::copy_metadata(original_path, temp_file.path()) {
276        warn!(
277            "Could not copy metadata for {}: {}",
278            original_path.display(),
279            e
280        );
281    }
282    temp_file.persist(original_path).with_context(|| {
283        format!(
284            "Failed to persist atomic write to {}",
285            original_path.display()
286        )
287    })?;
288    Ok(())
289}
290
291/// Compute a repo-relative cache key from a file path.
292///
293/// Uses `absolutize_from(repo_path)` to guarantee a correct absolute path
294/// (even if `list_files` returns relative paths), then computes the relative
295/// path via `diff_paths`. The result is raw OS-encoded bytes with `b'\\'`
296/// replaced by `b'/'` for cross-platform consistency.
297fn cache_key(file_path: &Path, repo_path: &Path) -> Vec<u8> {
298    let abs_path = if file_path.is_absolute() {
299        file_path.into()
300    } else {
301        file_path
302            .absolutize_from(repo_path)
303            .unwrap_or_else(|_| file_path.into())
304    };
305    let relative =
306        diff_paths(abs_path.as_ref(), repo_path).unwrap_or_else(|| abs_path.to_path_buf());
307    let mut bytes = relative.into_os_string().into_encoded_bytes();
308    for b in &mut bytes {
309        if *b == b'\\' {
310            *b = b'/';
311        }
312    }
313    bytes
314}
315
316// --- Key Derivation Cache (thundering-herd safe) ---
317
318/// Thread-safe key derivation cache with thundering-herd protection.
319///
320/// Uses `Arc<OnceLock>` per salt so that when multiple threads encounter the
321/// same salt simultaneously (e.g. new files sharing a `batch_salt`), only ONE
322/// thread performs the expensive Argon2 computation; all others block on the
323/// `OnceLock` and then clone the result.
324///
325/// The `Arc` allows cloning the handle out of the `DashMap` guard, releasing
326/// the internal shard lock before the Argon2 computation starts. This prevents
327/// contention on other keys sharing the same shard.
328type KeyCache = DashMap<[u8; SALT_LEN], Arc<OnceLock<Result<Zeroizing<[u8; 32]>, String>>>>;
329
330/// Retrieve or derive a key for the given salt, with thundering-herd
331/// protection.
332///
333/// If the key has already been computed, returns a clone immediately.
334/// If multiple threads arrive simultaneously for the same salt, only one
335/// performs the Argon2 computation; the others block and then clone the result.
336fn get_or_derive_key(
337    key_cache: &KeyCache,
338    master_key: &[u8],
339    salt: &[u8; SALT_LEN],
340) -> Result<Zeroizing<[u8; 32]>> {
341    // Atomically insert a placeholder OnceLock if this salt is new.
342    // We clone the Arc so that the DashMap shard lock is released before
343    // the potentially slow Argon2 computation, preventing contention on
344    // other keys in the same shard.
345    let lock = {
346        let guard = key_cache
347            .entry(*salt)
348            .or_insert_with(|| Arc::new(OnceLock::new()));
349        Arc::clone(&*guard)
350    };
351
352    // Only the first thread to reach get_or_init runs the closure;
353    // all others block until the result is available.
354    match lock.get_or_init(|| derive_key(master_key, salt).map_err(|e| e.to_string())) {
355        Ok(key) => Ok(key.clone()),
356        Err(msg) => Err(anyhow!("{msg}")),
357    }
358}
359
360// --- Public Operations ---
361
362/// Encrypt a single file using streaming chunked encryption.
363///
364/// Returns `Some(header)` on success, or `None` if the file was already
365/// encrypted and skipped.
366pub fn encrypt_file(
367    path: &Path,
368    derived_key: &[u8; 32],
369    salt: &[u8; SALT_LEN],
370    file_id: Option<[u8; FILE_ID_LEN]>,
371    zstd: Option<u8>,
372) -> Result<Option<FileHeader>> {
373    let mut file = fs::File::open(path)?;
374
375    // 1. Quick check if already encrypted
376    let mut header_bytes = [0u8; HEADER_LEN];
377    if file.read_exact(&mut header_bytes).is_ok()
378        && &header_bytes[0..5] == MAGIC
379        && is_encrypted_version(header_bytes[5])
380    {
381        warn!("File already encrypted, skipping: {}", path.display());
382        return Ok(None);
383    }
384    file.seek(SeekFrom::Start(0))?; // Rewind to start
385
386    debug!("Encrypting: {}", path.display());
387
388    // 2. Generate File_ID (random if not cached) and prepare Header & Temp File
389    let file_id = file_id.unwrap_or_else(|| {
390        let mut rng = rand::rng();
391        let mut id = [0u8; FILE_ID_LEN];
392        rng.fill_bytes(&mut id);
393        id
394    });
395    let header = FileHeader::new(zstd.is_some(), *salt, Some(file_id));
396    let parent_dir = path.parent().unwrap_or_else(|| Path::new("."));
397    let mut temp_file = NamedTempFile::new_in(parent_dir)
398        .with_context(|| "Failed to create temp file".to_string())?;
399
400    header.write_to(&mut temp_file)?;
401
402    // 3. Split master key into encryption key and MAC key, then setup cipher.
403    let (key_enc, key_mac) = split_keys(derived_key);
404    let cipher = XChaCha20Poly1305::new(key_enc.as_ref().into());
405
406    // If compression is enabled, wrap the file reader in a Zstd Encoder
407    let mut reader: Box<dyn Read> = if let Some(zstd_level) = zstd {
408        Box::new(zstd::stream::read::Encoder::new(
409            file,
410            i32::from(zstd_level),
411        )?)
412    } else {
413        Box::new(file)
414    };
415
416    // 4. Chunked Encryption Loop — content-based nonce derivation with File_ID
417    let mut buffer = Zeroizing::new(vec![0u8; CHUNK_SIZE]);
418    let mut chunk_idx = 0u64;
419
420    loop {
421        let mut bytes_read = 0;
422        while bytes_read < CHUNK_SIZE {
423            let n = reader.read(&mut buffer[bytes_read..])?;
424            if n == 0 {
425                break;
426            }
427            bytes_read += n;
428        }
429
430        let is_last_chunk = bytes_read < CHUNK_SIZE;
431        // AAD includes the full 64-byte header + chunk_idx + is_last_chunk.
432        // This ensures any tampering with header fields (e.g. compressed flag)
433        // is detected during decryption via Poly1305 authentication failure.
434        let mut aad = [0u8; HEADER_LEN + 9];
435        aad[..HEADER_LEN].copy_from_slice(header.as_bytes());
436        aad[HEADER_LEN..HEADER_LEN + 8].copy_from_slice(&chunk_idx.to_le_bytes());
437        aad[HEADER_LEN + 8] = u8::from(is_last_chunk);
438
439        // Derive nonce from File_ID + current chunk's plaintext using keyed Blake3.
440        let nonce_bytes = derive_nonce(&key_mac, &file_id, &buffer[..bytes_read], chunk_idx);
441        let nonce = XNonce::from(nonce_bytes);
442
443        let payload = Payload {
444            msg: &buffer[..bytes_read],
445            aad: &aad,
446        };
447
448        let ciphertext = cipher
449            .encrypt(&nonce, payload)
450            .map_err(|e| anyhow!("Encryption failed: {e}"))?;
451
452        // Write: nonce (24B) + ciphertext (includes 16B Poly1305 tag).
453        temp_file.write_all(&nonce_bytes)?;
454        temp_file.write_all(&ciphertext)?;
455
456        chunk_idx += 1;
457
458        if is_last_chunk {
459            break;
460        }
461    }
462
463    // 5. Atomic Write with Metadata Preservation
464    drop(reader);
465    atomic_write_with_metadata(path, temp_file)?;
466
467    Ok(Some(header))
468}
469
470/// Decrypt a single file using streaming chunked decryption.
471pub fn decrypt_file(path: &Path, master_key: &[u8]) -> Result<()> {
472    let key_cache: KeyCache = DashMap::new();
473    decrypt_file_with_cache(path, &key_cache, None, master_key)
474}
475
476/// Decrypt a single file using streaming chunked decryption, with a thread-safe
477/// cache for derived keys and an optional cache sender for deterministic
478/// re-encryption.
479///
480/// The `cache` tuple contains `(sender, relative_key)` — the caller is
481/// responsible for computing the repo-relative path key.
482pub fn decrypt_file_with_cache(
483    path: &Path,
484    key_cache: &KeyCache,
485    cache: Option<(&SaltCacheSender, &[u8])>,
486    master_key: &[u8],
487) -> Result<()> {
488    let mut file = fs::File::open(path)?;
489
490    // 1. Read and validate header directly (Redundant I/O fixed)
491    let mut header_bytes = [0u8; HEADER_LEN];
492    if file.read_exact(&mut header_bytes).is_err() {
493        debug!(
494            "File too small to be encrypted, skipping: {}",
495            path.display()
496        );
497        return Ok(());
498    }
499    if &header_bytes[0..5] != MAGIC || !is_encrypted_version(header_bytes[5]) {
500        debug!(
501            "File not encrypted (no magic), skipping: {}",
502            path.display()
503        );
504        return Ok(());
505    }
506
507    debug!("Decrypting: {}", path.display());
508    let header = FileHeader::from_bytes(&header_bytes)
509        .with_context(|| format!("Corrupt header in {}", path.display()))?;
510
511    // Cache the salt+file_id BEFORE decryption so it is preserved even if
512    // decryption fails halfway through.
513    if let Some((sender, key)) = cache {
514        sender.insert(
515            key,
516            CachedEntry {
517                salt: header.salt,
518                file_id: header.file_id,
519            },
520        );
521    }
522
523    // 2. Retrieve or Derive Key (with thundering-herd protection)
524    let derived_key = get_or_derive_key(key_cache, master_key, &header.salt)?;
525
526    // 3. Split master key and setup cipher
527    let (key_enc, _key_mac) = split_keys(&derived_key);
528    let cipher = XChaCha20Poly1305::new(key_enc.as_ref().into());
529    let parent_dir = path.parent().unwrap_or_else(|| Path::new("."));
530    let mut temp_file = NamedTempFile::new_in(parent_dir)
531        .with_context(|| "Failed to create temp file".to_string())?;
532
533    // 4. Chunked Decryption Loop
534    if header.is_compressed() {
535        let mut decoder = zstd::stream::write::Decoder::new(&mut temp_file)?.auto_flush();
536        decrypt_chunks(&mut file, &mut decoder, &cipher, header.as_bytes())?;
537        decoder.flush()?;
538    } else {
539        decrypt_chunks(&mut file, &mut temp_file, &cipher, header.as_bytes())?;
540    }
541    drop(file);
542
543    // 5. Atomic Write with Metadata Preservation
544    atomic_write_with_metadata(path, temp_file)?;
545
546    Ok(())
547}
548
549/// Helper function to read ciphertext chunks, decrypt them, and write to the
550/// destination.
551///
552/// Chunk layout: `[NONCE (24B)] [CIPHERTEXT] [TAG (16B)]`
553///
554/// `header_bytes` is the raw 64-byte file header, included in every chunk's
555/// AAD to bind the ciphertext to the exact header that was present during
556/// encryption. Any header tampering will cause Poly1305 authentication failure.
557fn decrypt_chunks(
558    file: &mut fs::File,
559    writer: &mut dyn Write,
560    cipher: &XChaCha20Poly1305,
561    header_bytes: &[u8; HEADER_LEN],
562) -> Result<()> {
563    let mut nonce_buf = [0u8; NONCE_LEN];
564    let mut ct_buffer = vec![0u8; CHUNK_SIZE + 16]; // ciphertext + Poly1305 tag
565    let mut last_chunk_was_final = false;
566    let mut chunk_idx = 0u64;
567
568    loop {
569        // Read the 24-byte nonce from the chunk header.
570        match file.read_exact(&mut nonce_buf) {
571            Ok(()) => {}
572            Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
573            Err(e) => return Err(e.into()),
574        }
575
576        // Read ciphertext + tag (up to CHUNK_SIZE + 16 bytes).
577        let mut bytes_read = 0;
578        while bytes_read < ct_buffer.len() {
579            let n = file.read(&mut ct_buffer[bytes_read..])?;
580            if n == 0 {
581                break;
582            }
583            bytes_read += n;
584        }
585
586        if bytes_read == 0 {
587            return Err(anyhow!(
588                "Truncated chunk: nonce present but no ciphertext follows"
589            ));
590        }
591
592        let is_last_chunk = bytes_read < ct_buffer.len();
593
594        // AAD must match what was used during encryption:
595        // full header (64B) + chunk_idx (8B) + is_last_chunk (1B)
596        let mut aad = [0u8; HEADER_LEN + 9];
597        aad[..HEADER_LEN].copy_from_slice(header_bytes);
598        aad[HEADER_LEN..HEADER_LEN + 8].copy_from_slice(&chunk_idx.to_le_bytes());
599        aad[HEADER_LEN + 8] = u8::from(is_last_chunk);
600
601        let nonce = XNonce::from(nonce_buf);
602        let payload = chacha20poly1305::aead::Payload {
603            msg: &ct_buffer[..bytes_read],
604            aad: &aad,
605        };
606
607        let plaintext = Zeroizing::new(cipher.decrypt(&nonce, payload).map_err(|e| {
608            anyhow!("Decryption failed (wrong password, corrupt, or tampered data): {e}")
609        })?);
610
611        writer.write_all(&plaintext)?;
612
613        chunk_idx += 1;
614
615        if is_last_chunk {
616            last_chunk_was_final = true;
617            break;
618        }
619    }
620
621    if !last_chunk_was_final {
622        return Err(anyhow!(
623            "File truncation detected! The ciphertext is incomplete."
624        ));
625    }
626
627    Ok(())
628}
629
630// --- Repo Integration ---
631
632/// Encrypt given files in the repo. If no paths are given, encrypt all files
633/// in the repo's crypt list.
634///
635/// # Deterministic Re-encryption
636///
637/// Loads the `salt+file_id` cache (populated by a previous decrypt) via mmap +
638/// rkyv zero-copy and reuses cached values so that decrypt→encrypt on
639/// unchanged content produces byte-identical ciphertext. Files not in the
640/// cache share a single new batch salt to minimise Argon2 overhead.
641///
642/// The cache is **read-only** during encryption; only the decrypt path
643/// writes to the cache.
644pub fn encrypt_repo(repo: &'static Repo, paths: &[PathBuf]) -> Result<()> {
645    let key = repo.get_key();
646    ensure!(!key.is_empty(), "Key must not be empty");
647
648    let target_files = resolve_target_files(paths, &repo.conf.crypt_list, repo.path());
649    ensure!(!target_files.is_empty(), "No file to encrypt");
650
651    // Pre-operation report
652    print_pre_report("Encrypting", &target_files, repo.path());
653
654    // Read-only cache: mmap + rkyv zero-copy. No write during encryption.
655    let reader = salt_cache::SaltCacheReader::load(repo.path());
656
657    let key_cache: KeyCache = DashMap::new();
658
659    // Generate a single batch salt for files that have no cache entry.
660    let mut batch_salt = [0u8; SALT_LEN];
661    rand::rng().fill_bytes(&mut batch_salt);
662
663    let pb = create_progress_bar(target_files.len(), "Encrypt");
664    let skipped = AtomicUsize::new(0);
665    let failed = AtomicUsize::new(0);
666
667    let result = target_files.par_iter().try_for_each(|f| -> Result<()> {
668        let relative_key = cache_key(f, repo.path());
669        let (salt, cached_file_id) = reader
670            .get(&relative_key)
671            .map_or((batch_salt, None), |entry| {
672                (entry.salt, Some(entry.file_id))
673            });
674
675        // Derive key — thundering-herd safe: only one thread per salt runs Argon2.
676        let derived_key = get_or_derive_key(&key_cache, key.as_bytes(), &salt)?;
677
678        let header = encrypt_file(
679            f,
680            &derived_key,
681            &salt,
682            cached_file_id,
683            repo.conf.use_zstd.then_some(repo.conf.zstd_level),
684        )
685        .with_context(|| format!("Failed to encrypt {}", f.display()))?;
686
687        if header.is_none() {
688            skipped.fetch_add(1, Ordering::Relaxed);
689        }
690
691        pb.inc(1);
692        Ok(())
693    });
694
695    pb.finish_and_clear();
696
697    print_post_report(
698        "Encrypt",
699        target_files.len(),
700        skipped.load(Ordering::Relaxed),
701        failed.load(Ordering::Relaxed),
702    );
703
704    result?;
705
706    Ok(())
707}
708
709/// Decrypt given files in the repo. If no paths are given, decrypt all files
710/// in the repo's crypt list.
711///
712/// The `salt+file_id` of each successfully parsed encrypted file is captured
713/// via an mpsc channel so that a subsequent encrypt can reproduce identical
714/// ciphertext.
715pub fn decrypt_repo(repo: &'static Repo, paths: &[PathBuf]) -> Result<()> {
716    let key = repo.get_key();
717    ensure!(!key.is_empty(), "Master key must not be empty");
718
719    let target_files = resolve_target_files(paths, &repo.conf.crypt_list, repo.path());
720    ensure!(!target_files.is_empty(), "No file to decrypt");
721
722    // Pre-operation report
723    print_pre_report("Decrypting", &target_files, repo.path());
724
725    let key_cache: KeyCache = DashMap::new();
726
727    // Write-only: mpsc channel collects salt/file_id from rayon threads.
728    let (sender, saver) = salt_cache::create_writer(repo.path());
729
730    let pb = create_progress_bar(target_files.len(), "Decrypt");
731    let skipped = AtomicUsize::new(0);
732    let failed = AtomicUsize::new(0);
733
734    let result = target_files.par_iter().try_for_each(|f| -> Result<()> {
735        if !is_file_encrypted(f)? {
736            skipped.fetch_add(1, Ordering::Relaxed);
737            pb.inc(1);
738            return Ok(());
739        }
740
741        let relative_key = cache_key(f, repo.path());
742
743        let decrypt_result = decrypt_file_with_cache(
744            f,
745            &key_cache,
746            Some((&sender, &relative_key)),
747            key.as_bytes(),
748        )
749        .with_context(|| format!("Failed to decrypt {}", f.display()));
750
751        if decrypt_result.is_err() {
752            failed.fetch_add(1, Ordering::Relaxed);
753        }
754
755        pb.inc(1);
756        decrypt_result
757    });
758
759    // Drop sender to close the channel, then persist the cache.
760    drop(sender);
761    saver.save();
762
763    pb.finish_and_clear();
764
765    print_post_report(
766        "Decrypt",
767        target_files.len(),
768        skipped.load(Ordering::Relaxed),
769        failed.load(Ordering::Relaxed),
770    );
771
772    result?;
773
774    Ok(())
775}
776
777#[cfg(test)]
778mod tests {
779    use std::io::{Read, Write};
780
781    use tempfile::{NamedTempFile, TempPath};
782
783    use super::*;
784
785    // --- Helper Functions ---
786
787    fn get_test_key_and_salt() -> ([u8; 32], [u8; SALT_LEN]) {
788        let password = b"super_secret_password";
789        let mut salt = [0u8; SALT_LEN];
790        rand::rng().fill_bytes(&mut salt);
791        let derived = derive_key(password, &salt).unwrap();
792        let mut key = [0u8; 32];
793        key.copy_from_slice(&*derived);
794        (key, salt)
795    }
796
797    fn create_temp_file(content: &[u8]) -> TempPath {
798        let mut file = NamedTempFile::new().unwrap();
799        file.write_all(content).unwrap();
800        file.flush().unwrap();
801        file.into_temp_path()
802    }
803
804    // --- Tests ---
805
806    #[test]
807    fn test_header_serialization() {
808        let salt = [0xAB; SALT_LEN];
809        let header = FileHeader::new(true, salt, None);
810
811        // Write to buffer
812        let mut buf = Vec::new();
813        header.write_to(&mut buf).unwrap();
814        assert_eq!(buf.len(), HEADER_LEN);
815
816        // Roundtrip: bytes → from_bytes (zero-copy)
817        let raw: &[u8; HEADER_LEN] = buf.as_slice().try_into().unwrap();
818        let decoded = FileHeader::from_bytes(raw).unwrap();
819
820        assert_eq!(decoded.magic, *MAGIC);
821        assert_eq!(decoded.version, VERSION);
822        assert_eq!(decoded.flags, FLAG_COMPRESSED);
823        assert_eq!(decoded.enc_algo, ENC_ALGO);
824        assert_eq!(decoded.salt, salt);
825        assert_eq!(decoded.file_id, header.file_id);
826        assert_eq!(decoded.reserved, [0u8; RESERVED_LEN]);
827        assert!(decoded.is_compressed());
828    }
829
830    #[test]
831    fn test_nonce_derivation_deterministic() {
832        let key_mac = [0x42u8; 32];
833        let file_id = [0x99u8; FILE_ID_LEN];
834        let plaintext = b"hello world";
835
836        // Same inputs → same nonce (deterministic).
837        let nonce0_a = derive_nonce(&key_mac, &file_id, plaintext, 0);
838        let nonce0_b = derive_nonce(&key_mac, &file_id, plaintext, 0);
839        assert_eq!(nonce0_a, nonce0_b);
840
841        // Different chunk index → different nonce.
842        let nonce1 = derive_nonce(&key_mac, &file_id, plaintext, 1);
843        assert_ne!(nonce0_a, nonce1);
844
845        // Different plaintext → different nonce.
846        let other_plaintext = b"hello world!";
847        let nonce_other = derive_nonce(&key_mac, &file_id, other_plaintext, 0);
848        assert_ne!(nonce0_a, nonce_other);
849
850        // Different key_mac → different nonce.
851        let key_mac2 = [0x43u8; 32];
852        let nonce_key2 = derive_nonce(&key_mac2, &file_id, plaintext, 0);
853        assert_ne!(nonce0_a, nonce_key2);
854
855        // Different file_id → different nonce (cross-file uniqueness).
856        let file_id2 = [0xAAu8; FILE_ID_LEN];
857        let nonce_file2 = derive_nonce(&key_mac, &file_id2, plaintext, 0);
858        assert_ne!(nonce0_a, nonce_file2);
859
860        // Empty plaintext should still produce a valid nonce.
861        let nonce_empty = derive_nonce(&key_mac, &file_id, b"", 0);
862        assert_ne!(nonce_empty, [0u8; NONCE_LEN]);
863    }
864
865    #[test]
866    fn test_encrypt_decrypt_basic_no_compression() {
867        let plaintext = b"Hello, World! This is a test without compression.";
868        let path = create_temp_file(plaintext);
869
870        let (key, salt) = get_test_key_and_salt();
871        let master_key = b"super_secret_password";
872
873        // Encrypt
874        encrypt_file(&path, &key, &salt, None, None).unwrap();
875
876        // Verify it's encrypted
877        let mut encrypted_content = Vec::new();
878        fs::File::open(&path)
879            .unwrap()
880            .read_to_end(&mut encrypted_content)
881            .unwrap();
882        assert_ne!(encrypted_content, plaintext);
883        assert_eq!(&encrypted_content[0..5], MAGIC);
884        assert_eq!(encrypted_content[5], VERSION);
885
886        // Decrypt
887        decrypt_file(&path, master_key).unwrap();
888
889        // Verify plaintext
890        let mut decrypted_content = Vec::new();
891        fs::File::open(path)
892            .unwrap()
893            .read_to_end(&mut decrypted_content)
894            .unwrap();
895        assert_eq!(decrypted_content, plaintext);
896    }
897
898    #[test]
899    fn test_encrypt_decrypt_with_compression() {
900        // Highly compressible data
901        let plaintext = b"A".repeat(10000);
902        let path = create_temp_file(&plaintext);
903
904        let (key, salt) = get_test_key_and_salt();
905        let master_key = b"super_secret_password";
906
907        // Encrypt with Zstd level 3
908        encrypt_file(&path, &key, &salt, None, Some(3)).unwrap();
909
910        // Verify it's encrypted and compressed (size should be much smaller than 10000
911        // + header)
912        let encrypted_meta = fs::metadata(&path).unwrap();
913        assert!(encrypted_meta.len() < 5000);
914
915        // Decrypt
916        decrypt_file(&path, master_key).unwrap();
917
918        // Verify plaintext
919        let mut decrypted_content = Vec::new();
920        fs::File::open(path)
921            .unwrap()
922            .read_to_end(&mut decrypted_content)
923            .unwrap();
924        assert_eq!(decrypted_content, plaintext);
925    }
926
927    #[test]
928    #[allow(clippy::cast_possible_truncation)]
929    #[allow(clippy::cast_sign_loss)]
930    fn test_chunked_encryption_large_file() {
931        // Create a file larger than CHUNK_SIZE (64KB) to test the streaming loop
932        let plaintext = {
933            let mut data = Vec::with_capacity(100_000);
934            for i in 0..100_000 {
935                data.push((i % 256) as u8);
936            }
937            data
938        };
939
940        let path = create_temp_file(&plaintext);
941
942        let (key, salt) = get_test_key_and_salt();
943        let master_key = b"super_secret_password";
944
945        // Encrypt
946        encrypt_file(&path, &key, &salt, None, None).unwrap();
947
948        // Decrypt
949        decrypt_file(&path, master_key).unwrap();
950
951        // Verify plaintext
952        let mut decrypted_content = Vec::new();
953        fs::File::open(path)
954            .unwrap()
955            .read_to_end(&mut decrypted_content)
956            .unwrap();
957        assert_eq!(decrypted_content, plaintext);
958    }
959
960    #[test]
961    fn test_tamper_resistance() {
962        let plaintext = b"Sensitive data that should not be tampered with.";
963        let path = create_temp_file(plaintext);
964
965        let (key, salt) = get_test_key_and_salt();
966        let master_key = b"super_secret_password";
967
968        // Encrypt
969        encrypt_file(&path, &key, &salt, None, None).unwrap();
970
971        // Tamper with the ciphertext (modify a byte after the 64-byte header)
972        let mut encrypted_content = Vec::new();
973        let mut f = fs::OpenOptions::new()
974            .read(true)
975            .write(true)
976            .open(&path)
977            .unwrap();
978        f.read_to_end(&mut encrypted_content).unwrap();
979
980        // Flip a bit in the ciphertext
981        encrypted_content[HEADER_LEN + 5] ^= 0xFF;
982
983        f.seek(std::io::SeekFrom::Start(0)).unwrap();
984        f.write_all(&encrypted_content).unwrap();
985        drop(f);
986
987        // Attempt to decrypt, should fail due to Poly1305 MAC mismatch
988        let result = decrypt_file(&path, master_key);
989
990        assert!(result.is_err());
991        assert!(
992            result
993                .unwrap_err()
994                .to_string()
995                .contains("Decryption failed")
996        );
997    }
998
999    #[test]
1000    fn test_header_tamper_detected() {
1001        let plaintext = b"Test data with header integrity check.";
1002        let path = create_temp_file(plaintext);
1003
1004        let (key, salt) = get_test_key_and_salt();
1005        let master_key = b"super_secret_password";
1006
1007        // Encrypt
1008        encrypt_file(&path, &key, &salt, None, None).unwrap();
1009
1010        // Tamper with the header flags byte (flip the compressed bit at byte 6)
1011        let mut encrypted_content = Vec::new();
1012        let mut f = fs::OpenOptions::new()
1013            .read(true)
1014            .write(true)
1015            .open(&path)
1016            .unwrap();
1017        f.read_to_end(&mut encrypted_content).unwrap();
1018
1019        encrypted_content[6] ^= FLAG_COMPRESSED;
1020
1021        f.seek(std::io::SeekFrom::Start(0)).unwrap();
1022        f.write_all(&encrypted_content).unwrap();
1023        drop(f);
1024
1025        // Attempt to decrypt — should fail because header tampering changes
1026        // the AAD, causing Poly1305 authentication failure.
1027        let result = decrypt_file(&path, master_key);
1028        assert!(result.is_err());
1029        assert!(
1030            result
1031                .unwrap_err()
1032                .to_string()
1033                .contains("Decryption failed")
1034        );
1035    }
1036
1037    #[test]
1038    fn test_deterministic_encrypt_with_fixed_salt_file_id() {
1039        let plaintext = b"Deterministic encryption test data.";
1040
1041        let password = b"test_password";
1042        let salt = [0x42; SALT_LEN];
1043        let file_id = [0x13; FILE_ID_LEN];
1044        let derived = derive_key(password, &salt).unwrap();
1045        let mut key = [0u8; 32];
1046        key.copy_from_slice(&*derived);
1047
1048        // Encrypt twice with the same salt+file_id → identical ciphertext
1049        let path1 = create_temp_file(plaintext);
1050        let path2 = create_temp_file(plaintext);
1051
1052        encrypt_file(&path1, &key, &salt, Some(file_id), None).unwrap();
1053        encrypt_file(&path2, &key, &salt, Some(file_id), None).unwrap();
1054
1055        let ct1 = fs::read(&path1).unwrap();
1056        let ct2 = fs::read(&path2).unwrap();
1057        assert_eq!(
1058            ct1, ct2,
1059            "Same plaintext + same salt+file_id must produce identical ciphertext"
1060        );
1061
1062        // And both should decrypt correctly
1063        decrypt_file(&path1, password).unwrap();
1064        assert_eq!(fs::read(&path1).unwrap(), plaintext);
1065    }
1066
1067    #[test]
1068    fn test_deterministic_encrypt_multi_chunk() {
1069        // Multi-chunk file: test determinism across chunk boundaries.
1070        #[allow(clippy::cast_possible_truncation)]
1071        let plaintext = {
1072            let mut data = Vec::with_capacity(CHUNK_SIZE * 2 + 1000);
1073            for i in 0..(CHUNK_SIZE * 2 + 1000) {
1074                data.push(i as u8);
1075            }
1076            data
1077        };
1078
1079        let password = b"test_password";
1080        let salt = [0x42; SALT_LEN];
1081        let file_id = [0x13; FILE_ID_LEN];
1082        let derived = derive_key(password, &salt).unwrap();
1083        let mut key = [0u8; 32];
1084        key.copy_from_slice(&*derived);
1085
1086        let path1 = create_temp_file(&plaintext);
1087        let path2 = create_temp_file(&plaintext);
1088
1089        encrypt_file(&path1, &key, &salt, Some(file_id), None).unwrap();
1090        encrypt_file(&path2, &key, &salt, Some(file_id), None).unwrap();
1091
1092        let ct1 = fs::read(&path1).unwrap();
1093        let ct2 = fs::read(&path2).unwrap();
1094        assert_eq!(
1095            ct1, ct2,
1096            "Same multi-chunk plaintext + same salt+file_id must produce identical ciphertext"
1097        );
1098
1099        // Decrypt and verify
1100        decrypt_file(&path1, password).unwrap();
1101        assert_eq!(fs::read(&path1).unwrap(), plaintext);
1102    }
1103
1104    #[test]
1105    fn test_different_file_id_produces_different_ciphertext() {
1106        // Verify that the same plaintext encrypted with different File_IDs
1107        // produces different ciphertext (cross-file uniqueness).
1108        let plaintext = b"Same content, different file.";
1109
1110        let password = b"test_password";
1111        let salt = [0x42; SALT_LEN];
1112        let derived = derive_key(password, &salt).unwrap();
1113        let mut key = [0u8; 32];
1114        key.copy_from_slice(&*derived);
1115
1116        let path1 = create_temp_file(plaintext);
1117        let path2 = create_temp_file(plaintext);
1118
1119        let file_id1 = [0x01; FILE_ID_LEN];
1120        let file_id2 = [0x02; FILE_ID_LEN];
1121
1122        encrypt_file(&path1, &key, &salt, Some(file_id1), None).unwrap();
1123        encrypt_file(&path2, &key, &salt, Some(file_id2), None).unwrap();
1124
1125        let ct1 = fs::read(&path1).unwrap();
1126        let ct2 = fs::read(&path2).unwrap();
1127        assert_ne!(
1128            ct1, ct2,
1129            "Same plaintext with different File_IDs must produce different ciphertext"
1130        );
1131
1132        // Both should decrypt correctly
1133        decrypt_file(&path1, password).unwrap();
1134        assert_eq!(fs::read(&path1).unwrap(), plaintext);
1135        decrypt_file(&path2, password).unwrap();
1136        assert_eq!(fs::read(&path2).unwrap(), plaintext);
1137    }
1138
1139    #[cfg(unix)]
1140    #[test]
1141    fn test_metadata_preservation() {
1142        use std::os::unix::fs::PermissionsExt;
1143
1144        let plaintext = b"Executable script content";
1145        let file = create_temp_file(plaintext);
1146        let path = file.path();
1147
1148        // Set permissions to 0o755 (rwxr-xr-x)
1149        let mut perms = fs::metadata(path).unwrap().permissions();
1150        perms.set_mode(0o755);
1151        fs::set_permissions(path, perms).unwrap();
1152
1153        let (key, salt) = get_test_key_and_salt();
1154        let master_key = b"super_secret_password";
1155
1156        // Encrypt
1157        encrypt_file(path, &key, &salt, None, None).unwrap();
1158
1159        // Check permissions after encryption
1160        let encrypted_perms = fs::metadata(path).unwrap().permissions();
1161        assert_eq!(encrypted_perms.mode() & 0o777, 0o755);
1162
1163        // Decrypt
1164        let key_cache: KeyCache = DashMap::new();
1165        decrypt_file_with_cache(path, &key_cache, None, master_key).unwrap();
1166
1167        // Check permissions after decryption
1168        let decrypted_perms = fs::metadata(path).unwrap().permissions();
1169        assert_eq!(decrypted_perms.mode() & 0o777, 0o755);
1170    }
1171}