Skip to main content

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