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
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.
225///
226/// # Drop safety
227///
228/// [`Drop`] is implemented as a safety net: if [`save`](Self::save) is not
229/// called (e.g. due to a panic during parallel decryption), any entries
230/// already buffered in the channel are still persisted. This honors the
231/// module-level contract that partial progress is preserved on error.
232pub struct SaltCacheSaver {
233    /// `Option` so [`save_inner`] can take it exactly once; subsequent `Drop`
234    /// becomes a no-op.
235    rx: Option<mpsc::Receiver<(Vec<u8>, CachedEntry)>>,
236    repo_path: PathBuf,
237}
238
239impl SaltCacheSaver {
240    /// Persist all collected entries to disk (best-effort, atomic).
241    ///
242    /// 1. Collects all `(key, entry)` pairs currently buffered in the channel
243    ///    via [`mpsc::Receiver::try_iter`] (non-blocking — by the time this is
244    ///    called, all rayon workers have finished, so every sent entry is
245    ///    already buffered).
246    /// 2. Merges with any existing on-disk cache (existing entries are kept
247    ///    only if no new entry overrides them).
248    /// 3. Serializes via rkyv and writes atomically to
249    ///    `<repo>/.git/<CACHE_FILENAME>`.
250    ///
251    /// Safe to call exactly once; a paired [`Drop`] impl guards the
252    /// panic-on-drop path. Errors are logged but not propagated because cache
253    /// persistence is non-critical: losing the cache only means the next
254    /// encryption uses fresh salts.
255    pub fn save(mut self) {
256        self.save_inner();
257    }
258
259    fn save_inner(&mut self) {
260        // `take()` ensures the body runs at most once across `save()` + `Drop`.
261        let Some(rx) = self.rx.take() else {
262            return;
263        };
264
265        // Use `try_iter` (non-blocking) rather than `into_iter` so that:
266        //   - explicit `save()` does not require the caller to drop the sender first
267        //     (removing a brittle ordering contract);
268        //   - the `Drop` impl cannot deadlock if the paired `SaltCacheSender` is
269        //     dropped after `self` under non-2024 drop ordering.
270        // All rayon workers have returned by the time we get here, so every
271        // sent entry is already in the channel buffer.
272        let mut entries: HashMap<Vec<u8>, CachedEntry> = rx.try_iter().collect();
273
274        if entries.is_empty() {
275            debug!("No cache entries to save");
276            return;
277        }
278
279        // Merge with existing cache on disk (keep existing entries only when
280        // no new entry covers the same path).
281        let path = cache_path(&self.repo_path);
282        if path.exists()
283            && let Ok(existing_bytes) = std::fs::read(&path)
284            && let Ok(existing) =
285                rkyv::from_bytes::<HashMap<Vec<u8>, CachedEntry>, RkyvError>(&existing_bytes)
286        {
287            for (k, v) in existing {
288                entries.entry(k).or_insert(v);
289            }
290        }
291
292        // Serialize and write atomically.
293        match rkyv::to_bytes::<RkyvError>(&entries) {
294            Ok(bytes) => {
295                if let Err(e) = atomic_write(&path, bytes.as_slice()) {
296                    warn!("Failed to save salt cache to {}: {e}", path.display());
297                } else {
298                    debug!(
299                        "Saved salt cache with {} entries to {}",
300                        entries.len(),
301                        path.display()
302                    );
303                }
304            }
305            Err(e) => {
306                warn!("Failed to serialize salt cache: {e}");
307            }
308        }
309    }
310}
311
312/// Create a paired sender/saver for collecting cache entries.
313///
314/// The sender is `Sync` and can be shared across rayon threads. The saver
315/// should be kept on the main thread and `.save()`d after parallel work
316/// completes. If `.save()` is not called, [`SaltCacheSaver::drop`] will
317/// persist any buffered entries as a safety net.
318#[must_use]
319pub fn create_writer(repo_path: &Path) -> (SaltCacheSender, SaltCacheSaver) {
320    let (tx, rx) = mpsc::channel();
321    (
322        SaltCacheSender { tx },
323        SaltCacheSaver {
324            rx: Some(rx),
325            repo_path: repo_path.to_path_buf(),
326        },
327    )
328}
329
330impl Drop for SaltCacheSaver {
331    fn drop(&mut self) {
332        self.save_inner();
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use tempfile::TempDir;
339
340    use super::*;
341
342    fn make_entry(salt_byte: u8, file_id_byte: u8) -> CachedEntry {
343        CachedEntry {
344            salt: [salt_byte; SALT_LEN],
345            file_id: [file_id_byte; FILE_ID_LEN],
346        }
347    }
348
349    #[test]
350    fn test_reader_get_from_wrong_path() {
351        let dir = TempDir::new().unwrap();
352        let reader = SaltCacheReader::load(dir.path());
353        assert_eq!(reader.get(b"test.txt"), None);
354    }
355
356    #[test]
357    fn test_roundtrip_via_sender_and_reader() {
358        let dir = TempDir::new().unwrap();
359        let repo = dir.path();
360        std::fs::create_dir_all(repo.join(".git")).unwrap();
361
362        let entry1 = make_entry(0x11, 0x22);
363        let entry2 = make_entry(0x33, 0x44);
364
365        {
366            let (sender, saver) = create_writer(repo);
367            sender.insert(b"file1.txt", entry1.clone());
368            sender.insert(b"sub/file2.txt", entry2.clone());
369            // Drop sender to close the channel before saving.
370            drop(sender);
371            saver.save();
372        }
373
374        // Load via reader and verify.
375        let reader = SaltCacheReader::load(repo);
376        assert_eq!(reader.get(b"file1.txt"), Some(entry1));
377        assert_eq!(reader.get(b"sub/file2.txt"), Some(entry2));
378        assert_eq!(reader.get(b"nonexistent.txt"), None);
379    }
380
381    #[test]
382    fn test_load_corrupted_file() {
383        let dir = TempDir::new().unwrap();
384        let repo = dir.path();
385        std::fs::create_dir_all(repo.join(".git")).unwrap();
386
387        let path = cache_path(repo);
388        std::fs::write(&path, b"not valid rkyv data").unwrap();
389
390        // Should return a reader with no data (all lookups return None).
391        let reader = SaltCacheReader::load(repo);
392        assert_eq!(reader.get(b"test.txt"), None);
393    }
394
395    #[test]
396    fn test_overwrite_entry() {
397        let dir = TempDir::new().unwrap();
398        let repo = dir.path();
399        std::fs::create_dir_all(repo.join(".git")).unwrap();
400
401        let entry1 = make_entry(0x11, 0x22);
402        let entry2 = make_entry(0x33, 0x44);
403
404        {
405            let (sender, saver) = create_writer(repo);
406            sender.insert(b"test.txt", entry1);
407            sender.insert(b"test.txt", entry2.clone());
408            drop(sender);
409            saver.save();
410        }
411
412        let reader = SaltCacheReader::load(repo);
413        assert_eq!(reader.get(b"test.txt"), Some(entry2));
414    }
415
416    #[test]
417    fn test_relative_path_key_persistence() {
418        let dir = TempDir::new().unwrap();
419        let repo = dir.path();
420        std::fs::create_dir_all(repo.join(".git")).unwrap();
421
422        let entry = make_entry(0x55, 0x66);
423
424        {
425            let (sender, saver) = create_writer(repo);
426            sender.insert(b"subdir/file.txt", entry.clone());
427            drop(sender);
428            saver.save();
429        }
430
431        let reader = SaltCacheReader::load(repo);
432        assert_eq!(reader.get(b"subdir/file.txt"), Some(entry));
433    }
434
435    #[test]
436    fn test_merge_with_existing() {
437        let dir = TempDir::new().unwrap();
438        let repo = dir.path();
439        std::fs::create_dir_all(repo.join(".git")).unwrap();
440
441        let entry_a = make_entry(0xAA, 0xBB);
442        let entry_b = make_entry(0xCC, 0xDD);
443
444        // Save initial entry.
445        {
446            let (sender, saver) = create_writer(repo);
447            sender.insert(b"existing.txt", entry_a.clone());
448            drop(sender);
449            saver.save();
450        }
451
452        // Save a new entry — the existing one should be preserved via merge.
453        {
454            let (sender, saver) = create_writer(repo);
455            sender.insert(b"new.txt", entry_b.clone());
456            drop(sender);
457            saver.save();
458        }
459
460        let reader = SaltCacheReader::load(repo);
461        assert_eq!(reader.get(b"existing.txt"), Some(entry_a));
462        assert_eq!(reader.get(b"new.txt"), Some(entry_b));
463    }
464}