Skip to main content

keyhog_core/
merkle_index.rs

1//! Incremental scan support via a persisted file-content index.
2//!
3//! ## What it does
4//!
5//! On a fresh scan we compute, for every input chunk, a metadata tuple
6//! `(mtime_ns, size, chunk_offset, BLAKE3(content))` and store it under the
7//! file's canonical path plus chunk offset. On the next run, files whose
8//! `(mtime, size)` match the stored values can be skipped *without re-reading
9//! the bytes* -
10//! they almost certainly haven't changed (rsync-style trust). When
11//! `(mtime, size)` differ but BLAKE3 matches we record the new mtime
12//! and still skip - same content, different stat (touched, copied).
13//!
14//! Tier-B moat innovation #3 from the internal design notes: "10–100×
15//! speedup on CI re-runs" by skipping the 99% of files that didn't change.
16//!
17//! ## Schema versions
18//!
19//! - **v1 (legacy)** - `path → BLAKE3 hex` only. Loadable but lacks the
20//!   metadata short-circuit; treated as cold-start to avoid mixing schemas.
21//! - **v2 (legacy)** - `path → (mtime_ns, size, BLAKE3 hex)` plus a
22//!   top-level `spec_hash` derived from the loaded detector set. A
23//!   spec-hash mismatch invalidates the entire cache; this is the
24//!   correctness fix for "added a detector but unchanged files were
25//!   silently skipped, missing the new detection forever." Superseded by
26//!   v3 and treated as cold-start (it lacks the racy-clean timestamp).
27//! - **v3 (legacy)** - v2 plus a top-level `written_at_ns` (wall-clock
28//!   nanoseconds when the index was last written). On load, any entry
29//!   whose file `mtime_ns` falls in the same clock-second as - or after -
30//!   `written_at_ns` is dropped (git's "racy index" guard): a
31//!   size-preserving edit made in that window leaves `(mtime, size)`
32//!   unchanged on coarse-granularity filesystems (FAT/HFS+/some NFS expose
33//!   whole-second mtimes), so trusting the stored hash would skip a
34//!   freshly injected secret forever. Dropped entries are simply re-read
35//!   and re-hashed on the next scan - slower for those few files, never
36//!   unsound.
37//! - **v4 (current)** - v3 plus an explicit chunk offset in each persisted
38//!   row. Chunked files no longer overwrite every earlier chunk under the
39//!   same path, so incremental scans can skip unchanged large files by chunk
40//!   instead of re-hashing them on every run. Newer v4 rows also carry a
41//!   default-compatible `last_seen_order` so over-cap saves evict oldest cache
42//!   rows deterministically instead of depending on hash-map iteration order.
43//!
44//! ## Serialization
45//!
46//! JSON, on purpose. The dataset is one row per scanned file (≤ ~1M for
47//! any sane repo) and JSON keeps the on-disk format trivial to debug,
48//! diff, and version-control if a team wants to.
49//!
50//! ## Threat model
51//!
52//! Cached entries do NOT contain credentials. Storing a `(mtime, size,
53//! content_hash)` tuple per scanned path leaks that the path *exists*
54//! and what its content fingerprint is, which is why `--lockdown`
55//! refuses to load or write the cache at all.
56
57use indexmap::{map::Entry, Equivalent, IndexMap};
58use std::hash::{Hash, Hasher};
59use std::path::{Path, PathBuf};
60use std::sync::atomic::{AtomicU64, Ordering};
61use std::time::SystemTime;
62
63use parking_lot::RwLock;
64
65// Disk persistence and stale-tmp hygiene are separate filesystem responsibilities;
66// the root module owns only the live index and calls those owners through methods.
67mod storage;
68mod tmp_hygiene;
69
70pub use storage::{default_cache_path, merkle_default_cache_path};
71
72const SCHEMA_VERSION: u32 = 4;
73
74/// Shard count: spreads concurrent `record` / `unchanged` calls across
75/// independent locks so tiny-file storms don't serialize all rayon workers.
76const MERKLE_SHARDS: usize = 64;
77
78type MerkleShardBuildHasher = ahash::RandomState;
79type MerkleShardMap = IndexMap<CacheKey, CacheEntry, MerkleShardBuildHasher>;
80
81#[cfg(target_pointer_width = "64")]
82const SHARD_MIX: usize = 0x517c_c1b7_2722_0a95;
83#[cfg(target_pointer_width = "32")]
84const SHARD_MIX: usize = 0x9e37_79b1;
85
86/// Default upper bound on the number of in-memory cache entries.
87///
88/// Resident cost per entry is roughly `56 bytes` for the [`CacheEntry`]
89/// (`mtime_ns: u64` + `size: u64` + `last_seen_order: u64` + `hash: [u8; 32]`) plus
90/// the heap-allocated [`PathBuf`] key (one allocation, length of the
91/// canonical path). On a typical repo a path averages ~80-120 bytes, so
92/// budget ~150 bytes/entry end-to-end. At the default cap of 8M entries
93/// that bounds the index at roughly 1.2 GB resident - large, but bounded,
94/// and survivable on the fleet's 32-128 GB boxes. A giant monorepo can
95/// raise or lower this via [`MerkleIndex::with_max_entries`] (Tier-A
96/// configurability: compiled default, overridable by the caller).
97///
98/// When the cap is hit we WARN and stop *adding new paths*; updates to
99/// paths already in the index are always allowed so an over-cap scan
100/// never corrupts an existing entry. An uncached file is simply re-read
101/// and re-scanned next run - slower, never unsound. This preserves the
102/// module's core guarantee (a file that ever produced a finding is
103/// `forget`-ten, never cached) regardless of the cap.
104const MERKLE_DEFAULT_MAX_ENTRIES: usize = 8_000_000;
105
106/// Result of loading a persisted [`MerkleIndex`] cache.
107#[derive(Debug)]
108pub struct MerkleLoadReport {
109    index: MerkleIndex,
110    status: MerkleLoadStatus,
111}
112
113impl MerkleLoadReport {
114    /// Status describing whether the cache was loaded or why it cold-started.
115    pub fn status(&self) -> &MerkleLoadStatus {
116        &self.status
117    }
118
119    /// Consume the report and return the live in-memory index.
120    pub fn into_index(self) -> MerkleIndex {
121        self.index
122    }
123}
124
125/// Operator-relevant status for a Merkle cache load.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub enum MerkleLoadStatus {
128    /// No cache file exists yet.
129    Missing {
130        /// Cache path that was probed.
131        path: PathBuf,
132    },
133    /// Cache file loaded and contributed `entries` entries.
134    Loaded {
135        /// Cache path that loaded successfully.
136        path: PathBuf,
137        /// Number of entries retained after validation and racy-clean drops.
138        entries: usize,
139    },
140    /// Cache file existed but could not be read.
141    ReadFailed {
142        /// Cache path that could not be read.
143        path: PathBuf,
144        /// Filesystem error message.
145        error: String,
146    },
147    /// Cache file existed but was not valid JSON for the current schema.
148    ParseFailed {
149        /// Cache path that could not be parsed.
150        path: PathBuf,
151        /// JSON/schema parse error message.
152        error: String,
153    },
154    /// Cache file used an incompatible schema version.
155    SchemaMismatch {
156        /// Cache path that used the incompatible schema.
157        path: PathBuf,
158        /// Schema version found in the cache file.
159        version: u32,
160        /// Schema version required by this binary.
161        expected: u32,
162    },
163    /// Cache file was built for a different detector corpus or scan config.
164    SpecChanged {
165        /// Cache path whose stored detector-spec hash did not match.
166        path: PathBuf,
167    },
168    /// One persisted entry carried an invalid BLAKE3 hex digest.
169    InvalidEntryHash {
170        /// Cache path containing the invalid entry.
171        path: PathBuf,
172        /// Persisted source path key for the invalid entry.
173        entry_path: String,
174        /// Invalid hash string from the cache file.
175        hash: String,
176    },
177}
178
179fn shard_index(path: &Path) -> usize {
180    #[cfg(unix)]
181    {
182        use std::os::unix::ffi::OsStrExt;
183        shard_index_bytes(path.as_os_str().as_bytes())
184    }
185
186    #[cfg(not(unix))]
187    {
188        shard_index_bytes(path.as_os_str().to_string_lossy().as_bytes())
189    }
190}
191
192#[inline]
193fn shard_index_bytes(bytes: &[u8]) -> usize {
194    debug_assert!(MERKLE_SHARDS.is_power_of_two());
195    let mut hash = SHARD_MIX ^ bytes.len();
196    for &byte in bytes {
197        hash ^= usize::from(byte);
198        hash = hash.rotate_left(5).wrapping_mul(SHARD_MIX);
199    }
200    hash & (MERKLE_SHARDS - 1)
201}
202
203#[inline]
204fn shard_capacity(max_entries: usize) -> usize {
205    if max_entries == 0 {
206        0
207    } else {
208        (max_entries / MERKLE_SHARDS) + usize::from(max_entries % MERKLE_SHARDS != 0)
209    }
210}
211
212/// Live-cache key. Sharding stays path-based so all chunks from one file live
213/// in the same shard; `forget(path)` can then evict the whole file cheaply.
214#[derive(Debug, Clone, PartialEq, Eq, Hash)]
215struct CacheKey {
216    path: PathBuf,
217    chunk_offset: u64,
218}
219
220impl CacheKey {
221    fn file(path: PathBuf) -> Self {
222        Self::chunk(path, 0)
223    }
224
225    fn chunk(path: PathBuf, chunk_offset: u64) -> Self {
226        Self { path, chunk_offset }
227    }
228}
229
230struct CacheKeyRef<'a> {
231    path: &'a Path,
232    chunk_offset: u64,
233}
234
235impl Hash for CacheKeyRef<'_> {
236    fn hash<H: Hasher>(&self, state: &mut H) {
237        self.path.hash(state);
238        self.chunk_offset.hash(state);
239    }
240}
241
242impl Equivalent<CacheKey> for CacheKeyRef<'_> {
243    fn equivalent(&self, key: &CacheKey) -> bool {
244        self.path == key.path.as_path() && self.chunk_offset == key.chunk_offset
245    }
246}
247
248/// In-memory per-entry record. Mirrors the durable storage entry but holds the hash as
249/// a fixed-size array - saves the per-lookup hex-decode cost on the
250/// `unchanged` hot path.
251#[derive(Debug, Clone, Copy)]
252struct CacheEntry {
253    mtime_ns: u64,
254    size: u64,
255    last_seen_order: u64,
256    hash: [u8; 32],
257}
258
259#[derive(Clone, Copy, Debug, PartialEq, Eq)]
260struct CacheFileFingerprint {
261    modified: SystemTime,
262    len: u64,
263}
264
265/// In-memory file-hash index loaded from / saved to a JSON cache file.
266///
267/// Concurrency model: the orchestrator holds an `Arc<MerkleIndex>` and
268/// records new entries as chunks arrive from rayon-parallel sources.
269/// Paths are sharded across [`MERKLE_SHARDS`] mutex-protected maps so
270/// concurrent updates rarely contend.
271#[derive(Debug)]
272pub struct MerkleIndex {
273    shards: Vec<RwLock<MerkleShardMap>>,
274    /// Upper bound on the number of retained entries across all shards.
275    /// Defaults to [`MERKLE_DEFAULT_MAX_ENTRIES`]. Once reached, only
276    /// updates to existing paths are accepted; new paths are dropped
277    /// (with a one-shot WARN) so a giant monorepo can't silently grow
278    /// the index without bound.
279    max_entries: usize,
280    /// Set once the cap is first hit so we WARN at most once per index
281    /// rather than once per dropped entry (which would be a log storm
282    /// on a multi-million-file overflow).
283    cap_warned: std::sync::atomic::AtomicBool,
284    /// Approximate live entry count, maintained on the insert hot path so
285    /// the cap check is O(1) instead of summing all 64 shard lengths per
286    /// insert (that scan would dominate a multi-million-file scan). It is
287    /// incremented only on a NEW-path insert and never decremented (the
288    /// `forget` path is for found-secret invalidation, not bulk eviction),
289    /// so it is a monotonic upper bound on live entries - exactly the
290    /// conservative side for a "stop growing" budget. Exact counts use
291    /// [`Self::len`].
292    approx_count: std::sync::atomic::AtomicUsize,
293    /// Fingerprint of the cache file that populated this index, or the cache
294    /// file written by the most recent successful save. Save uses this to skip
295    /// the expensive read/parse merge when disk has not changed under us.
296    cache_file_fingerprint: RwLock<Option<CacheFileFingerprint>>,
297    /// Monotonic access order used for deterministic persisted-cache eviction.
298    /// New or updated entries receive a larger value than entries loaded from
299    /// disk, so over-cap saves evict stale rows before fresh scan work.
300    access_order: AtomicU64,
301}
302
303impl MerkleIndex {
304    /// Construct a fresh, empty [`MerkleIndex`] with no cached entries and
305    /// the default entry cap ([`MERKLE_DEFAULT_MAX_ENTRIES`]).
306    fn empty() -> Self {
307        Self::with_max_entries(MERKLE_DEFAULT_MAX_ENTRIES)
308    }
309
310    /// Construct a fresh, empty [`MerkleIndex`] with an explicit entry cap.
311    /// A cap of `0` is treated as "unbounded" for callers that genuinely
312    /// want the old behavior, but the documented resident cost still
313    /// applies (~150 bytes/entry).
314    pub(crate) fn with_max_entries(max_entries: usize) -> Self {
315        let shard_capacity = shard_capacity(max_entries);
316        Self {
317            shards: (0..MERKLE_SHARDS)
318                .map(|_| {
319                    RwLock::new(IndexMap::with_capacity_and_hasher(
320                        shard_capacity,
321                        MerkleShardBuildHasher::default(),
322                    ))
323                })
324                .collect(),
325            max_entries,
326            cap_warned: std::sync::atomic::AtomicBool::new(false),
327            approx_count: std::sync::atomic::AtomicUsize::new(0),
328            cache_file_fingerprint: RwLock::new(None),
329            access_order: AtomicU64::new(0),
330        }
331    }
332
333    /// The configured maximum number of retained entries (`0` = unbounded).
334    pub(crate) fn max_entries(&self) -> usize {
335        self.max_entries
336    }
337
338    /// Hash the given content with BLAKE3 (32-byte output).
339    pub(crate) fn hash_content(content: &[u8]) -> [u8; 32] {
340        *blake3::hash(content).as_bytes()
341    }
342
343    /// Record one observed chunk at its absolute byte offset and return `true`
344    /// when the same `(path, chunk_offset)` previously held the same content.
345    pub fn record_chunk_at_offset_and_check_unchanged(
346        &self,
347        path: PathBuf,
348        chunk_offset: u64,
349        mtime_ns: u64,
350        size: u64,
351        content: &[u8],
352    ) -> bool {
353        self.record_chunk_path_at_offset_and_check_unchanged(
354            path.as_path(),
355            chunk_offset,
356            mtime_ns,
357            size,
358            content,
359        )
360    }
361
362    /// Borrowing variant for hot dispatch loops that already hold a path
363    /// string/reference. The index still owns the persisted key, but callers do
364    /// not need to allocate a temporary `PathBuf` before handing it off.
365    pub fn record_chunk_path_at_offset_and_check_unchanged(
366        &self,
367        path: &Path,
368        chunk_offset: u64,
369        mtime_ns: u64,
370        size: u64,
371        content: &[u8],
372    ) -> bool {
373        let content_hash = Self::hash_content(content);
374        self.record_borrowed_key_at_offset_with_metadata(
375            path,
376            chunk_offset,
377            CacheEntry {
378                mtime_ns,
379                size,
380                last_seen_order: self.next_access_order(),
381                hash: content_hash,
382            },
383        )
384    }
385
386    /// Returns `true` when `path` was previously indexed with the SAME
387    /// content hash. Kept for callers that already have the hash in hand
388    /// (e.g. the orchestrator's chunk-level skip path).
389    pub(crate) fn unchanged(&self, path: &Path, content_hash: &[u8; 32]) -> bool {
390        // perf: borrow the path via CacheKeyRef (zero-alloc Equivalent lookup,
391        // same as the write path at `record_*`) instead of allocating a
392        // CacheKey::file(path.to_path_buf()) on every chunk-level skip check.
393        let i = shard_index(path);
394        self.shards[i]
395            .read()
396            .get(&CacheKeyRef {
397                path,
398                chunk_offset: 0,
399            })
400            .is_some_and(|prev| &prev.hash == content_hash)
401    }
402
403    /// Returns `true` when `(path, mtime_ns, size)` exactly matches a
404    /// stored entry. This is the **fast-path skip** - it avoids reading
405    /// the file at all, which is the dominant cost on cold-cache disk.
406    /// A `false` return means "either we've never seen this path, or
407    /// metadata differs - caller must read + hash to decide."
408    pub fn metadata_unchanged(&self, path: &Path, mtime_ns: u64, size: u64) -> bool {
409        // perf: this is the per-file fast-path skip (dominant cold-cache cost);
410        // borrow the path via CacheKeyRef so it never allocates a PathBuf.
411        let i = shard_index(path);
412        self.shards[i]
413            .read()
414            .get(&CacheKeyRef {
415                path,
416                chunk_offset: 0,
417            })
418            .is_some_and(|prev| prev.mtime_ns == mtime_ns && prev.size == size)
419    }
420
421    /// Returns the stored `(mtime_ns, size, content_hash)` for `path`,
422    /// or `None` if the index hasn't seen it. Used by paranoid-mode
423    /// verifiers that want to confirm content didn't change even when
424    /// metadata happens to match.
425    pub(crate) fn lookup(&self, path: &Path) -> Option<(u64, u64, [u8; 32])> {
426        // perf: zero-alloc borrowed lookup (see `metadata_unchanged`).
427        let i = shard_index(path);
428        self.shards[i]
429            .read()
430            .get(&CacheKeyRef {
431                path,
432                chunk_offset: 0,
433            })
434            .map(|e| (e.mtime_ns, e.size, e.hash))
435    }
436
437    /// Seed a file's metadata + content hash for the public test facade.
438    /// Production ingestion uses the chunk-aware record-and-compare path.
439    /// Overwrites any prior
440    /// entry at the same path. The path-shard mutex is held for the
441    /// duration of the insert only; concurrent recordings against
442    /// different shards never contend.
443    pub(crate) fn seed_for_testing(
444        &self,
445        path: PathBuf,
446        mtime_ns: u64,
447        size: u64,
448        content_hash: [u8; 32],
449    ) {
450        self.record_key_with_metadata(
451            CacheKey::file(path),
452            CacheEntry {
453                mtime_ns,
454                size,
455                last_seen_order: self.next_access_order(),
456                hash: content_hash,
457            },
458        );
459    }
460
461    fn next_access_order(&self) -> u64 {
462        let previous =
463            match self
464                .access_order
465                .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
466                    Some(current.saturating_add(1))
467                }) {
468                Ok(previous) => previous,
469                Err(current) => current, // LAW10: closure always returns Some; Err is unreachable, and preserving current keeps the monotonic counter conservative if the API contract changes.
470            };
471        previous.saturating_add(1)
472    }
473
474    fn observe_loaded_access_order(&self, loaded_order: u64) {
475        if let Err(current) =
476            self.access_order
477                .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
478                    (loaded_order > current).then_some(loaded_order)
479                })
480        {
481            debug_assert!(current >= loaded_order);
482        }
483    }
484
485    fn record_key_with_metadata(&self, key: CacheKey, entry: CacheEntry) {
486        self.try_insert(key, entry);
487    }
488
489    fn record_borrowed_key_at_offset_with_metadata(
490        &self,
491        path: &Path,
492        chunk_offset: u64,
493        entry: CacheEntry,
494    ) -> bool {
495        let i = shard_index(path);
496        let mut shard = self.shards[i].write();
497        let lookup = CacheKeyRef { path, chunk_offset };
498        if let Some(previous) = shard.get_mut(&lookup) {
499            let unchanged = previous.hash == entry.hash;
500            *previous = entry;
501            return unchanged;
502        }
503
504        if self.entry_cap_reached() {
505            return false;
506        }
507        shard.insert(CacheKey::chunk(path.to_path_buf(), chunk_offset), entry);
508        self.approx_count.fetch_add(1, Ordering::Relaxed);
509        false
510    }
511
512    /// Insert or update one entry, honoring [`Self::max_entries`].
513    ///
514    /// Returns `true` if the entry is now present (inserted or updated),
515    /// `false` if it was a NEW path dropped because the cap is reached.
516    /// Updates to an already-present path always succeed (they don't grow
517    /// the working set) so an over-cap scan never corrupts existing state.
518    /// The first drop emits a single WARN; subsequent drops are silent to
519    /// avoid a log storm on a multi-million-file overflow.
520    fn try_insert(&self, key: CacheKey, entry: CacheEntry) -> bool {
521        let i = shard_index(&key.path);
522        let mut shard = self.shards[i].write();
523        let slot = match shard.entry(key) {
524            Entry::Occupied(mut slot) => {
525                slot.insert(entry);
526                return true;
527            }
528            Entry::Vacant(slot) => slot,
529        };
530
531        // `max_entries == 0` means unbounded (opt-in legacy behavior).
532        // The cap is a soft budget checked against `approx_count` (O(1),
533        // no shard scan). Concurrent new-path inserts across shards can
534        // overshoot by at most the number of in-flight `record` calls -
535        // bounded and harmless (a few entries over budget, never
536        // unbounded growth).
537        if self.entry_cap_reached() {
538            return false;
539        }
540        slot.insert(entry);
541        self.approx_count.fetch_add(1, Ordering::Relaxed);
542        true
543    }
544
545    fn entry_cap_reached(&self) -> bool {
546        if self.max_entries == 0 || self.approx_count.load(Ordering::Relaxed) < self.max_entries {
547            return false;
548        }
549        if !self.cap_warned.swap(true, Ordering::Relaxed) {
550            tracing::warn!(
551                cap = self.max_entries,
552                "merkle index entry cap reached; new paths will not be \
553                 cached this run (they are re-scanned next run). Raise \
554                 the cap for very large trees if the rescan cost matters."
555            );
556        }
557        true
558    }
559
560    /// Remove `path` from the index so the next scan treats it as new and
561    /// re-reads + re-scans it.
562    ///
563    /// This is how incremental mode keeps its core safety guarantee: a file
564    /// that produced ANY finding is never cached, so a secret in an otherwise
565    /// unchanged file still surfaces on every later run instead of being
566    /// silently skipped (the failure this module's own header warns about).
567    /// Clean files - the 99% - stay cached, so the 10-100x speedup is
568    /// unaffected, and because we store the ABSENCE of an entry rather than the
569    /// finding, no secret value ever touches the on-disk index.
570    pub fn forget(&self, path: &Path) {
571        let i = shard_index(path);
572        self.shards[i].write().retain(|key, _| key.path != path);
573    }
574
575    /// Number of indexed entries.
576    pub(crate) fn len(&self) -> usize {
577        self.shards.iter().map(|s| s.read().len()).sum()
578    }
579
580    /// Returns true if no cached entries are present across any shard.
581    pub(crate) fn is_empty(&self) -> bool {
582        self.shards.iter().all(|s| s.read().is_empty())
583    }
584}
585
586impl Default for MerkleIndex {
587    fn default() -> Self {
588        Self::empty()
589    }
590}