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 = 5;
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    /// Inode change time. `0` means "unknown" (legacy v4 entry or a platform
255    /// with no change time); such entries are never trusted by the read-free
256    /// fast-path skip.
257    ctime_ns: u64,
258    size: u64,
259    last_seen_order: u64,
260    hash: [u8; 32],
261}
262
263#[derive(Clone, Copy, Debug, PartialEq, Eq)]
264struct CacheFileFingerprint {
265    modified: SystemTime,
266    len: u64,
267}
268
269/// In-memory file-hash index loaded from / saved to a JSON cache file.
270///
271/// Concurrency model: the orchestrator holds an `Arc<MerkleIndex>` and
272/// records new entries as chunks arrive from rayon-parallel sources.
273/// Paths are sharded across [`MERKLE_SHARDS`] mutex-protected maps so
274/// concurrent updates rarely contend.
275#[derive(Debug)]
276pub struct MerkleIndex {
277    shards: Vec<RwLock<MerkleShardMap>>,
278    /// Upper bound on the number of retained entries across all shards.
279    /// Defaults to [`MERKLE_DEFAULT_MAX_ENTRIES`]. Once reached, only
280    /// updates to existing paths are accepted; new paths are dropped
281    /// (with a one-shot WARN) so a giant monorepo can't silently grow
282    /// the index without bound.
283    max_entries: usize,
284    /// Set once the cap is first hit so we WARN at most once per index
285    /// rather than once per dropped entry (which would be a log storm
286    /// on a multi-million-file overflow).
287    cap_warned: std::sync::atomic::AtomicBool,
288    /// Approximate live entry count, maintained on the insert hot path so
289    /// the cap check is O(1) instead of summing all 64 shard lengths per
290    /// insert (that scan would dominate a multi-million-file scan). It is
291    /// incremented only on a NEW-path insert and never decremented (the
292    /// `forget` path is for found-secret invalidation, not bulk eviction),
293    /// so it is a monotonic upper bound on live entries - exactly the
294    /// conservative side for a "stop growing" budget. Exact counts use
295    /// [`Self::len`].
296    approx_count: std::sync::atomic::AtomicUsize,
297    /// Fingerprint of the cache file that populated this index, or the cache
298    /// file written by the most recent successful save. Save uses this to skip
299    /// the expensive read/parse merge when disk has not changed under us.
300    cache_file_fingerprint: RwLock<Option<CacheFileFingerprint>>,
301    /// Monotonic access order used for deterministic persisted-cache eviction.
302    /// New or updated entries receive a larger value than entries loaded from
303    /// disk, so over-cap saves evict stale rows before fresh scan work.
304    access_order: AtomicU64,
305}
306
307impl MerkleIndex {
308    /// Construct a fresh, empty [`MerkleIndex`] with no cached entries and
309    /// the default entry cap ([`MERKLE_DEFAULT_MAX_ENTRIES`]).
310    pub fn empty() -> Self {
311        Self::with_max_entries(MERKLE_DEFAULT_MAX_ENTRIES)
312    }
313
314    /// Construct a fresh, empty [`MerkleIndex`].
315    pub fn new() -> Self {
316        Self::empty()
317    }
318
319    /// Construct a fresh, empty [`MerkleIndex`] with an explicit entry cap.
320    /// A cap of `0` is treated as "unbounded" for callers that genuinely
321    /// want the old behavior, but the documented resident cost still
322    /// applies (~150 bytes/entry).
323    pub(crate) fn with_max_entries(max_entries: usize) -> Self {
324        let shard_capacity = shard_capacity(max_entries);
325        Self {
326            shards: (0..MERKLE_SHARDS)
327                .map(|_| {
328                    RwLock::new(IndexMap::with_capacity_and_hasher(
329                        shard_capacity,
330                        MerkleShardBuildHasher::default(),
331                    ))
332                })
333                .collect(),
334            max_entries,
335            cap_warned: std::sync::atomic::AtomicBool::new(false),
336            approx_count: std::sync::atomic::AtomicUsize::new(0),
337            cache_file_fingerprint: RwLock::new(None),
338            access_order: AtomicU64::new(0),
339        }
340    }
341
342    /// The configured maximum number of retained entries (`0` = unbounded).
343    pub(crate) fn max_entries(&self) -> usize {
344        self.max_entries
345    }
346
347    /// Hash the given content with BLAKE3 (32-byte output).
348    pub(crate) fn hash_content(content: &[u8]) -> [u8; 32] {
349        *blake3::hash(content).as_bytes()
350    }
351
352    /// Record one observed chunk at its absolute byte offset and return `true`
353    /// when the same `(path, chunk_offset)` previously held the same content.
354    pub fn record_chunk_at_offset_and_check_unchanged(
355        &self,
356        path: PathBuf,
357        chunk_offset: u64,
358        mtime_ns: u64,
359        ctime_ns: u64,
360        size: u64,
361        content: &[u8],
362    ) -> bool {
363        self.record_chunk_path_at_offset_and_check_unchanged(
364            path.as_path(),
365            chunk_offset,
366            mtime_ns,
367            ctime_ns,
368            size,
369            content,
370        )
371    }
372
373    /// Borrowing variant for hot dispatch loops that already hold a path
374    /// string/reference. The index still owns the persisted key, but callers do
375    /// not need to allocate a temporary `PathBuf` before handing it off.
376    pub fn record_chunk_path_at_offset_and_check_unchanged(
377        &self,
378        path: &Path,
379        chunk_offset: u64,
380        mtime_ns: u64,
381        ctime_ns: u64,
382        size: u64,
383        content: &[u8],
384    ) -> bool {
385        let content_hash = Self::hash_content(content);
386        self.record_borrowed_key_at_offset_with_metadata(
387            path,
388            chunk_offset,
389            CacheEntry {
390                mtime_ns,
391                ctime_ns,
392                size,
393                last_seen_order: self.next_access_order(),
394                hash: content_hash,
395            },
396        )
397    }
398
399    /// Returns `true` when `path` was previously indexed with the SAME
400    /// content hash. Kept for callers that already have the hash in hand
401    /// (e.g. the orchestrator's chunk-level skip path).
402    pub(crate) fn unchanged(&self, path: &Path, content_hash: &[u8; 32]) -> bool {
403        let _profile = keyhog_profile::span(keyhog_profile::Stage::IncrementalLookup);
404        // perf: borrow the path via CacheKeyRef (zero-alloc Equivalent lookup,
405        // same as the write path at `record_*`) instead of allocating a
406        // CacheKey::file(path.to_path_buf()) on every chunk-level skip check.
407        let i = shard_index(path);
408        self.shards[i]
409            .read()
410            .get(&CacheKeyRef {
411                path,
412                chunk_offset: 0,
413            })
414            .is_some_and(|prev| &prev.hash == content_hash)
415    }
416
417    /// Returns `true` when the stored entry for `path` carries exactly this
418    /// `(mtime_ns, ctime_ns, size)` identity. This is the **fast-path skip** -
419    /// it avoids reading the file at all, which is the dominant cost on
420    /// cold-cache disk. A `false` return means "either we've never seen this
421    /// path, or metadata differs - caller must read + hash to decide."
422    ///
423    /// The change time is part of the identity: a userspace tamper can restore
424    /// mtime and size over modified content, but the kernel-owned inode change
425    /// time moves on any write and cannot be set back. An entry recorded
426    /// without one (`0`: legacy v4 cache row, or a platform whose stat has no
427    /// change time) is never trusted here.
428    pub fn metadata_unchanged(&self, path: &Path, mtime_ns: u64, ctime_ns: u64, size: u64) -> bool {
429        let _profile = keyhog_profile::span(keyhog_profile::Stage::IncrementalLookup);
430        // perf: this is the per-file fast-path skip (dominant cold-cache cost);
431        // borrow the path via CacheKeyRef so it never allocates a PathBuf.
432        let i = shard_index(path);
433        self.shards[i]
434            .read()
435            .get(&CacheKeyRef {
436                path,
437                chunk_offset: 0,
438            })
439            .is_some_and(|prev| {
440                prev.mtime_ns == mtime_ns
441                    && prev.size == size
442                    && prev.ctime_ns != 0
443                    && prev.ctime_ns == ctime_ns
444            })
445    }
446
447    /// Returns the stored `(mtime_ns, size, content_hash)` for `path`,
448    /// or `None` if the index hasn't seen it. Used by paranoid-mode
449    /// verifiers that want to confirm content didn't change even when
450    /// metadata happens to match.
451    pub(crate) fn lookup(&self, path: &Path) -> Option<(u64, u64, [u8; 32])> {
452        let _profile = keyhog_profile::span(keyhog_profile::Stage::IncrementalLookup);
453        // perf: zero-alloc borrowed lookup (see `metadata_unchanged`).
454        let i = shard_index(path);
455        self.shards[i]
456            .read()
457            .get(&CacheKeyRef {
458                path,
459                chunk_offset: 0,
460            })
461            .map(|e| (e.mtime_ns, e.size, e.hash))
462    }
463    pub(crate) fn seed_for_testing(
464        &self,
465        path: PathBuf,
466        mtime_ns: u64,
467        ctime_ns: u64,
468        size: u64,
469        content_hash: [u8; 32],
470    ) {
471        self.record_key_with_metadata(
472            CacheKey::file(path),
473            CacheEntry {
474                mtime_ns,
475                ctime_ns,
476                size,
477                last_seen_order: self.next_access_order(),
478                hash: content_hash,
479            },
480        );
481    }
482
483    fn next_access_order(&self) -> u64 {
484        self.access_order
485            .fetch_add(1, Ordering::Relaxed)
486            .saturating_add(1)
487    }
488
489    fn observe_loaded_access_order(&self, loaded_order: u64) {
490        self.access_order.fetch_max(loaded_order, Ordering::Relaxed);
491    }
492
493    fn record_key_with_metadata(&self, key: CacheKey, entry: CacheEntry) {
494        self.try_insert(key, entry);
495    }
496
497    fn record_borrowed_key_at_offset_with_metadata(
498        &self,
499        path: &Path,
500        chunk_offset: u64,
501        entry: CacheEntry,
502    ) -> bool {
503        let _profile = keyhog_profile::span(keyhog_profile::Stage::ResultMerge);
504        let i = shard_index(path);
505        let mut shard = self.shards[i].write();
506        let lookup = CacheKeyRef { path, chunk_offset };
507        if let Some(previous) = shard.get_mut(&lookup) {
508            let unchanged = previous.hash == entry.hash;
509            *previous = entry;
510            return unchanged;
511        }
512
513        if self.entry_cap_reached() {
514            return false;
515        }
516        shard.insert(CacheKey::chunk(path.to_path_buf(), chunk_offset), entry);
517        self.approx_count.fetch_add(1, Ordering::Relaxed);
518        false
519    }
520
521    /// Insert or update one entry, honoring [`Self::max_entries`].
522    ///
523    /// Returns `true` if the entry is now present (inserted or updated),
524    /// `false` if it was a NEW path dropped because the cap is reached.
525    /// Updates to an already-present path always succeed (they don't grow
526    /// the working set) so an over-cap scan never corrupts existing state.
527    /// The first drop emits a single WARN; subsequent drops are silent to
528    /// avoid a log storm on a multi-million-file overflow.
529    fn try_insert(&self, key: CacheKey, entry: CacheEntry) -> bool {
530        let _profile = keyhog_profile::span(keyhog_profile::Stage::ResultMerge);
531        let i = shard_index(&key.path);
532        let mut shard = self.shards[i].write();
533        let slot = match shard.entry(key) {
534            Entry::Occupied(mut slot) => {
535                slot.insert(entry);
536                return true;
537            }
538            Entry::Vacant(slot) => slot,
539        };
540
541        // `max_entries == 0` means unbounded (opt-in legacy behavior).
542        // The cap is a soft budget checked against `approx_count` (O(1),
543        // no shard scan). Concurrent new-path inserts across shards can
544        // overshoot by at most the number of in-flight `record` calls -
545        // bounded and harmless (a few entries over budget, never
546        // unbounded growth).
547        if self.entry_cap_reached() {
548            return false;
549        }
550        slot.insert(entry);
551        self.approx_count.fetch_add(1, Ordering::Relaxed);
552        true
553    }
554
555    fn entry_cap_reached(&self) -> bool {
556        if self.max_entries == 0 || self.approx_count.load(Ordering::Relaxed) < self.max_entries {
557            return false;
558        }
559        if !self.cap_warned.swap(true, Ordering::Relaxed) {
560            tracing::warn!(
561                cap = self.max_entries,
562                "merkle index entry cap reached; new paths will not be \
563                 cached this run (they are re-scanned next run). Raise \
564                 the cap for very large trees if the rescan cost matters."
565            );
566        }
567        true
568    }
569
570    /// Remove `path` from the index so the next scan treats it as new and
571    /// re-reads + re-scans it.
572    ///
573    /// This is how incremental mode keeps its core safety guarantee: a file
574    /// that produced ANY finding is never cached, so a secret in an otherwise
575    /// unchanged file still surfaces on every later run instead of being
576    /// silently skipped (the failure this module's own header warns about).
577    /// Clean files - the 99% - stay cached, so the 10-100x speedup is
578    /// unaffected, and because we store the ABSENCE of an entry rather than the
579    /// finding, no secret value ever touches the on-disk index.
580    pub fn forget(&self, path: &Path) {
581        let _profile = keyhog_profile::span(keyhog_profile::Stage::ResultMerge);
582        let i = shard_index(path);
583        self.shards[i].write().retain(|key, _| key.path != path);
584    }
585
586    /// Number of indexed entries.
587    pub(crate) fn len(&self) -> usize {
588        self.shards.iter().map(|s| s.read().len()).sum()
589    }
590
591    /// Returns true if no cached entries are present across any shard.
592    pub(crate) fn is_empty(&self) -> bool {
593        self.shards.iter().all(|s| s.read().is_empty())
594    }
595}
596
597impl Default for MerkleIndex {
598    fn default() -> Self {
599        Self::empty()
600    }
601}