git_simple_encrypt/salt_cache.rs
1//! Persistent `salt+file_id` cache for deterministic re-encryption.
2//!
3//! During **decrypt**, the file's salt and `file_id` are recorded. During
4//! **encrypt**, the cached values are reused so that decrypt→encrypt on the
5//! same plaintext produces byte-identical output.
6//!
7//! # Architecture
8//!
9//! ## Read Path (encrypt) — Zero-copy via mmap + rkyv
10//!
11//! [`SaltCacheReader`] memory-maps the cache file and uses rkyv's zero-copy
12//! deserialization to access the archived `HashMap<String, CachedEntry>`
13//! directly. No heap allocation or full deserialization is required for
14//! lookups.
15//!
16//! ## Write Path (decrypt) — mpsc + rkyv
17//!
18//! [`SaltCacheSender`] is a `Sync` handle that wraps an `mpsc::Sender`.
19//! Rayon worker threads send `(path, entry)` pairs through the channel.
20//! After all parallel work completes, [`SaltCacheSaver`] collects the
21//! entries, merges with any existing on-disk cache, and serializes the
22//! result via rkyv.
23//!
24//! # Key Format
25//!
26//! Cache keys are repo-relative path bytes with forward slashes (`b'/'`),
27//! computed by the caller via [`crate::crypt::cache_key`]. Using raw bytes
28//! (`Vec<u8>`) avoids UTF-8 validation overhead and string allocation.
29//!
30//! # Persistence
31//!
32//! Serialized via [`rkyv`] to `<repo>/.git/git-simple-encrypt-salt-cache`.
33//! The binary format is opaque and not meant for human consumption. Writes
34//! are performed atomically to prevent corruption.
35//!
36//! # Lifecycle
37//!
38//! - **Decrypt**: Create sender → workers send entries → saver persists
39//! (atomically)
40//! - **Encrypt**: Create reader (mmap, read-only) → workers look up cached
41//! values. **No write** is performed during encryption.
42//! - **On error**: Cache is saved with whatever entries were captured before
43//! the failure, preserving partial progress.
44//! - **Stale entries**: Entries for files that no longer exist are harmless
45//! (looked up by key, simply not found) and do not affect correctness.
46
47use std::{
48 collections::HashMap,
49 fmt,
50 path::{Path, PathBuf},
51 sync::mpsc,
52};
53
54use log::{debug, warn};
55use memmap2::Mmap;
56use rkyv::rancor::Error as RkyvError;
57
58use crate::{
59 crypt::{FILE_ID_LEN, SALT_LEN},
60 utils::atomic_write,
61};
62
63/// File name for the persistent salt cache, stored inside `.git/`.
64const CACHE_FILENAME: &str = "git-simple-encrypt-salt-cache";
65
66/// A cached header entry for deterministic re-encryption.
67///
68/// Stores the salt (for key derivation) and `file_id` (for nonce derivation) so
69/// that re-encrypting the same plaintext produces byte-identical ciphertext.
70#[derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize, Clone, Debug, PartialEq, Eq)]
71pub struct CachedEntry {
72 pub salt: [u8; SALT_LEN],
73 pub file_id: [u8; FILE_ID_LEN],
74}
75
76/// Borrowed reference to a salt-cache writer + the repo-relative key for a
77/// single file.
78///
79/// Passed into [`crate::crypt::decrypt_file_with_cache`] so that the decrypt
80/// path can record `(salt, file_id)` for deterministic re-encryption.
81#[derive(Clone, Copy)]
82pub struct CacheRef<'a> {
83 /// The thread-safe sender that forwards entries to the persister thread.
84 pub sender: &'a SaltCacheSender,
85 /// Forward-slash-normalized repo-relative path bytes for this file.
86 pub key: &'a [u8],
87}
88
89impl fmt::Debug for CacheRef<'_> {
90 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91 f.debug_struct("CacheRef")
92 .field("sender", &"SaltCacheSender")
93 .field("key", &String::from_utf8_lossy(self.key))
94 .finish()
95 }
96}
97
98/// Returns the cache file path for the given repo.
99fn cache_path(repo_path: &Path) -> PathBuf {
100 repo_path.join(".git").join(CACHE_FILENAME)
101}
102
103// ---------------------------------------------------------------------------
104// Read Path — zero-copy via mmap + rkyv
105// ---------------------------------------------------------------------------
106
107/// Read-only salt cache backed by memory-mapped file + rkyv zero-copy access.
108///
109/// Used during **encryption** to look up previously cached `salt/file_id`
110/// values without allocating or fully deserializing the cache.
111pub struct SaltCacheReader {
112 /// The memory-mapped cache file. `None` if no cache exists.
113 mmap: Option<Mmap>,
114}
115
116impl SaltCacheReader {
117 /// Open the salt cache for the given repository.
118 ///
119 /// If the cache file does not exist or is corrupted, returns an empty
120 /// reader (all lookups will return `None`). This never fails — a missing
121 /// or corrupt cache simply means we start fresh (new salts will be
122 /// generated during encryption).
123 #[must_use]
124 pub fn load(repo_path: &Path) -> Self {
125 let path = cache_path(repo_path);
126
127 let mmap = if path.exists() {
128 match std::fs::File::open(&path) {
129 Ok(file) => match unsafe { Mmap::map(&file) } {
130 Ok(mmap) => {
131 // Validate the archived data on load so that
132 // `access_unchecked` in `get()` is sound.
133 match rkyv::access::<rkyv::Archived<HashMap<Vec<u8>, CachedEntry>>, RkyvError>(
134 &mmap,
135 ) {
136 Ok(_) => {
137 debug!("Loaded salt cache from {}", path.display());
138 Some(mmap)
139 }
140 Err(e) => {
141 warn!("Corrupted salt cache at {}: {e}", path.display());
142 None
143 }
144 }
145 }
146 Err(e) => {
147 warn!("Failed to mmap salt cache at {}: {e}", path.display());
148 None
149 }
150 },
151 Err(e) => {
152 warn!("Failed to open salt cache at {}: {e}", path.display());
153 None
154 }
155 }
156 } else {
157 debug!("Salt cache not found at {}", path.display());
158 None
159 };
160
161 Self { mmap }
162 }
163
164 /// Look up a cached entry by repo-relative path key (bytes). Zero-copy.
165 ///
166 /// The `key` should be forward-slash normalized repo-relative path bytes,
167 /// computed by the caller.
168 ///
169 /// Returns `None` if no cache file exists or the key is not cached.
170 #[must_use]
171 pub fn get(&self, key: &[u8]) -> Option<CachedEntry> {
172 let mmap = self.mmap.as_ref()?;
173
174 // SAFETY: We validated the mmap data in `load()`. The mapped file is
175 // not modified while this reader is alive.
176 let archived = unsafe {
177 rkyv::access_unchecked::<rkyv::Archived<HashMap<Vec<u8>, CachedEntry>>>(mmap.as_ref())
178 };
179
180 let entry = archived.get(key)?;
181
182 // For [u8; N] fields, Archived<[u8; N]> = [u8; N], so we can copy
183 // directly.
184 Some(CachedEntry {
185 salt: entry.salt,
186 file_id: entry.file_id,
187 })
188 }
189}
190
191// ---------------------------------------------------------------------------
192// Write Path — mpsc collection + rkyv serialization
193// ---------------------------------------------------------------------------
194
195/// Thread-safe sender for cache entries, safe to share across rayon workers.
196///
197/// Workers call [`insert`](Self::insert) to send `(key, entry)` pairs
198/// through an internal `mpsc` channel. After all parallel work completes,
199/// the paired [`SaltCacheSaver`] collects and persists the entries.
200pub struct SaltCacheSender {
201 tx: mpsc::Sender<(Vec<u8>, CachedEntry)>,
202}
203
204impl SaltCacheSender {
205 /// Send a cache entry for the given repo-relative path key (bytes).
206 ///
207 /// The `key` should be forward-slash normalized repo-relative path bytes,
208 /// computed by the caller.
209 ///
210 /// This is thread-safe (`&Self`) and non-blocking. Errors (e.g. channel
211 /// closed) are silently ignored because cache persistence is non-critical.
212 pub fn insert(&self, key: &[u8], entry: CachedEntry) {
213 let _ = self.tx.send((key.to_vec(), entry));
214 }
215}
216
217/// Receiver that collects and persists cache entries to disk.
218///
219/// Created paired with a [`SaltCacheSender`] via [`create_writer`]. After all
220/// parallel work completes, call [`save`](Self::save) to collect entries,
221/// merge with any existing on-disk cache, and serialize via rkyv.
222///
223/// This type is **not** `Sync` — it should only be used on the main thread
224/// after rayon work completes.
225pub struct SaltCacheSaver {
226 rx: mpsc::Receiver<(Vec<u8>, CachedEntry)>,
227 repo_path: PathBuf,
228}
229
230impl SaltCacheSaver {
231 /// Persist all collected entries to disk (best-effort, atomic).
232 ///
233 /// 1. Drops the internal sender (via the paired `SaltCacheSender` going out
234 /// of scope in the caller) so the channel closes.
235 /// 2. Collects all `(key, entry)` pairs from the channel.
236 /// 3. Merges with any existing on-disk cache (existing entries are kept
237 /// only if no new entry overrides them).
238 /// 4. Serializes via rkyv and writes atomically to
239 /// `<repo>/.git/<CACHE_FILENAME>`.
240 ///
241 /// Errors are logged but not propagated because cache persistence is
242 /// non-critical: losing the cache only means the next encryption uses
243 /// fresh salts.
244 pub fn save(self) {
245 let Self { rx, repo_path } = self;
246
247 // Collect all entries sent through the channel. The sender side must
248 // have been dropped (or going to be dropped) by the caller before
249 // this call, otherwise `into_iter()` will block.
250 let mut entries: HashMap<Vec<u8>, CachedEntry> = rx.into_iter().collect();
251
252 if entries.is_empty() {
253 debug!("No cache entries to save");
254 return;
255 }
256
257 // Merge with existing cache on disk (keep existing entries only when
258 // no new entry covers the same path).
259 let path = cache_path(&repo_path);
260 if path.exists()
261 && let Ok(existing_bytes) = std::fs::read(&path)
262 && let Ok(existing) =
263 rkyv::from_bytes::<HashMap<Vec<u8>, CachedEntry>, RkyvError>(&existing_bytes)
264 {
265 for (k, v) in existing {
266 entries.entry(k).or_insert(v);
267 }
268 }
269
270 // Serialize and write atomically.
271 match rkyv::to_bytes::<RkyvError>(&entries) {
272 Ok(bytes) => {
273 if let Err(e) = atomic_write(&path, bytes.as_slice()) {
274 warn!("Failed to save salt cache to {}: {e}", path.display());
275 } else {
276 debug!(
277 "Saved salt cache with {} entries to {}",
278 entries.len(),
279 path.display()
280 );
281 }
282 }
283 Err(e) => {
284 warn!("Failed to serialize salt cache: {e}");
285 }
286 }
287 }
288}
289
290/// Create a paired sender/saver for collecting cache entries.
291///
292/// The sender is `Sync` and can be shared across rayon threads. The saver
293/// should be kept on the main thread and `.save()`d after parallel work
294/// completes.
295#[must_use]
296pub fn create_writer(repo_path: &Path) -> (SaltCacheSender, SaltCacheSaver) {
297 let (tx, rx) = mpsc::channel();
298 (
299 SaltCacheSender { tx },
300 SaltCacheSaver {
301 rx,
302 repo_path: repo_path.to_path_buf(),
303 },
304 )
305}
306
307#[cfg(test)]
308mod tests {
309 use tempfile::TempDir;
310
311 use super::*;
312
313 fn make_entry(salt_byte: u8, file_id_byte: u8) -> CachedEntry {
314 CachedEntry {
315 salt: [salt_byte; SALT_LEN],
316 file_id: [file_id_byte; FILE_ID_LEN],
317 }
318 }
319
320 #[test]
321 fn test_reader_get_from_wrong_path() {
322 let dir = TempDir::new().unwrap();
323 let reader = SaltCacheReader::load(dir.path());
324 assert_eq!(reader.get(b"test.txt"), None);
325 }
326
327 #[test]
328 fn test_roundtrip_via_sender_and_reader() {
329 let dir = TempDir::new().unwrap();
330 let repo = dir.path();
331 std::fs::create_dir_all(repo.join(".git")).unwrap();
332
333 let entry1 = make_entry(0x11, 0x22);
334 let entry2 = make_entry(0x33, 0x44);
335
336 {
337 let (sender, saver) = create_writer(repo);
338 sender.insert(b"file1.txt", entry1.clone());
339 sender.insert(b"sub/file2.txt", entry2.clone());
340 // Drop sender to close the channel before saving.
341 drop(sender);
342 saver.save();
343 }
344
345 // Load via reader and verify.
346 let reader = SaltCacheReader::load(repo);
347 assert_eq!(reader.get(b"file1.txt"), Some(entry1));
348 assert_eq!(reader.get(b"sub/file2.txt"), Some(entry2));
349 assert_eq!(reader.get(b"nonexistent.txt"), None);
350 }
351
352 #[test]
353 fn test_load_corrupted_file() {
354 let dir = TempDir::new().unwrap();
355 let repo = dir.path();
356 std::fs::create_dir_all(repo.join(".git")).unwrap();
357
358 let path = cache_path(repo);
359 std::fs::write(&path, b"not valid rkyv data").unwrap();
360
361 // Should return a reader with no data (all lookups return None).
362 let reader = SaltCacheReader::load(repo);
363 assert_eq!(reader.get(b"test.txt"), None);
364 }
365
366 #[test]
367 fn test_overwrite_entry() {
368 let dir = TempDir::new().unwrap();
369 let repo = dir.path();
370 std::fs::create_dir_all(repo.join(".git")).unwrap();
371
372 let entry1 = make_entry(0x11, 0x22);
373 let entry2 = make_entry(0x33, 0x44);
374
375 {
376 let (sender, saver) = create_writer(repo);
377 sender.insert(b"test.txt", entry1);
378 sender.insert(b"test.txt", entry2.clone());
379 drop(sender);
380 saver.save();
381 }
382
383 let reader = SaltCacheReader::load(repo);
384 assert_eq!(reader.get(b"test.txt"), Some(entry2));
385 }
386
387 #[test]
388 fn test_relative_path_key_persistence() {
389 let dir = TempDir::new().unwrap();
390 let repo = dir.path();
391 std::fs::create_dir_all(repo.join(".git")).unwrap();
392
393 let entry = make_entry(0x55, 0x66);
394
395 {
396 let (sender, saver) = create_writer(repo);
397 sender.insert(b"subdir/file.txt", entry.clone());
398 drop(sender);
399 saver.save();
400 }
401
402 let reader = SaltCacheReader::load(repo);
403 assert_eq!(reader.get(b"subdir/file.txt"), Some(entry));
404 }
405
406 #[test]
407 fn test_merge_with_existing() {
408 let dir = TempDir::new().unwrap();
409 let repo = dir.path();
410 std::fs::create_dir_all(repo.join(".git")).unwrap();
411
412 let entry_a = make_entry(0xAA, 0xBB);
413 let entry_b = make_entry(0xCC, 0xDD);
414
415 // Save initial entry.
416 {
417 let (sender, saver) = create_writer(repo);
418 sender.insert(b"existing.txt", entry_a.clone());
419 drop(sender);
420 saver.save();
421 }
422
423 // Save a new entry — the existing one should be preserved via merge.
424 {
425 let (sender, saver) = create_writer(repo);
426 sender.insert(b"new.txt", entry_b.clone());
427 drop(sender);
428 saver.save();
429 }
430
431 let reader = SaltCacheReader::load(repo);
432 assert_eq!(reader.get(b"existing.txt"), Some(entry_a));
433 assert_eq!(reader.get(b"new.txt"), Some(entry_b));
434 }
435}