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}