Skip to main content

lean_ctx/core/cache/
validation.rs

1use md5::{Digest, Md5};
2use std::sync::atomic::{AtomicU64, Ordering};
3use std::time::SystemTime;
4
5/// The single terminal reuse classification for one eligible cacheable read.
6///
7/// A request can pass through several cache layers, but it must be counted in
8/// exactly one bucket.  In particular, a session-cache rendering that is later
9/// collapsed by kernel dedup is an [`UnchangedStub`](Self::UnchangedStub), not
10/// two independent hits.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ReuseOutcome {
13    Cold,
14    DiskContentHit,
15    RenderCacheHit,
16    UnchangedStub,
17    CrossFileRef,
18    FreshBypass,
19    Stale,
20    VariantEvicted,
21    PolicyBypass,
22}
23
24impl ReuseOutcome {
25    const fn is_render_reuse(self) -> bool {
26        matches!(
27            self,
28            Self::RenderCacheHit | Self::UnchangedStub | Self::CrossFileRef
29        )
30    }
31
32    const fn is_ctx_read_eligible(self) -> bool {
33        !matches!(self, Self::FreshBypass | Self::PolicyBypass)
34    }
35}
36
37/// Snapshot for the three non-overlapping cache-reuse measurements.
38///
39/// `eligible_ctx_reads` intentionally excludes explicit fresh reads and
40/// policy-disabled cache reads: neither was allowed to reuse a rendering.
41#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
42pub struct ReuseSnapshot {
43    pub cold: u64,
44    pub disk_content_hits: u64,
45    pub render_cache_hits: u64,
46    pub unchanged_stubs: u64,
47    pub cross_file_refs: u64,
48    pub fresh_bypasses: u64,
49    pub stale: u64,
50    pub variant_evicted: u64,
51    pub policy_bypasses: u64,
52    pub eligible_ctx_reads: u64,
53    pub reused_renderings: u64,
54    pub eligible_search_reads: u64,
55}
56
57impl ReuseSnapshot {
58    /// Percentage of eligible `ctx_read`s served by an already-rendered result.
59    pub fn read_reuse_rate(&self) -> f64 {
60        rate(self.reused_renderings, self.eligible_ctx_reads)
61    }
62
63    /// Percentage of eligible search file reads served by `content_cache`.
64    pub fn disk_reuse_rate(&self) -> f64 {
65        rate(self.disk_content_hits, self.eligible_search_reads)
66    }
67}
68
69struct ReuseCounters {
70    cold: AtomicU64,
71    disk_content_hits: AtomicU64,
72    render_cache_hits: AtomicU64,
73    unchanged_stubs: AtomicU64,
74    cross_file_refs: AtomicU64,
75    fresh_bypasses: AtomicU64,
76    stale: AtomicU64,
77    variant_evicted: AtomicU64,
78    policy_bypasses: AtomicU64,
79    eligible_ctx_reads: AtomicU64,
80    reused_renderings: AtomicU64,
81    eligible_search_reads: AtomicU64,
82}
83
84impl ReuseCounters {
85    const fn new() -> Self {
86        Self {
87            cold: AtomicU64::new(0),
88            disk_content_hits: AtomicU64::new(0),
89            render_cache_hits: AtomicU64::new(0),
90            unchanged_stubs: AtomicU64::new(0),
91            cross_file_refs: AtomicU64::new(0),
92            fresh_bypasses: AtomicU64::new(0),
93            stale: AtomicU64::new(0),
94            variant_evicted: AtomicU64::new(0),
95            policy_bypasses: AtomicU64::new(0),
96            eligible_ctx_reads: AtomicU64::new(0),
97            reused_renderings: AtomicU64::new(0),
98            eligible_search_reads: AtomicU64::new(0),
99        }
100    }
101}
102
103static REUSE_COUNTERS: ReuseCounters = ReuseCounters::new();
104
105fn rate(numerator: u64, denominator: u64) -> f64 {
106    if denominator == 0 {
107        0.0
108    } else {
109        numerator as f64 * 100.0 / denominator as f64
110    }
111}
112
113fn bump_outcome(counters: &ReuseCounters, outcome: ReuseOutcome) {
114    let counter = match outcome {
115        ReuseOutcome::Cold => &counters.cold,
116        ReuseOutcome::DiskContentHit => &counters.disk_content_hits,
117        ReuseOutcome::RenderCacheHit => &counters.render_cache_hits,
118        ReuseOutcome::UnchangedStub => &counters.unchanged_stubs,
119        ReuseOutcome::CrossFileRef => &counters.cross_file_refs,
120        ReuseOutcome::FreshBypass => &counters.fresh_bypasses,
121        ReuseOutcome::Stale => &counters.stale,
122        ReuseOutcome::VariantEvicted => &counters.variant_evicted,
123        ReuseOutcome::PolicyBypass => &counters.policy_bypasses,
124    };
125    counter.fetch_add(1, Ordering::Relaxed);
126}
127
128fn record_ctx_read_outcome_into(counters: &ReuseCounters, outcome: ReuseOutcome) {
129    bump_outcome(counters, outcome);
130    if outcome.is_ctx_read_eligible() {
131        counters.eligible_ctx_reads.fetch_add(1, Ordering::Relaxed);
132        if outcome.is_render_reuse() {
133            counters.reused_renderings.fetch_add(1, Ordering::Relaxed);
134        }
135    }
136}
137
138/// Record exactly one terminal outcome for a completed `ctx_read`.
139pub fn record_ctx_read_outcome(outcome: ReuseOutcome) {
140    record_ctx_read_outcome_into(&REUSE_COUNTERS, outcome);
141}
142
143fn record_search_content_read_into(counters: &ReuseCounters, hit: bool) {
144    counters
145        .eligible_search_reads
146        .fetch_add(1, Ordering::Relaxed);
147    bump_outcome(
148        counters,
149        if hit {
150            ReuseOutcome::DiskContentHit
151        } else {
152            ReuseOutcome::Cold
153        },
154    );
155}
156
157/// Record exactly one content-cache outcome for one eligible search file read.
158pub fn record_search_content_read(hit: bool) {
159    record_search_content_read_into(&REUSE_COUNTERS, hit);
160}
161
162/// Return a lock-free point-in-time view of reuse counters.
163#[must_use]
164pub fn reuse_snapshot() -> ReuseSnapshot {
165    snapshot_from(&REUSE_COUNTERS)
166}
167
168fn snapshot_from(counters: &ReuseCounters) -> ReuseSnapshot {
169    ReuseSnapshot {
170        cold: counters.cold.load(Ordering::Relaxed),
171        disk_content_hits: counters.disk_content_hits.load(Ordering::Relaxed),
172        render_cache_hits: counters.render_cache_hits.load(Ordering::Relaxed),
173        unchanged_stubs: counters.unchanged_stubs.load(Ordering::Relaxed),
174        cross_file_refs: counters.cross_file_refs.load(Ordering::Relaxed),
175        fresh_bypasses: counters.fresh_bypasses.load(Ordering::Relaxed),
176        stale: counters.stale.load(Ordering::Relaxed),
177        variant_evicted: counters.variant_evicted.load(Ordering::Relaxed),
178        policy_bypasses: counters.policy_bypasses.load(Ordering::Relaxed),
179        eligible_ctx_reads: counters.eligible_ctx_reads.load(Ordering::Relaxed),
180        reused_renderings: counters.reused_renderings.load(Ordering::Relaxed),
181        eligible_search_reads: counters.eligible_search_reads.load(Ordering::Relaxed),
182    }
183}
184
185pub fn file_mtime(path: &str) -> Option<SystemTime> {
186    // #1780: a transient failure here is indistinguishable from "the file is
187    // gone" once it collapses into `None`, and `is_cache_entry_stale` reads
188    // `Some(cached)` against `None` as a change. Retry first; `NotFound` — the
189    // common and meaningful case — is not retried and still returns `None`
190    // immediately.
191    retry_while_transient(|| std::fs::metadata(path).and_then(|m| m.modified())).ok()
192}
193
194pub fn is_cache_entry_stale(path: &str, cached_mtime: Option<SystemTime>) -> bool {
195    let current = file_mtime(path);
196    match (cached_mtime, current) {
197        // Both unavailable (e.g. WSL DrvFS): can't tell → assume fresh (conservative).
198        (None, None) => false,
199        // One side missing: metadata changed or appeared/disappeared → stale.
200        (Some(_), None) | (None, Some(_)) => true,
201        // `!=`, not `>`: a *backward* mtime (git checkout, touch -t, snapshot
202        // restore) is just as much a content change as a forward one.
203        (Some(cached), Some(current)) => current != cached,
204    }
205}
206
207/// Files larger than this are not content-hashed for stub verification; the
208/// mtime check alone decides. Keeps the stub fast-path O(small-file-read).
209const VERIFY_HASH_CAP_BYTES: u64 = 8 * 1024 * 1024;
210
211fn cache_verify_enabled() -> bool {
212    std::env::var("LEAN_CTX_CACHE_VERIFY").map_or(true, |v| v != "0")
213}
214
215/// #1780: tells "the file changed" apart from "we could not look at it".
216///
217/// `NotFound` is a real change — the file is gone — and is reported at once.
218/// Every other failure is treated as possibly transient. Enumerating kinds
219/// would be brittle: Windows reports a briefly-held file as
220/// `ERROR_SHARING_VIOLATION` or `ERROR_ACCESS_DENIED` depending on the holder,
221/// and their mapping onto `ErrorKind` is not something this crate can pin down
222/// on a POSIX host. Retrying costs a few milliseconds on a file that really is
223/// unreadable, and the verdict for that case is unchanged: stale.
224fn io_error_may_be_transient(error: &std::io::Error) -> bool {
225    error.kind() != std::io::ErrorKind::NotFound
226}
227
228/// Runs `op`, retrying briefly while it fails in a way that may be transient.
229///
230/// The fail-closed guarantee is untouched: when every attempt fails the caller
231/// still treats the entry as stale, so a wrong `[unchanged]` stub remains
232/// impossible. What this removes is the *non-determinism* — on Windows a file
233/// can still be locked moments after a write, which made the stub path
234/// re-deliver content that had not changed (#1780).
235fn retry_while_transient<T>(mut op: impl FnMut() -> std::io::Result<T>) -> std::io::Result<T> {
236    const ATTEMPTS: usize = 3;
237    const BACKOFF: std::time::Duration = std::time::Duration::from_millis(15);
238
239    let mut result = op();
240    for _ in 1..ATTEMPTS {
241        match result {
242            Err(ref error) if io_error_may_be_transient(error) => {
243                std::thread::sleep(BACKOFF);
244                result = op();
245            }
246            // Success, or a failure that says something definite (`NotFound`):
247            // either way there is nothing a retry would tell us.
248            _ => return result,
249        }
250    }
251    result
252}
253
254/// Staleness with content verification: like [`is_cache_entry_stale`], but when
255/// the mtime claims "unchanged", additionally compares the md5 of the on-disk
256/// content against the cached hash.
257///
258/// mtime alone cannot be trusted for *correctness*: same-second writes are
259/// invisible on coarse-granularity filesystems (HFS+ 1s, FAT 2s) and mtimes can
260/// be restored by tools. Serving an `[unchanged]` stub for changed content
261/// would silently mislead the agent — the worst failure mode a context layer
262/// can have. The extra disk read costs microseconds for typical source files;
263/// the stub's token savings are unaffected. Opt out: `LEAN_CTX_CACHE_VERIFY=0`.
264///
265/// Note: entries whose stored content differs from disk by design (e.g. secret
266/// redaction) hash differently and therefore never serve stubs — conservative
267/// and correct.
268///
269/// #1780: both filesystem calls below retry briefly before giving up, so a
270/// transient lock is not mistaken for a content change. The mtime probe that
271/// runs first is covered too — [`file_mtime`] retries in the same way — so all
272/// three filesystem touches on this path share the behaviour. An unreadable
273/// file is still declared stale — that verdict is never *wrong*, only wasteful
274/// — but it no longer happens for a file that merely had a writer's handle open
275/// a moment ago.
276pub fn is_cache_entry_stale_verified(
277    path: &str,
278    cached_mtime: Option<SystemTime>,
279    cached_hash: &str,
280) -> bool {
281    let mtime_stale = is_cache_entry_stale(path, cached_mtime);
282
283    if !mtime_stale && (cached_hash.is_empty() || !cache_verify_enabled()) {
284        return false;
285    }
286
287    if cached_hash.is_empty() {
288        return mtime_stale;
289    }
290
291    let Ok(meta) = retry_while_transient(|| std::fs::metadata(path)) else {
292        return true;
293    };
294    if meta.len() > VERIFY_HASH_CAP_BYTES {
295        return mtime_stale;
296    }
297    match retry_while_transient(|| std::fs::read(path)) {
298        Ok(bytes) => compute_md5(&String::from_utf8_lossy(&bytes)) != cached_hash,
299        Err(_) => true,
300    }
301}
302pub(super) fn compute_md5(content: &str) -> String {
303    let mut hasher = Md5::new();
304    hasher.update(content.as_bytes());
305    crate::core::agent_identity::hex_encode(&hasher.finalize())
306}
307
308#[cfg(test)]
309mod tests {
310    use super::{
311        ReuseCounters, ReuseOutcome, ReuseSnapshot, record_ctx_read_outcome_into,
312        record_search_content_read_into, snapshot_from,
313    };
314
315    #[test]
316    fn every_read_records_one_exclusive_terminal_outcome() {
317        let counters = ReuseCounters::new();
318        record_ctx_read_outcome_into(&counters, ReuseOutcome::RenderCacheHit);
319        record_ctx_read_outcome_into(&counters, ReuseOutcome::UnchangedStub);
320        record_ctx_read_outcome_into(&counters, ReuseOutcome::FreshBypass);
321        record_ctx_read_outcome_into(&counters, ReuseOutcome::PolicyBypass);
322        record_search_content_read_into(&counters, true);
323        record_search_content_read_into(&counters, false);
324
325        let snapshot = snapshot_from(&counters);
326        assert_eq!(snapshot.render_cache_hits, 1);
327        assert_eq!(snapshot.unchanged_stubs, 1);
328        assert_eq!(snapshot.fresh_bypasses, 1);
329        assert_eq!(snapshot.policy_bypasses, 1);
330        assert_eq!(snapshot.disk_content_hits, 1);
331        assert_eq!(snapshot.cold, 1);
332        assert_eq!(snapshot.eligible_ctx_reads, 2);
333        assert_eq!(snapshot.reused_renderings, 2);
334        assert_eq!(snapshot.eligible_search_reads, 2);
335    }
336
337    #[test]
338    fn reuse_rates_keep_read_and_disk_denominators_separate() {
339        let metrics = ReuseSnapshot {
340            reused_renderings: 3,
341            eligible_ctx_reads: 4,
342            disk_content_hits: 2,
343            eligible_search_reads: 5,
344            ..ReuseSnapshot::default()
345        };
346
347        assert_eq!(metrics.read_reuse_rate(), 75.0);
348        assert_eq!(metrics.disk_reuse_rate(), 40.0);
349    }
350
351    #[test]
352    fn only_render_reuse_outcomes_count_for_ctx_read_reuse() {
353        assert!(ReuseOutcome::RenderCacheHit.is_render_reuse());
354        assert!(ReuseOutcome::UnchangedStub.is_render_reuse());
355        assert!(ReuseOutcome::CrossFileRef.is_render_reuse());
356        assert!(!ReuseOutcome::DiskContentHit.is_render_reuse());
357        assert!(!ReuseOutcome::FreshBypass.is_render_reuse());
358        assert!(!ReuseOutcome::Stale.is_render_reuse());
359    }
360
361    #[test]
362    fn bypasses_are_not_eligible_for_read_reuse() {
363        assert!(!ReuseOutcome::FreshBypass.is_ctx_read_eligible());
364        assert!(!ReuseOutcome::PolicyBypass.is_ctx_read_eligible());
365        assert!(ReuseOutcome::Stale.is_ctx_read_eligible());
366    }
367
368    #[test]
369    fn stale_mtime_but_same_hash_is_not_stale() {
370        use std::io::Write;
371        let dir = tempfile::tempdir().unwrap();
372        let p = dir.path().join("poll.txt");
373        std::fs::write(&p, "terminal output line 1").unwrap();
374
375        let mtime1 = std::fs::metadata(&p).unwrap().modified().unwrap();
376        let hash = super::compute_md5("terminal output line 1");
377
378        std::thread::sleep(std::time::Duration::from_millis(50));
379        {
380            let mut f = std::fs::OpenOptions::new()
381                .write(true)
382                .truncate(true)
383                .open(&p)
384                .unwrap();
385            f.write_all(b"terminal output line 1").unwrap();
386        }
387        let mtime2 = std::fs::metadata(&p).unwrap().modified().unwrap();
388        assert_ne!(mtime1, mtime2, "mtime must differ after rewrite");
389
390        let stale = super::is_cache_entry_stale_verified(p.to_str().unwrap(), Some(mtime1), &hash);
391        assert!(
392            !stale,
393            "same content with different mtime must NOT be stale"
394        );
395    }
396
397    #[test]
398    fn stale_mtime_with_changed_content_is_stale() {
399        let dir = tempfile::tempdir().unwrap();
400        let p = dir.path().join("changed.txt");
401        std::fs::write(&p, "version 1").unwrap();
402
403        let mtime1 = std::fs::metadata(&p).unwrap().modified().unwrap();
404        let hash = super::compute_md5("version 1");
405
406        std::thread::sleep(std::time::Duration::from_millis(50));
407        std::fs::write(&p, "version 2 with new content").unwrap();
408
409        let stale = super::is_cache_entry_stale_verified(p.to_str().unwrap(), Some(mtime1), &hash);
410        assert!(stale, "changed content must be stale");
411    }
412
413    #[test]
414    fn same_mtime_same_hash_is_not_stale() {
415        let dir = tempfile::tempdir().unwrap();
416        let p = dir.path().join("stable.txt");
417        std::fs::write(&p, "stable content").unwrap();
418
419        let mtime = std::fs::metadata(&p).unwrap().modified().unwrap();
420        let hash = super::compute_md5("stable content");
421
422        let stale = super::is_cache_entry_stale_verified(p.to_str().unwrap(), Some(mtime), &hash);
423        assert!(!stale, "unchanged file must not be stale");
424    }
425
426    /// #1780: a verification read that fails once and then succeeds must not be
427    /// reported as a content change. On Windows a file can still be locked
428    /// moments after a write, which made the stub path non-deterministic.
429    ///
430    /// This exercises the retry itself, not the platform: a real sharing
431    /// violation cannot be produced on a POSIX host.
432    #[test]
433    fn a_transient_failure_is_retried_before_giving_up() {
434        let attempts = std::cell::Cell::new(0_u32);
435        let result = super::retry_while_transient(|| {
436            let n = attempts.get() + 1;
437            attempts.set(n);
438            if n < 3 {
439                Err(std::io::Error::new(
440                    std::io::ErrorKind::PermissionDenied,
441                    "file is locked by another handle",
442                ))
443            } else {
444                Ok(42_u32)
445            }
446        });
447        assert_eq!(result.unwrap(), 42);
448        assert_eq!(attempts.get(), 3, "must keep trying until it succeeds");
449    }
450
451    /// A missing file is a *real* change, not a transient failure — reporting it
452    /// immediately matters, because retrying would delay every genuine deletion.
453    #[test]
454    fn a_missing_file_is_not_retried() {
455        let attempts = std::cell::Cell::new(0_u32);
456        let result: std::io::Result<()> = super::retry_while_transient(|| {
457            attempts.set(attempts.get() + 1);
458            Err(std::io::Error::new(std::io::ErrorKind::NotFound, "gone"))
459        });
460        assert!(result.is_err());
461        assert_eq!(attempts.get(), 1, "NotFound must not be retried");
462    }
463
464    /// The fail-closed guarantee is unchanged by #1780: content that cannot be
465    /// verified is still treated as stale, so a wrong `[unchanged]` stub stays
466    /// impossible. The retry only removes the *non-determinism*, never the
467    /// safety.
468    #[test]
469    fn content_that_never_becomes_readable_is_still_stale() {
470        let dir = tempfile::tempdir().unwrap();
471        let missing = dir.path().join("never-existed.txt");
472        let stale = super::is_cache_entry_stale_verified(
473            missing.to_str().unwrap(),
474            Some(std::time::SystemTime::now()),
475            &super::compute_md5("whatever was cached"),
476        );
477        assert!(stale, "unverifiable content must stay fail-closed");
478    }
479
480    #[test]
481    fn empty_hash_falls_back_to_mtime_only() {
482        let dir = tempfile::tempdir().unwrap();
483        let p = dir.path().join("nohash.txt");
484        std::fs::write(&p, "content").unwrap();
485
486        let mtime = std::fs::metadata(&p).unwrap().modified().unwrap();
487        assert!(
488            !super::is_cache_entry_stale_verified(p.to_str().unwrap(), Some(mtime), ""),
489            "same mtime + empty hash = not stale"
490        );
491
492        std::thread::sleep(std::time::Duration::from_millis(50));
493        std::fs::write(&p, "content").unwrap();
494        assert!(
495            super::is_cache_entry_stale_verified(p.to_str().unwrap(), Some(mtime), ""),
496            "different mtime + empty hash = stale (no hash to rescue)"
497        );
498    }
499
500    #[test]
501    fn large_file_uses_mtime_only() {
502        let dir = tempfile::tempdir().unwrap();
503        let p = dir.path().join("big.txt");
504        let big = "x".repeat(9 * 1024 * 1024);
505        std::fs::write(&p, &big).unwrap();
506
507        let mtime = std::fs::metadata(&p).unwrap().modified().unwrap();
508        let hash = super::compute_md5(&big);
509
510        assert!(
511            !super::is_cache_entry_stale_verified(p.to_str().unwrap(), Some(mtime), &hash),
512            "same mtime on large file = not stale"
513        );
514
515        std::thread::sleep(std::time::Duration::from_millis(50));
516        std::fs::write(&p, &big).unwrap();
517        assert!(
518            super::is_cache_entry_stale_verified(p.to_str().unwrap(), Some(mtime), &hash),
519            "large files skip hash check even if content matches"
520        );
521    }
522
523    #[test]
524    fn deleted_file_is_stale() {
525        let dir = tempfile::tempdir().unwrap();
526        let p = dir.path().join("ephemeral.txt");
527        std::fs::write(&p, "temp").unwrap();
528
529        let mtime = std::fs::metadata(&p).unwrap().modified().unwrap();
530        let hash = super::compute_md5("temp");
531
532        std::fs::remove_file(&p).unwrap();
533
534        assert!(
535            super::is_cache_entry_stale_verified(p.to_str().unwrap(), Some(mtime), &hash),
536            "deleted file must be stale"
537        );
538    }
539
540    #[test]
541    fn empty_file_hash_matches_correctly() {
542        let dir = tempfile::tempdir().unwrap();
543        let p = dir.path().join("empty.txt");
544        std::fs::write(&p, "").unwrap();
545
546        let mtime = std::fs::metadata(&p).unwrap().modified().unwrap();
547        let hash = super::compute_md5("");
548
549        std::thread::sleep(std::time::Duration::from_millis(50));
550        std::fs::write(&p, "").unwrap();
551
552        assert!(
553            !super::is_cache_entry_stale_verified(p.to_str().unwrap(), Some(mtime), &hash),
554            "empty file rewritten with same content must not be stale"
555        );
556    }
557
558    #[test]
559    fn rapid_touch_cycles_stay_cached() {
560        let dir = tempfile::tempdir().unwrap();
561        let p = dir.path().join("rapid.txt");
562        let content = "rapid polling content that stays stable";
563        std::fs::write(&p, content).unwrap();
564
565        let mtime_initial = std::fs::metadata(&p).unwrap().modified().unwrap();
566        let hash = super::compute_md5(content);
567
568        for _ in 0..10 {
569            std::thread::sleep(std::time::Duration::from_millis(10));
570            std::fs::write(&p, content).unwrap();
571
572            let stale = super::is_cache_entry_stale_verified(
573                p.to_str().unwrap(),
574                Some(mtime_initial),
575                &hash,
576            );
577            assert!(
578                !stale,
579                "repeated rewrites of same content must never be stale"
580            );
581        }
582    }
583
584    #[test]
585    fn binary_content_hash_works() {
586        let dir = tempfile::tempdir().unwrap();
587        let p = dir.path().join("binary.bin");
588        let data: Vec<u8> = (0..=255).collect();
589        std::fs::write(&p, &data).unwrap();
590
591        let mtime = std::fs::metadata(&p).unwrap().modified().unwrap();
592        let hash = super::compute_md5(&String::from_utf8_lossy(&data));
593
594        std::thread::sleep(std::time::Duration::from_millis(50));
595        std::fs::write(&p, &data).unwrap();
596
597        assert!(
598            !super::is_cache_entry_stale_verified(p.to_str().unwrap(), Some(mtime), &hash),
599            "binary content with same bytes must not be stale"
600        );
601
602        let mut changed = data.clone();
603        changed[100] = 0;
604        std::fs::write(&p, &changed).unwrap();
605        assert!(
606            super::is_cache_entry_stale_verified(p.to_str().unwrap(), Some(mtime), &hash),
607            "binary content with 1 byte changed must be stale"
608        );
609    }
610}