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}