Skip to main content

git_simple_encrypt/crypt/
batch.rs

1use std::{
2    fs,
3    io::Read,
4    path::{Path, PathBuf},
5    sync::atomic::{AtomicUsize, Ordering},
6};
7
8use chacha20poly1305::{XChaCha20Poly1305, aead::KeyInit};
9use dashmap::DashMap;
10use log::debug;
11use rand::Rng;
12use rayon::prelude::*;
13use tempfile::NamedTempFile;
14
15use crate::{
16    crypt::{
17        file::{encrypt_file_to, persist_temp_file},
18        header::{FileHeader, HEADER_LEN, MAGIC, SALT_LEN, is_encrypted_version},
19        key::{KeyCache, get_or_derive_key, split_keys},
20        stream::decrypt_body,
21    },
22    error::{Error, Result},
23};
24
25/// Summary of a batch encrypt/decrypt run.
26#[derive(Debug, Default)]
27pub struct BatchSummary {
28    pub total: usize,
29    pub succeeded: usize,
30    pub skipped: usize,
31    pub failed: usize,
32    pub errors: Vec<(PathBuf, Error)>,
33}
34
35impl BatchSummary {
36    #[must_use]
37    pub fn is_ok(&self) -> bool {
38        self.errors.is_empty()
39    }
40}
41
42/// Internal: decrypt `src` → `dst` using a shared Argon2 key cache.
43#[allow(dead_code)]
44fn decrypt_file_to_with_key_cache(
45    src: &Path,
46    dst: &Path,
47    key_cache: &KeyCache,
48    master_key: &[u8],
49) -> Result<Option<FileHeader>> {
50    let mut src_file = fs::File::open(src)?;
51
52    let mut header_bytes = [0u8; HEADER_LEN];
53    if src_file.read_exact(&mut header_bytes).is_err() {
54        debug!(
55            "File too small to be encrypted, skipping: {}",
56            src.display()
57        );
58        return Ok(None);
59    }
60    if &header_bytes[0..5] != MAGIC || !is_encrypted_version(header_bytes[5]) {
61        debug!("File not encrypted (no magic), skipping: {}", src.display());
62        return Ok(None);
63    }
64
65    debug!("Decrypting {} → {}", src.display(), dst.display());
66
67    let header = *FileHeader::from_bytes(&header_bytes)?;
68    let derived_key = get_or_derive_key(key_cache, master_key, &header.salt)?;
69
70    let dst_parent = dst.parent().unwrap_or_else(|| Path::new("."));
71    fs::create_dir_all(dst_parent)?;
72    let mut temp_file = NamedTempFile::new_in(dst_parent)?;
73
74    let (key_enc, _) = split_keys(&derived_key);
75    let cipher = XChaCha20Poly1305::new(key_enc.as_ref().into());
76    decrypt_body(&mut src_file, &mut temp_file, &cipher, &header)?;
77
78    drop(src_file);
79    persist_temp_file(temp_file, dst, Some(src))?;
80
81    Ok(Some(header))
82}
83
84/// Decrypt multiple files in parallel, each to a caller-determined destination.
85#[allow(dead_code, clippy::unnecessary_wraps)]
86pub fn decrypt_files_to<I, P, F>(sources: I, master_key: &[u8], mapper: F) -> Result<BatchSummary>
87where
88    I: IntoIterator<Item = P>,
89    P: AsRef<Path> + Sync,
90    F: Fn(&Path) -> Option<PathBuf> + Sync,
91{
92    let sources: Vec<PathBuf> = sources
93        .into_iter()
94        .map(|p| p.as_ref().to_path_buf())
95        .collect();
96    let total = sources.len();
97
98    let key_cache: KeyCache = DashMap::new();
99    let errors: parking_lot::Mutex<Vec<(PathBuf, Error)>> = parking_lot::Mutex::new(Vec::new());
100    let skipped = AtomicUsize::new(0);
101    let succeeded = AtomicUsize::new(0);
102
103    sources.par_iter().for_each(|src| {
104        let Some(dst) = mapper(src) else { return };
105
106        match decrypt_file_to_with_key_cache(src, &dst, &key_cache, master_key) {
107            Ok(Some(_)) => {
108                succeeded.fetch_add(1, Ordering::Relaxed);
109            }
110            Ok(None) => {
111                skipped.fetch_add(1, Ordering::Relaxed);
112            }
113            Err(e) => {
114                errors.lock().push((src.clone(), e));
115            }
116        }
117    });
118
119    let errors = errors.into_inner();
120    let succeeded = succeeded.load(Ordering::Relaxed);
121    let skipped = skipped.load(Ordering::Relaxed);
122    let failed = errors.len();
123
124    Ok(BatchSummary {
125        total,
126        succeeded,
127        skipped,
128        failed,
129        errors,
130    })
131}
132
133/// Encrypt multiple files in parallel, each from a caller-determined source to
134/// a caller-determined destination.
135#[allow(dead_code, clippy::unnecessary_wraps)]
136pub fn encrypt_files_to<I, P, F>(
137    sources: I,
138    master_key: &[u8],
139    mapper: F,
140    zstd: Option<u8>,
141) -> Result<BatchSummary>
142where
143    I: IntoIterator<Item = P>,
144    P: AsRef<Path> + Sync,
145    F: Fn(&Path) -> Option<PathBuf> + Sync,
146{
147    let sources: Vec<PathBuf> = sources
148        .into_iter()
149        .map(|p| p.as_ref().to_path_buf())
150        .collect();
151    let total = sources.len();
152
153    let mut batch_salt = [0u8; SALT_LEN];
154    rand::rng().fill_bytes(&mut batch_salt);
155    let derived_key = crate::crypt::key::derive_key(master_key, &batch_salt)?;
156
157    let errors: parking_lot::Mutex<Vec<(PathBuf, Error)>> = parking_lot::Mutex::new(Vec::new());
158    let skipped = AtomicUsize::new(0);
159    let succeeded = AtomicUsize::new(0);
160
161    sources.par_iter().for_each(|src| {
162        let Some(dst) = mapper(src) else { return };
163
164        match encrypt_file_to(src, &dst, &derived_key, batch_salt, None, zstd) {
165            Ok(Some(_)) => {
166                succeeded.fetch_add(1, Ordering::Relaxed);
167            }
168            Ok(None) => {
169                skipped.fetch_add(1, Ordering::Relaxed);
170            }
171            Err(e) => {
172                errors.lock().push((src.clone(), e));
173            }
174        }
175    });
176
177    let errors = errors.into_inner();
178    let succeeded = succeeded.load(Ordering::Relaxed);
179    let skipped = skipped.load(Ordering::Relaxed);
180    let failed = errors.len();
181
182    Ok(BatchSummary {
183        total,
184        succeeded,
185        skipped,
186        failed,
187        errors,
188    })
189}