rsconstruct 0.9.122

Rust based fast build system
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
use anyhow::{Context, Result};
use redb::ReadableDatabase;
use redb::ReadableTable;
use redb::TableDefinition;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::time::SystemTime;

/// Buffer size for streaming file reads in `file_checksum`. 64 KB is a
/// good trade-off: large enough to amortize syscall overhead, small
/// enough to fit in L1 cache and bound peak memory regardless of file
/// size.
const HASH_BUF_SIZE: usize = 64 * 1024;

use crate::build_context::BuildContext;

const MTIME_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("mtime_cache");

/// Cached stamp-to-checksum mapping for a single file. The checksum is
/// trusted while the file's whole stamp — mtime, ctime, size and inode
/// (see `platform::FileStamp`) — is unchanged. An entry from before the
/// stamp existed fails to deserialize and is re-hashed once.
#[derive(Serialize, Deserialize)]
struct MtimeEntry {
    stamp: crate::platform::FileStamp,
    checksum: String,
}

/// Seconds within which a file counts as just changed: no entry is stored
/// for it (see `fast_checksum`).
const RECENT_CHANGE_SECS: i64 = 2;

/// Open or get the mtime database from the `BuildContext`.
fn get_mtime_db(ctx: &BuildContext) -> Result<std::sync::MutexGuard<'_, Option<redb::Database>>> {
    let mut guard = ctx.mtime_db.lock().unwrap();
    if guard.is_none() {
        let dir = PathBuf::from(".rsconstruct");
        crate::errors::ctx(
            fs::create_dir_all(&dir),
            "Failed to create .rsconstruct directory",
        )?;
        let db = crate::db::open_or_recreate(&dir.join("mtime.redb"), "Mtime cache")?;
        *guard = Some(db);
    }
    Ok(guard)
}

/// Calculate SHA-256 checksum of a file's contents, using the `BuildContext`'s
/// in-memory cache. First call for a given path streams the file through
/// the hasher and caches the result. Subsequent calls return the cached
/// value.
///
/// Streaming (vs `fs::read` + hash) keeps peak memory bounded at
/// `HASH_BUF_SIZE` regardless of file size — a 100 MB binary doesn't
/// allocate a 100 MB Vec.
pub fn file_checksum(ctx: &BuildContext, path: &Path) -> Result<String> {
    let mut guard = ctx.checksum_cache.lock().unwrap();
    let cache = guard.get_or_insert_with(HashMap::new);
    if let Some(cached) = cache.get(path) {
        return Ok(cached.clone());
    }
    let checksum = stream_file_checksum(path)?;
    cache.insert(path.to_path_buf(), checksum.clone());
    Ok(checksum)
}

/// Evict paths from the in-session checksum cache. The executor calls this
/// whenever it writes or restores files (a product's outputs): the
/// in-session cache assumes content is stable across one build run, and
/// these are exactly the writes that break that assumption. Without
/// eviction, the non-mtime path (`file_checksum`) serves the pre-write
/// checksum to every later reader — including the post-execution
/// descriptor-key recompute, which then keys the cache by content that no
/// longer exists. (`fast_checksum` re-hashes on mtime change, so only
/// `mtime_check = false` builds were affected.)
pub fn forget_in_session(ctx: &BuildContext, paths: &[std::path::PathBuf]) {
    let mut guard = ctx.checksum_cache.lock().unwrap();
    if let Some(cache) = guard.as_mut() {
        for path in paths {
            cache.remove(path);
        }
    }
    drop(guard);
    ctx.combined_memo.lock().unwrap().forget(paths);
}

/// Input lists at least this long have their combined checksum memoized for
/// the session. Below it, recomputing is as cheap as the memo bookkeeping.
const MEMO_MIN_INPUTS: usize = 32;

/// In-session memo of combined checksums for large input lists.
///
/// Many products can share one long input list: every page a zola site
/// renders depends on the whole content tree, ~900 files for 1,400 products.
/// Without the memo each product re-checks every file (a `stat` and an mtime
/// lookup apiece), twice per build — over a million of each for a build
/// that changes nothing. The memo is valid while none of the files it covers
/// is rewritten; `forget_in_session` (called on every write and restore)
/// drops it the moment one is.
#[derive(Default)]
pub struct CombinedMemo {
    by_inputs: HashMap<Vec<PathBuf>, String>,
    covered: std::collections::HashSet<PathBuf>,
}

impl CombinedMemo {
    fn get(&self, inputs: &[PathBuf]) -> Option<String> {
        self.by_inputs.get(inputs).cloned()
    }

    fn insert(&mut self, inputs: &[PathBuf], combined: String) {
        self.covered.extend(inputs.iter().cloned());
        self.by_inputs.insert(inputs.to_vec(), combined);
    }

    fn forget(&mut self, paths: &[PathBuf]) {
        if paths.iter().any(|p| self.covered.contains(p)) {
            self.by_inputs.clear();
            self.covered.clear();
        }
    }
}

/// Stream a file through SHA-256 with a fixed-size buffer. The buffer is
/// heap-allocated to keep stack usage trivial — a single allocation per
/// hashed file is negligible next to the I/O.
fn stream_file_checksum(path: &Path) -> Result<String> {
    let mut file = fs::File::open(path)
        .with_context(|| format!("Failed to open file for checksum: {}", path.display()))?;
    let mut hasher = Sha256::new();
    let mut buf = vec![0u8; HASH_BUF_SIZE];
    loop {
        let n = file
            .read(&mut buf)
            .with_context(|| format!("Failed to read file for checksum: {}", path.display()))?;
        if n == 0 {
            break;
        }
        hasher.update(&buf[..n]);
    }
    Ok(hex::encode(hasher.finalize()))
}

/// A dirty mtime-cache entry waiting to be flushed: (path key, entry).
type DirtyMtimeEntry = (String, MtimeEntry);

/// Get checksum using mtime pre-check to avoid re-reading unchanged files.
/// Returns the checksum, which path was taken, and optionally a dirty mtime
/// entry to flush (absent for cache hits and for recently-modified files,
/// which are deliberately not cached — see below).
fn fast_checksum(
    ctx: &BuildContext,
    path: &Path,
) -> Result<(String, ChecksumPath, Option<DirtyMtimeEntry>)> {
    let metadata =
        fs::metadata(path).with_context(|| format!("Failed to stat file: {}", path.display()))?;
    let stamp = crate::platform::file_stamp(&metadata);

    let path_str = path.display().to_string();

    // Check mtime cache in DB
    let db_guard = get_mtime_db(ctx)?;
    let cached = if let Some(ref db) = *db_guard {
        let read_txn = crate::errors::ctx(
            db.begin_read(),
            "Failed to begin read transaction for mtime cache",
        )?;
        match read_txn.open_table(MTIME_TABLE) {
            Ok(table) => table
                .get(path_str.as_str())
                .ok()
                .flatten()
                .and_then(|data| serde_json::from_slice::<MtimeEntry>(data.value()).ok()),
            Err(_) => None,
        }
    } else {
        None
    };
    drop(db_guard);

    if let Some(ref entry) = cached
        && entry.stamp == stamp
    {
        let mut guard = ctx.checksum_cache.lock().unwrap();
        let cache = guard.get_or_insert_with(HashMap::new);
        cache.insert(path.to_path_buf(), entry.checksum.clone());
        return Ok((entry.checksum.clone(), ChecksumPath::MtimeShortcut, None));
    }

    // mtime changed or no cache entry — recompute checksum from disk.
    //
    // We bypass `file_checksum` (which would consult `ctx.checksum_cache`)
    // because the in-session cache assumes file content is stable across a
    // single build run. That assumption is wrong when an upstream product
    // writes a file mid-build: a stale entry from a pre-rebuild read would
    // be returned here even though mtime told us the file changed. Hash
    // the bytes directly, then refresh the in-session cache.
    let checksum = stream_file_checksum(path)?;
    let mut guard = ctx.checksum_cache.lock().unwrap();
    let cache = guard.get_or_insert_with(HashMap::new);
    cache.insert(path.to_path_buf(), checksum.clone());
    drop(guard);
    // Don't persist an entry for a file changed moments ago: on coarse-
    // timestamp filesystems a write landing in the same tick after we
    // hashed would leave a permanently valid-looking (stamp, checksum) pair
    // for stale content. The next run simply re-hashes such files. Judged
    // by ctime as well as mtime: `cp -p` leaves an old mtime on a file it
    // just wrote, and only ctime shows the write was just now.
    let now_secs = SystemTime::now()
        .duration_since(SystemTime::UNIX_EPOCH)
        .map_or(i64::MAX, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX));
    if stamp.changed_within(now_secs, RECENT_CHANGE_SECS) {
        return Ok((checksum, ChecksumPath::FullRead, None));
    }

    let new_entry = MtimeEntry {
        stamp,
        checksum: checksum.clone(),
    };

    Ok((
        checksum,
        ChecksumPath::FullRead,
        Some((path_str, new_entry)),
    ))
}

/// Flush a batch of dirty mtime entries in a single write transaction.
fn flush_mtime_entries(ctx: &BuildContext, dirty: Vec<(String, MtimeEntry)>) -> Result<()> {
    if dirty.is_empty() {
        return Ok(());
    }
    let db_guard = get_mtime_db(ctx)?;
    let db = crate::errors::ctx_opt(db_guard.as_ref(), "Mtime database not available")?;
    let write_txn = crate::errors::ctx(
        db.begin_write(),
        "Failed to begin write transaction for mtime cache",
    )?;
    {
        let mut table = write_txn
            .open_table(MTIME_TABLE)
            .context("Failed to open mtime cache table")?;
        for (path_str, entry) in &dirty {
            let value = serde_json::to_vec(entry).context("Failed to serialize mtime entry")?;
            table
                .insert(path_str.as_str(), value.as_slice())
                .context("Failed to insert mtime entry")?;
        }
    }
    crate::errors::ctx(write_txn.commit(), "Failed to commit mtime cache entries")?;
    Ok(())
}

/// Hash a list of individual checksums into a single combined SHA-256 checksum.
/// Each element is length-prefixed: a plain `:` join would be ambiguous
/// because `MISSING:{path}` entries embed arbitrary paths that may themselves
/// contain `:`.
fn hash_checksums(checksums: &[String]) -> String {
    let mut hasher = Sha256::new();
    for c in checksums {
        hasher.update((c.len() as u64).to_le_bytes());
        hasher.update(c.as_bytes());
    }
    hex::encode(hasher.finalize())
}

/// Hash a sequence of string parts with length prefixes, so no part can
/// impersonate a boundary between parts — the same injection-proofing as
/// `hash_checksums`, exposed for the other key-composition sites
/// (`CacheKey::digest`/`descriptor_key`, tool identity hashing). Those sites
/// used to join user-influenced values (instance names, file paths) with
/// bare separators, letting crafted values realign key boundaries.
pub fn hash_parts(parts: &[&str]) -> String {
    let mut hasher = Sha256::new();
    for p in parts {
        hasher.update((p.len() as u64).to_le_bytes());
        hasher.update(p.as_bytes());
    }
    hex::encode(hasher.finalize())
}

/// Outcome of a `checksum_fast` call, surfacing whether the mtime cache
/// succeeded in avoiding a file read.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChecksumPath {
    MtimeShortcut,
    FullRead,
}

/// Drop mtime-cache entries whose file no longer exists. Returns the number
/// removed.
///
/// The cache is keyed by path and nothing ever removed an entry, so deleting
/// or renaming a file left its row behind forever — the database grew
/// monotonically with the history of every path the project ever had.
/// Called by `rsconstruct cache trim`.
pub fn prune_mtime_cache(ctx: &BuildContext) -> Result<usize> {
    if !PathBuf::from(".rsconstruct").join("mtime.redb").exists() {
        return Ok(0);
    }

    let stale: Vec<String> = {
        let db_guard = get_mtime_db(ctx)?;
        let Some(db) = db_guard.as_ref() else {
            return Ok(0);
        };
        let read_txn = crate::errors::ctx(
            db.begin_read(),
            "Failed to begin read transaction for mtime prune",
        )?;
        let Ok(table) = read_txn.open_table(MTIME_TABLE) else {
            return Ok(0);
        };
        let mut stale = Vec::new();
        for entry in table.iter().context("Failed to iterate mtime cache")? {
            let (key, _) = entry.context("Failed to read mtime cache entry")?;
            let path_str = key.value();
            if !Path::new(path_str).exists() {
                stale.push(path_str.to_string());
            }
        }
        stale
    };

    if stale.is_empty() {
        return Ok(0);
    }

    let db_guard = get_mtime_db(ctx)?;
    let db = crate::errors::ctx_opt(db_guard.as_ref(), "Mtime database not available")?;
    let write_txn = crate::errors::ctx(
        db.begin_write(),
        "Failed to begin write transaction for mtime prune",
    )?;
    {
        let mut table = write_txn
            .open_table(MTIME_TABLE)
            .context("Failed to open mtime cache table for prune")?;
        for path_str in &stale {
            table
                .remove(path_str.as_str())
                .with_context(|| format!("Failed to remove mtime entry for {path_str}"))?;
        }
    }
    crate::errors::ctx(write_txn.commit(), "Failed to commit mtime cache prune")?;
    Ok(stale.len())
}

/// Compute a file's checksum, consulting the persistent mtime cache first.
pub fn checksum_fast(ctx: &BuildContext, path: &Path) -> Result<(String, ChecksumPath)> {
    if !ctx.mtime_enabled.load(std::sync::atomic::Ordering::Relaxed) {
        return Ok((file_checksum(ctx, path)?, ChecksumPath::FullRead));
    }
    let (checksum, path_taken, dirty) = fast_checksum(ctx, path)?;
    if let Some(entry) = dirty {
        flush_mtime_entries(ctx, vec![entry])?;
    }
    Ok((checksum, path_taken))
}

/// Checksum an **output** file, using the persistent mtime cache but never
/// the in-session cache.
///
/// The in-session cache (`file_checksum`) assumes content is stable for the
/// whole run — true for inputs, false for outputs, which are rewritten by
/// the very build doing the checking. Output verification therefore takes
/// the mtime shortcut when available and otherwise re-hashes from disk.
pub fn checksum_output(ctx: &BuildContext, path: &Path) -> Result<(String, ChecksumPath)> {
    if !ctx.mtime_enabled.load(std::sync::atomic::Ordering::Relaxed) {
        return Ok((stream_file_checksum(path)?, ChecksumPath::FullRead));
    }
    let (checksum, path_taken, dirty) = fast_checksum(ctx, path)?;
    if let Some(entry) = dirty {
        flush_mtime_entries(ctx, vec![entry])?;
    }
    Ok((checksum, path_taken))
}

/// Get the combined input checksum for a list of input files, using mtime
/// pre-check to avoid re-reading unchanged files across builds.
pub fn combined_input_checksum(ctx: &BuildContext, inputs: &[PathBuf]) -> Result<String> {
    let memoize = inputs.len() >= MEMO_MIN_INPUTS;
    if memoize && let Some(combined) = ctx.combined_memo.lock().unwrap().get(inputs) {
        return Ok(combined);
    }
    let combined = compute_combined_input_checksum(ctx, inputs)?;
    if memoize {
        ctx.combined_memo
            .lock()
            .unwrap()
            .insert(inputs, combined.clone());
    }
    Ok(combined)
}

fn compute_combined_input_checksum(ctx: &BuildContext, inputs: &[PathBuf]) -> Result<String> {
    let mtime_enabled = ctx.mtime_enabled.load(std::sync::atomic::Ordering::Relaxed);

    let mut checksums = Vec::with_capacity(inputs.len());
    let mut dirty_entries = Vec::new();

    for input in inputs {
        if input.exists() {
            if mtime_enabled {
                let (checksum, _, dirty) = fast_checksum(ctx, input)?;
                checksums.push(checksum);
                if let Some(entry) = dirty {
                    dirty_entries.push(entry);
                }
            } else {
                checksums.push(file_checksum(ctx, input)?);
            }
        } else {
            checksums.push(format!("MISSING:{}", input.display()));
        }
    }

    if mtime_enabled {
        flush_mtime_entries(ctx, dirty_entries)?;
    }

    Ok(hash_checksums(&checksums))
}

/// Calculate SHA-256 checksum of a byte slice. Not cached — pure function.
pub fn bytes_checksum(data: &[u8]) -> String {
    hex::encode(Sha256::digest(data))
}

#[cfg(test)]
mod tests {
    use super::*;

    /// What `cp -p` does: same length, new bytes, mtime put back. The mtime
    /// cache used to compare mtime alone and keep serving the old checksum;
    /// the stamp moves because ctime does.
    #[test]
    fn stamp_changes_when_content_is_rewritten_with_mtime_restored() {
        let tmp = tempfile::TempDir::new().unwrap();
        let path = tmp.path().join("f.txt");
        fs::write(&path, "aaaa").unwrap();
        let old_mtime = SystemTime::now() - std::time::Duration::from_secs(3600);
        fs::File::options()
            .write(true)
            .open(&path)
            .unwrap()
            .set_modified(old_mtime)
            .unwrap();
        let before = crate::platform::file_stamp(&fs::metadata(&path).unwrap());

        // Clear the ctime tick of the first write on coarse filesystems.
        std::thread::sleep(std::time::Duration::from_millis(20));
        fs::write(&path, "bbbb").unwrap();
        fs::File::options()
            .write(true)
            .open(&path)
            .unwrap()
            .set_modified(old_mtime)
            .unwrap();
        let after = crate::platform::file_stamp(&fs::metadata(&path).unwrap());

        assert_eq!(before.mtime_secs, after.mtime_secs, "mtime was restored");
        assert_eq!(before.size, after.size, "same length");
        assert_ne!(before, after, "ctime still records the rewrite");
    }

    /// A file whose ctime is recent counts as just changed even when its
    /// mtime is old, so no entry is stored for it.
    #[test]
    fn changed_within_uses_the_later_of_mtime_and_ctime() {
        let stamp = crate::platform::FileStamp {
            mtime_secs: 1_000,
            mtime_nanos: 0,
            ctime_secs: 5_000,
            ctime_nanos: 0,
            size: 4,
            inode: 1,
        };
        assert!(stamp.changed_within(5_001, RECENT_CHANGE_SECS));
        assert!(!stamp.changed_within(5_010, RECENT_CHANGE_SECS));
    }

    /// Length-prefixing exists precisely so element boundaries can't be
    /// forged: ["a:b", "c"] and ["a", "b:c"] concatenate identically under
    /// a plain `:` join.
    #[test]
    fn hash_checksums_is_injection_proof() {
        let joined_left = hash_checksums(&["a:b".to_string(), "c".to_string()]);
        let joined_right = hash_checksums(&["a".to_string(), "b:c".to_string()]);
        assert_ne!(joined_left, joined_right);

        assert_ne!(
            hash_checksums(&[]),
            hash_checksums(&[String::new()]),
            "no elements and one empty element must differ"
        );
        assert_eq!(
            hash_checksums(&["x".to_string()]),
            hash_checksums(&["x".to_string()]),
            "must be deterministic"
        );
    }

    /// The streaming hasher must agree with the one-shot hasher at every
    /// buffer boundary — off-by-one in the read loop shows up exactly there.
    #[test]
    fn stream_checksum_matches_bytes_checksum_at_buffer_boundaries() {
        let tmp = tempfile::TempDir::new().unwrap();
        for size in [0, HASH_BUF_SIZE - 1, HASH_BUF_SIZE, HASH_BUF_SIZE + 1] {
            let data = vec![0xABu8; size];
            let path = tmp.path().join(format!("f{size}"));
            fs::write(&path, &data).unwrap();
            assert_eq!(
                stream_file_checksum(&path).unwrap(),
                bytes_checksum(&data),
                "stream and one-shot checksums diverge at {size} bytes"
            );
        }
    }

    /// Missing inputs are part of the combined key (a vanished input must
    /// change it), and the combination is positional, not a set.
    #[test]
    fn combined_input_checksum_missing_and_order_semantics() {
        let ctx = BuildContext::new();
        ctx.set_mtime_check(false);
        let tmp = tempfile::TempDir::new().unwrap();

        let empty = tmp.path().join("empty.txt");
        fs::write(&empty, b"").unwrap();
        let missing = tmp.path().join("missing.txt");

        assert_ne!(
            combined_input_checksum(&ctx, std::slice::from_ref(&empty)).unwrap(),
            combined_input_checksum(&ctx, std::slice::from_ref(&missing)).unwrap(),
            "a missing input must not hash like an empty one"
        );

        let a = tmp.path().join("a.txt");
        let b = tmp.path().join("b.txt");
        fs::write(&a, b"aaa").unwrap();
        fs::write(&b, b"bbb").unwrap();
        assert_ne!(
            combined_input_checksum(&ctx, &[a.clone(), b.clone()]).unwrap(),
            combined_input_checksum(&ctx, &[b, a]).unwrap(),
            "input order is part of the key"
        );
    }

    /// The in-session cache serves repeat reads within one build run; the
    /// executor evicts a product's outputs via `forget_in_session` whenever
    /// it writes or restores them. Eviction is what keeps `file_checksum`
    /// correct when mtime checking is off — without it, a mid-build rewrite
    /// would be invisible for the rest of the run.
    #[test]
    fn file_checksum_cache_evicts_on_forget() {
        let ctx = BuildContext::new();
        let tmp = tempfile::TempDir::new().unwrap();
        let path = tmp.path().join("f.txt");

        fs::write(&path, b"one").unwrap();
        let first = file_checksum(&ctx, &path).unwrap();
        fs::write(&path, b"two").unwrap();
        assert_eq!(
            file_checksum(&ctx, &path).unwrap(),
            first,
            "un-evicted reads serve the in-session cached checksum"
        );

        forget_in_session(&ctx, std::slice::from_ref(&path));
        let second = file_checksum(&ctx, &path).unwrap();
        assert_ne!(second, first, "eviction must expose the new content");

        let fresh_ctx = BuildContext::new();
        assert_eq!(
            file_checksum(&fresh_ctx, &path).unwrap(),
            second,
            "post-eviction value must match a fresh context's view"
        );
    }
}