Skip to main content

haz_cache/
lookup.rs

1//! [`CacheReader::lookup`] per `CACHE-014`..`CACHE-016`, plus
2//! [`CacheReader::lookup_status`] for `AUX-015` step 11.
3//!
4//! [`CacheReader::lookup`] returns `Some(`[`Manifest`]`)` only if every
5//! check on the entry passes; any failure (manifest missing,
6//! manifest unparseable, schema-version mismatch, hash-function
7//! mismatch, output blob missing, output blob size mismatch,
8//! captured stream missing, captured stream size mismatch) yields
9//! [`None`].
10//!
11//! [`CacheReader::lookup_status`] is the introspecting variant `haz why`
12//! needs: it preserves the same miss conditions but discriminates
13//! them into the four `AUX-015` step 11 sub-states
14//! ([`CacheLookupStatus::Hit`],
15//! [`CacheLookupStatus::MissNoEntry`],
16//! [`CacheLookupStatus::MissSchemaMismatch`],
17//! [`CacheLookupStatus::MissCorruptEntry`]). Unlike
18//! [`CacheReader::lookup`], filesystem errors that are NOT the spec's
19//! "missing entry" shape surface as
20//! [`CacheLookupError::Io`].
21//!
22//! `CACHE-016` is explicit that a miss "MUST NOT raise an error
23//! to the caller", so [`CacheReader::lookup`]'s return type stays
24//! [`Option<Manifest>`] rather than `Result<Option<_>, _>`. The
25//! introspecting [`CacheReader::lookup_status`] obeys the same
26//! semantics for the enumerated misses; it lifts only the truly
27//! unexpected IO failures into [`CacheLookupError`].
28
29use haz_vfs::{EntryKind, Filesystem, FsError, FsMetadata};
30use snafu::Snafu;
31
32use crate::key::CacheKey;
33use crate::layout;
34use crate::manifest::{HashFunctionLabel, Manifest};
35use crate::reader::CacheReader;
36
37/// Failure modes for [`CacheReader::lookup_status`].
38///
39/// Every miss reason `CACHE-016` enumerates folds into one of the
40/// [`CacheLookupStatus`] variants; only filesystem errors that are
41/// NOT a "missing entry" shape surface here.
42#[derive(Debug, Snafu)]
43pub enum CacheLookupError {
44    /// Underlying filesystem error during the read-only introspecting
45    /// lookup. The wrapped [`FsError`] carries the specific path.
46    #[snafu(display("filesystem error during cache lookup: {source}"))]
47    Io {
48        /// The originating filesystem error.
49        source: FsError,
50    },
51}
52
53/// Discriminated outcome of [`CacheReader::lookup_status`] per
54/// `AUX-015` step 11.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum CacheLookupStatus {
57    /// A valid manifest exists per `CACHE-015`: schema matches,
58    /// every declared blob is present at the declared size, and
59    /// the captured stdout / stderr streams are present at the
60    /// declared lengths.
61    Hit(Manifest),
62    /// No manifest at the key's shard path.
63    MissNoEntry,
64    /// Manifest exists and parses, but its `chapter_revision` or
65    /// `hash_function` differs from the cache's active
66    /// configuration per `CACHE-016`.
67    MissSchemaMismatch,
68    /// Manifest exists, parses, and matches the schema, but at
69    /// least one declared blob is missing or size-mismatched per
70    /// `CACHE-015` step 4 / 5. Also surfaces an unparseable
71    /// manifest, which is the "entry exists on disk but is
72    /// corrupt" shape `CACHE-016` lists.
73    MissCorruptEntry,
74}
75
76impl<Fs: Filesystem> CacheReader<Fs> {
77    /// Look up the cache entry for `key`.
78    ///
79    /// Returns `Some(`[`Manifest`]`)` on a hit (every consistency
80    /// check from `CACHE-016` passed), or [`None`] on any miss.
81    /// Miss reasons enumerated by `CACHE-016`:
82    ///
83    /// 1. manifest file absent or unreadable;
84    /// 2. manifest JSON malformed, contains unknown fields, or
85    ///    uses an unknown `hash_function` value;
86    /// 3. manifest's `chapter_revision` does not match
87    ///    [`crate::CHAPTER_REVISION`];
88    /// 4. manifest's `hash_function` does not match the cache's
89    ///    active [`haz_domain::settings::cache::HashAlgo`];
90    /// 5. any output blob the manifest references is missing,
91    ///    not a regular file, or has a size different from the
92    ///    manifest's recorded size;
93    /// 6. the captured `stdout` or `stderr` stream is missing,
94    ///    not a regular file, or has a size different from the
95    ///    manifest's recorded `stdout_len` / `stderr_len`.
96    #[must_use]
97    pub fn lookup(&self, key: &CacheKey) -> Option<Manifest> {
98        let manifest_path = layout::manifest_path(self.cache_root(), key);
99        let bytes = self.fs().read(&manifest_path).ok()?;
100        let manifest = Manifest::from_json(&bytes).ok()?;
101
102        if !manifest.current_chapter_revision_matches() {
103            return None;
104        }
105        if HashFunctionLabel::from(self.hash_algo()) != manifest.hash_function {
106            return None;
107        }
108
109        for blob in &manifest.outputs {
110            let blob_path = layout::output_blob_path(self.cache_root(), key, &blob.content_hash);
111            let m = self.fs().metadata(&blob_path).ok()?;
112            if m.kind != EntryKind::File || m.size != blob.size {
113                return None;
114            }
115        }
116
117        let stdout_m = self
118            .fs()
119            .metadata(&layout::stdout_path(self.cache_root(), key))
120            .ok()?;
121        if stdout_m.kind != EntryKind::File || stdout_m.size != manifest.stdout_len {
122            return None;
123        }
124
125        let stderr_m = self
126            .fs()
127            .metadata(&layout::stderr_path(self.cache_root(), key))
128            .ok()?;
129        if stderr_m.kind != EntryKind::File || stderr_m.size != manifest.stderr_len {
130            return None;
131        }
132
133        Some(manifest)
134    }
135
136    /// Introspecting variant of [`Self::lookup`] for `AUX-015`
137    /// step 11.
138    ///
139    /// Performs the same per-entry checks as [`Self::lookup`] but
140    /// discriminates the outcome into the four spec-named
141    /// sub-states. Reuses the same blob / stream verification
142    /// (`CACHE-015` step 4 / 5), so a hit here is observationally
143    /// equivalent to a hit from [`Self::lookup`].
144    ///
145    /// # Errors
146    ///
147    /// Returns [`CacheLookupError::Io`] wrapping the underlying
148    /// [`FsError`] for filesystem errors that are NOT a "missing
149    /// entry" shape (those collapse into the
150    /// [`CacheLookupStatus::MissNoEntry`] /
151    /// [`CacheLookupStatus::MissCorruptEntry`] variants).
152    pub fn lookup_status(&self, key: &CacheKey) -> Result<CacheLookupStatus, CacheLookupError> {
153        let manifest_path = layout::manifest_path(self.cache_root(), key);
154        let bytes = match self.fs().read(&manifest_path) {
155            Ok(b) => b,
156            Err(FsError::NotFound { .. } | FsError::NotAFile { .. }) => {
157                return Ok(CacheLookupStatus::MissNoEntry);
158            }
159            Err(source) => return Err(CacheLookupError::Io { source }),
160        };
161        let Ok(manifest) = Manifest::from_json(&bytes) else {
162            return Ok(CacheLookupStatus::MissCorruptEntry);
163        };
164        if !manifest.current_chapter_revision_matches()
165            || HashFunctionLabel::from(self.hash_algo()) != manifest.hash_function
166        {
167            return Ok(CacheLookupStatus::MissSchemaMismatch);
168        }
169        for blob in &manifest.outputs {
170            let blob_path = layout::output_blob_path(self.cache_root(), key, &blob.content_hash);
171            let Some(m) = self.metadata_for_lookup(&blob_path)? else {
172                return Ok(CacheLookupStatus::MissCorruptEntry);
173            };
174            if m.kind != EntryKind::File || m.size != blob.size {
175                return Ok(CacheLookupStatus::MissCorruptEntry);
176            }
177        }
178        let stdout_path = layout::stdout_path(self.cache_root(), key);
179        let Some(stdout_m) = self.metadata_for_lookup(&stdout_path)? else {
180            return Ok(CacheLookupStatus::MissCorruptEntry);
181        };
182        if stdout_m.kind != EntryKind::File || stdout_m.size != manifest.stdout_len {
183            return Ok(CacheLookupStatus::MissCorruptEntry);
184        }
185        let stderr_path = layout::stderr_path(self.cache_root(), key);
186        let Some(stderr_m) = self.metadata_for_lookup(&stderr_path)? else {
187            return Ok(CacheLookupStatus::MissCorruptEntry);
188        };
189        if stderr_m.kind != EntryKind::File || stderr_m.size != manifest.stderr_len {
190            return Ok(CacheLookupStatus::MissCorruptEntry);
191        }
192        Ok(CacheLookupStatus::Hit(manifest))
193    }
194
195    /// Read `path`'s metadata, folding the "not present" shapes
196    /// into `Ok(None)` and propagating any other filesystem error.
197    fn metadata_for_lookup(
198        &self,
199        path: &std::path::Path,
200    ) -> Result<Option<FsMetadata>, CacheLookupError> {
201        match self.fs().metadata(path) {
202            Ok(m) => Ok(Some(m)),
203            Err(FsError::NotFound { .. } | FsError::NotAFile { .. }) => Ok(None),
204            Err(source) => Err(CacheLookupError::Io { source }),
205        }
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use std::path::Path;
212
213    use haz_domain::path::CanonicalPath;
214    use haz_domain::settings::cache::HashAlgo;
215    use haz_vfs::WritableFilesystem;
216    use haz_vfs_testing::MemFilesystem;
217
218    use crate::hasher::Hasher;
219    use crate::key::CacheKey;
220    use crate::key::prefix::CHAPTER_REVISION;
221    use crate::layout;
222    use crate::manifest::{HashFunctionLabel, Manifest, OutputBlob};
223    use crate::reader::CacheReader;
224    use crate::writer::CacheWriter;
225
226    fn cp(s: &str) -> CanonicalPath {
227        CanonicalPath::parse_workspace_absolute(s)
228            .expect("test helper expects a valid workspace-absolute path")
229    }
230
231    const WORKSPACE_ROOT: &str = "/ws";
232
233    fn sample_key() -> CacheKey {
234        let mut bytes = [0u8; 32];
235        bytes[0] = 0xAB;
236        bytes[1] = 0xCD;
237        CacheKey::from_bytes(bytes)
238    }
239
240    fn hash_bytes(algo: HashAlgo, data: &[u8]) -> [u8; 32] {
241        let mut h = Hasher::new(algo);
242        h.update(data);
243        h.finalize()
244    }
245
246    /// Build a fully-consistent entry on `fs` for `key`: writes
247    /// the manifest plus a `stdout`/`stderr` file plus one
248    /// output blob, with every recorded size matching the bytes
249    /// on disk and the hash function set from `algo`.
250    fn write_valid_entry(
251        fs: &MemFilesystem,
252        cache_root: &Path,
253        key: &CacheKey,
254        algo: HashAlgo,
255    ) -> Manifest {
256        let stdout_bytes = b"stdout-body".to_vec();
257        let stderr_bytes = b"stderr-body".to_vec();
258        let blob_bytes = b"blob-body".to_vec();
259
260        let content_hash = hash_bytes(algo, &blob_bytes);
261
262        let manifest = Manifest {
263            chapter_revision: CHAPTER_REVISION,
264            hash_function: HashFunctionLabel::from(algo),
265            key: *key,
266            outputs: vec![OutputBlob {
267                workspace_absolute_path: cp("/proj/out"),
268                content_hash,
269                #[allow(clippy::cast_possible_truncation)]
270                size: blob_bytes.len() as u64,
271                mode: 0o644,
272            }],
273            #[allow(clippy::cast_possible_truncation)]
274            stdout_len: stdout_bytes.len() as u64,
275            #[allow(clippy::cast_possible_truncation)]
276            stderr_len: stderr_bytes.len() as u64,
277            stdout_hash: hash_bytes(algo, &stdout_bytes),
278            stderr_hash: hash_bytes(algo, &stderr_bytes),
279            exit_status: 0,
280            created_at_unix: 1_715_700_000,
281        };
282
283        fs.create_dir_all(&layout::outputs_dir(cache_root, key))
284            .unwrap();
285        fs.write_file(
286            &layout::manifest_path(cache_root, key),
287            &manifest.to_json_bytes(),
288        )
289        .unwrap();
290        fs.write_file(&layout::stdout_path(cache_root, key), &stdout_bytes)
291            .unwrap();
292        fs.write_file(&layout::stderr_path(cache_root, key), &stderr_bytes)
293            .unwrap();
294        fs.write_file(
295            &layout::output_blob_path(cache_root, key, &content_hash),
296            &blob_bytes,
297        )
298        .unwrap();
299
300        manifest
301    }
302
303    fn fresh_cache(algo: HashAlgo) -> (CacheReader<MemFilesystem>, CacheKey) {
304        let fs = MemFilesystem::new();
305        let cache = CacheReader::new(fs, Path::new(WORKSPACE_ROOT), algo);
306        (cache, sample_key())
307    }
308
309    #[test]
310    fn writer_lookup_through_reader_matches_direct_reader() {
311        // Spec hook: the writer-side surface must reach lookup via
312        // `.reader()`; this test pins that path so a future
313        // refactor cannot accidentally introduce a writer-side
314        // lookup shim without renaming this test.
315        let fs = MemFilesystem::new();
316        let writer = CacheWriter::new(fs, Path::new(WORKSPACE_ROOT), HashAlgo::Blake3);
317        let key = sample_key();
318        write_valid_entry(writer.fs(), writer.cache_root(), &key, HashAlgo::Blake3);
319        assert!(writer.reader().lookup(&key).is_some());
320    }
321
322    // ---- 1. hit ----
323
324    #[test]
325    fn cache_015_hit_returns_manifest() {
326        let (cache, key) = fresh_cache(HashAlgo::Blake3);
327        let expected = write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
328
329        let got = cache.lookup(&key).expect("expected a hit");
330        assert_eq!(got, expected);
331    }
332
333    // ---- 2. manifest absent ----
334
335    #[test]
336    fn cache_016_miss_when_manifest_absent() {
337        let (cache, key) = fresh_cache(HashAlgo::Blake3);
338        // Empty cache; not even the cache root exists yet.
339        assert!(cache.lookup(&key).is_none());
340    }
341
342    // ---- 3. manifest unparseable ----
343
344    #[test]
345    fn cache_016_miss_when_manifest_unparseable() {
346        let (cache, key) = fresh_cache(HashAlgo::Blake3);
347        cache
348            .fs()
349            .create_dir_all(&layout::entry_dir(cache.cache_root(), &key))
350            .unwrap();
351        cache
352            .fs()
353            .write_file(
354                &layout::manifest_path(cache.cache_root(), &key),
355                b"not json at all",
356            )
357            .unwrap();
358        assert!(cache.lookup(&key).is_none());
359    }
360
361    // ---- 4. hash-function mismatch ----
362
363    #[test]
364    fn cache_016_miss_when_hash_function_mismatches() {
365        // Entry written with Sha256; cache configured Blake3.
366        let (cache, key) = fresh_cache(HashAlgo::Blake3);
367        write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Sha256);
368        assert!(cache.lookup(&key).is_none());
369    }
370
371    // ---- 5. chapter-revision mismatch ----
372
373    #[test]
374    fn cache_016_miss_when_chapter_revision_mismatches() {
375        let (cache, key) = fresh_cache(HashAlgo::Blake3);
376        let mut manifest =
377            write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
378        manifest.chapter_revision = CHAPTER_REVISION.saturating_add(1);
379        cache
380            .fs()
381            .write_file(
382                &layout::manifest_path(cache.cache_root(), &key),
383                &manifest.to_json_bytes(),
384            )
385            .unwrap();
386        assert!(cache.lookup(&key).is_none());
387    }
388
389    // ---- 6. output blob missing ----
390
391    #[test]
392    fn cache_016_miss_when_output_blob_missing() {
393        let (cache, key) = fresh_cache(HashAlgo::Blake3);
394        let manifest = write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
395        // Replace the manifest with one that references a blob
396        // hash for which we never wrote a file.
397        let mut tampered = manifest;
398        tampered.outputs[0].content_hash = [0x99u8; 32];
399        cache
400            .fs()
401            .write_file(
402                &layout::manifest_path(cache.cache_root(), &key),
403                &tampered.to_json_bytes(),
404            )
405            .unwrap();
406        assert!(cache.lookup(&key).is_none());
407    }
408
409    // ---- 7. output blob size mismatch ----
410
411    #[test]
412    fn cache_016_miss_when_output_blob_size_mismatch() {
413        let (cache, key) = fresh_cache(HashAlgo::Blake3);
414        let manifest = write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
415        // Overwrite the blob on disk with a longer payload while
416        // leaving the manifest's recorded size unchanged.
417        let blob_path =
418            layout::output_blob_path(cache.cache_root(), &key, &manifest.outputs[0].content_hash);
419        cache
420            .fs()
421            .write_file(&blob_path, b"a-much-longer-payload")
422            .unwrap();
423        assert!(cache.lookup(&key).is_none());
424    }
425
426    // ---- 8 & 9. stdout missing / size mismatch ----
427
428    #[test]
429    fn cache_016_miss_when_stdout_missing() {
430        let (cache, key) = fresh_cache(HashAlgo::Blake3);
431        write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
432        // Remove the stdout file by overwriting with a directory
433        // would be invalid; instead remove via remove_dir_all of
434        // its parent and rewrite everything except stdout. The
435        // simpler path is to write a "directory" by avoiding the
436        // file. The fs has no remove_file; the entry directory
437        // can be torn down and rebuilt without stdout.
438        cache
439            .fs()
440            .remove_dir_all(&layout::entry_dir(cache.cache_root(), &key))
441            .unwrap();
442        // Rewrite the entry without stdout.
443        let stderr_bytes = b"stderr-body".to_vec();
444        let blob_bytes = b"blob-body".to_vec();
445        let content_hash = hash_bytes(HashAlgo::Blake3, &blob_bytes);
446        let manifest = Manifest {
447            chapter_revision: CHAPTER_REVISION,
448            hash_function: HashFunctionLabel::Blake3,
449            key,
450            outputs: vec![OutputBlob {
451                workspace_absolute_path: cp("/proj/out"),
452                content_hash,
453                #[allow(clippy::cast_possible_truncation)]
454                size: blob_bytes.len() as u64,
455                mode: 0o644,
456            }],
457            stdout_len: 11,
458            #[allow(clippy::cast_possible_truncation)]
459            stderr_len: stderr_bytes.len() as u64,
460            stdout_hash: [0u8; 32],
461            stderr_hash: hash_bytes(HashAlgo::Blake3, &stderr_bytes),
462            exit_status: 0,
463            created_at_unix: 0,
464        };
465        cache
466            .fs()
467            .create_dir_all(&layout::outputs_dir(cache.cache_root(), &key))
468            .unwrap();
469        cache
470            .fs()
471            .write_file(
472                &layout::manifest_path(cache.cache_root(), &key),
473                &manifest.to_json_bytes(),
474            )
475            .unwrap();
476        cache
477            .fs()
478            .write_file(
479                &layout::stderr_path(cache.cache_root(), &key),
480                &stderr_bytes,
481            )
482            .unwrap();
483        cache
484            .fs()
485            .write_file(
486                &layout::output_blob_path(cache.cache_root(), &key, &content_hash),
487                &blob_bytes,
488            )
489            .unwrap();
490        assert!(cache.lookup(&key).is_none());
491    }
492
493    #[test]
494    fn cache_016_miss_when_stdout_size_mismatch() {
495        let (cache, key) = fresh_cache(HashAlgo::Blake3);
496        write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
497        // Overwrite the stdout file with bytes of a different
498        // length than recorded in the manifest.
499        cache
500            .fs()
501            .write_file(
502                &layout::stdout_path(cache.cache_root(), &key),
503                b"different-length-stdout-payload",
504            )
505            .unwrap();
506        assert!(cache.lookup(&key).is_none());
507    }
508
509    // ---- 10 & 11. stderr missing / size mismatch ----
510
511    #[test]
512    fn cache_016_miss_when_stderr_missing() {
513        let (cache, key) = fresh_cache(HashAlgo::Blake3);
514        write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
515        cache
516            .fs()
517            .remove_dir_all(&layout::entry_dir(cache.cache_root(), &key))
518            .unwrap();
519        let stdout_bytes = b"stdout-body".to_vec();
520        let blob_bytes = b"blob-body".to_vec();
521        let content_hash = hash_bytes(HashAlgo::Blake3, &blob_bytes);
522        let manifest = Manifest {
523            chapter_revision: CHAPTER_REVISION,
524            hash_function: HashFunctionLabel::Blake3,
525            key,
526            outputs: vec![OutputBlob {
527                workspace_absolute_path: cp("/proj/out"),
528                content_hash,
529                #[allow(clippy::cast_possible_truncation)]
530                size: blob_bytes.len() as u64,
531                mode: 0o644,
532            }],
533            #[allow(clippy::cast_possible_truncation)]
534            stdout_len: stdout_bytes.len() as u64,
535            stderr_len: 11,
536            stdout_hash: hash_bytes(HashAlgo::Blake3, &stdout_bytes),
537            stderr_hash: [0u8; 32],
538            exit_status: 0,
539            created_at_unix: 0,
540        };
541        cache
542            .fs()
543            .create_dir_all(&layout::outputs_dir(cache.cache_root(), &key))
544            .unwrap();
545        cache
546            .fs()
547            .write_file(
548                &layout::manifest_path(cache.cache_root(), &key),
549                &manifest.to_json_bytes(),
550            )
551            .unwrap();
552        cache
553            .fs()
554            .write_file(
555                &layout::stdout_path(cache.cache_root(), &key),
556                &stdout_bytes,
557            )
558            .unwrap();
559        cache
560            .fs()
561            .write_file(
562                &layout::output_blob_path(cache.cache_root(), &key, &content_hash),
563                &blob_bytes,
564            )
565            .unwrap();
566        assert!(cache.lookup(&key).is_none());
567    }
568
569    #[test]
570    fn cache_016_miss_when_stderr_size_mismatch() {
571        let (cache, key) = fresh_cache(HashAlgo::Blake3);
572        write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
573        cache
574            .fs()
575            .write_file(
576                &layout::stderr_path(cache.cache_root(), &key),
577                b"different-length-stderr-payload",
578            )
579            .unwrap();
580        assert!(cache.lookup(&key).is_none());
581    }
582
583    // ---- 12. partial tmp dir on the same shard does not perturb other keys ----
584
585    // ---- lookup_status: AUX-015 step 11 discrimination ----
586
587    use crate::lookup::CacheLookupStatus;
588
589    #[test]
590    fn lookup_status_returns_hit_for_valid_entry() {
591        let (cache, key) = fresh_cache(HashAlgo::Blake3);
592        let expected = write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
593        match cache.lookup_status(&key).unwrap() {
594            CacheLookupStatus::Hit(m) => assert_eq!(m, expected),
595            other => panic!("expected Hit, got {other:?}"),
596        }
597    }
598
599    #[test]
600    fn lookup_status_returns_miss_no_entry_for_absent_manifest() {
601        let (cache, key) = fresh_cache(HashAlgo::Blake3);
602        assert_eq!(
603            cache.lookup_status(&key).unwrap(),
604            CacheLookupStatus::MissNoEntry,
605        );
606    }
607
608    #[test]
609    fn lookup_status_returns_miss_corrupt_entry_for_unparseable_manifest() {
610        let (cache, key) = fresh_cache(HashAlgo::Blake3);
611        cache
612            .fs()
613            .create_dir_all(&layout::entry_dir(cache.cache_root(), &key))
614            .unwrap();
615        cache
616            .fs()
617            .write_file(
618                &layout::manifest_path(cache.cache_root(), &key),
619                b"not json",
620            )
621            .unwrap();
622        assert_eq!(
623            cache.lookup_status(&key).unwrap(),
624            CacheLookupStatus::MissCorruptEntry,
625        );
626    }
627
628    #[test]
629    fn lookup_status_returns_miss_schema_mismatch_for_chapter_revision() {
630        let (cache, key) = fresh_cache(HashAlgo::Blake3);
631        let mut manifest =
632            write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
633        manifest.chapter_revision = CHAPTER_REVISION.saturating_add(1);
634        cache
635            .fs()
636            .write_file(
637                &layout::manifest_path(cache.cache_root(), &key),
638                &manifest.to_json_bytes(),
639            )
640            .unwrap();
641        assert_eq!(
642            cache.lookup_status(&key).unwrap(),
643            CacheLookupStatus::MissSchemaMismatch,
644        );
645    }
646
647    #[test]
648    fn lookup_status_returns_miss_schema_mismatch_for_hash_function() {
649        // Cache configured Blake3; entry written with Sha256.
650        let (cache, key) = fresh_cache(HashAlgo::Blake3);
651        write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Sha256);
652        assert_eq!(
653            cache.lookup_status(&key).unwrap(),
654            CacheLookupStatus::MissSchemaMismatch,
655        );
656    }
657
658    #[test]
659    fn lookup_status_returns_miss_corrupt_entry_for_missing_blob() {
660        let (cache, key) = fresh_cache(HashAlgo::Blake3);
661        let manifest = write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
662        // Re-write the manifest with a content-hash that has no
663        // backing blob file.
664        let mut tampered = manifest;
665        tampered.outputs[0].content_hash = [0x99u8; 32];
666        cache
667            .fs()
668            .write_file(
669                &layout::manifest_path(cache.cache_root(), &key),
670                &tampered.to_json_bytes(),
671            )
672            .unwrap();
673        assert_eq!(
674            cache.lookup_status(&key).unwrap(),
675            CacheLookupStatus::MissCorruptEntry,
676        );
677    }
678
679    #[test]
680    fn lookup_status_returns_miss_corrupt_entry_for_blob_size_mismatch() {
681        let (cache, key) = fresh_cache(HashAlgo::Blake3);
682        let manifest = write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
683        let blob_path =
684            layout::output_blob_path(cache.cache_root(), &key, &manifest.outputs[0].content_hash);
685        cache
686            .fs()
687            .write_file(&blob_path, b"different-payload-length")
688            .unwrap();
689        assert_eq!(
690            cache.lookup_status(&key).unwrap(),
691            CacheLookupStatus::MissCorruptEntry,
692        );
693    }
694
695    #[test]
696    fn lookup_status_returns_miss_corrupt_entry_for_stdout_size_mismatch() {
697        let (cache, key) = fresh_cache(HashAlgo::Blake3);
698        write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
699        cache
700            .fs()
701            .write_file(
702                &layout::stdout_path(cache.cache_root(), &key),
703                b"different-length-stdout-payload",
704            )
705            .unwrap();
706        assert_eq!(
707            cache.lookup_status(&key).unwrap(),
708            CacheLookupStatus::MissCorruptEntry,
709        );
710    }
711
712    #[test]
713    fn lookup_status_returns_miss_corrupt_entry_for_stderr_size_mismatch() {
714        let (cache, key) = fresh_cache(HashAlgo::Blake3);
715        write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
716        cache
717            .fs()
718            .write_file(
719                &layout::stderr_path(cache.cache_root(), &key),
720                b"different-length-stderr-payload",
721            )
722            .unwrap();
723        assert_eq!(
724            cache.lookup_status(&key).unwrap(),
725            CacheLookupStatus::MissCorruptEntry,
726        );
727    }
728
729    // ---- 12. partial tmp dir on the same shard does not perturb other keys ----
730
731    #[test]
732    fn partial_tmp_dir_does_not_affect_other_keys() {
733        let (cache, key_a) = fresh_cache(HashAlgo::Blake3);
734        // Build a second key on the same shard as key_a (same
735        // first byte, different tail).
736        let mut b_bytes = [0u8; 32];
737        b_bytes[0] = 0xAB;
738        b_bytes[31] = 0xFF;
739        let key_b = CacheKey::from_bytes(b_bytes);
740        assert_eq!(layout::shard(&key_a), layout::shard(&key_b));
741
742        // Land a complete valid entry for A.
743        write_valid_entry(cache.fs(), cache.cache_root(), &key_a, HashAlgo::Blake3);
744
745        // Drop a partial tmp directory for B on the same shard.
746        // Its mere presence must not affect A's lookup.
747        let tmp = layout::tmp_entry_dir(cache.cache_root(), &key_b, "rnd123");
748        cache.fs().create_dir_all(&tmp).unwrap();
749        cache
750            .fs()
751            .write_file(&tmp.join("manifest.json"), b"partial junk")
752            .unwrap();
753
754        assert!(cache.lookup(&key_a).is_some());
755        // And B itself is a miss: there is no final entry yet.
756        assert!(cache.lookup(&key_b).is_none());
757    }
758}