Skip to main content

haz_cache/
lookup.rs

1//! [`Cache::lookup`] per `CACHE-014`..`CACHE-016`, plus
2//! [`Cache::lookup_status`] for `AUX-015` step 11.
3//!
4//! [`Cache::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//! [`Cache::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//! [`Cache::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 [`Cache::lookup`]'s return type stays
24//! [`Option<Manifest>`] rather than `Result<Option<_>, _>`. The
25//! introspecting [`Cache::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, FsError, FsMetadata, WritableFilesystem};
30use snafu::Snafu;
31
32use crate::cache::Cache;
33use crate::key::CacheKey;
34use crate::layout;
35use crate::manifest::{HashFunctionLabel, Manifest};
36
37/// Failure modes for [`Cache::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 [`Cache::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: WritableFilesystem> Cache<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::{MemFilesystem, WritableFilesystem};
216
217    use crate::cache::Cache;
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
224    fn cp(s: &str) -> CanonicalPath {
225        CanonicalPath::parse_workspace_absolute(s)
226            .expect("test helper expects a valid workspace-absolute path")
227    }
228
229    const WORKSPACE_ROOT: &str = "/ws";
230
231    fn sample_key() -> CacheKey {
232        let mut bytes = [0u8; 32];
233        bytes[0] = 0xAB;
234        bytes[1] = 0xCD;
235        CacheKey::from_bytes(bytes)
236    }
237
238    fn hash_bytes(algo: HashAlgo, data: &[u8]) -> [u8; 32] {
239        let mut h = Hasher::new(algo);
240        h.update(data);
241        h.finalize()
242    }
243
244    /// Build a fully-consistent entry on `fs` for `key`: writes
245    /// the manifest plus a `stdout`/`stderr` file plus one
246    /// output blob, with every recorded size matching the bytes
247    /// on disk and the hash function set from `algo`.
248    fn write_valid_entry(
249        fs: &MemFilesystem,
250        cache_root: &Path,
251        key: &CacheKey,
252        algo: HashAlgo,
253    ) -> Manifest {
254        let stdout_bytes = b"stdout-body".to_vec();
255        let stderr_bytes = b"stderr-body".to_vec();
256        let blob_bytes = b"blob-body".to_vec();
257
258        let content_hash = hash_bytes(algo, &blob_bytes);
259
260        let manifest = Manifest {
261            chapter_revision: CHAPTER_REVISION,
262            hash_function: HashFunctionLabel::from(algo),
263            key: *key,
264            outputs: vec![OutputBlob {
265                workspace_absolute_path: cp("/proj/out"),
266                content_hash,
267                #[allow(clippy::cast_possible_truncation)]
268                size: blob_bytes.len() as u64,
269                mode: 0o644,
270            }],
271            #[allow(clippy::cast_possible_truncation)]
272            stdout_len: stdout_bytes.len() as u64,
273            #[allow(clippy::cast_possible_truncation)]
274            stderr_len: stderr_bytes.len() as u64,
275            stdout_hash: hash_bytes(algo, &stdout_bytes),
276            stderr_hash: hash_bytes(algo, &stderr_bytes),
277            exit_status: 0,
278            created_at_unix: 1_715_700_000,
279        };
280
281        fs.create_dir_all(&layout::outputs_dir(cache_root, key))
282            .unwrap();
283        fs.write_file(
284            &layout::manifest_path(cache_root, key),
285            &manifest.to_json_bytes(),
286        )
287        .unwrap();
288        fs.write_file(&layout::stdout_path(cache_root, key), &stdout_bytes)
289            .unwrap();
290        fs.write_file(&layout::stderr_path(cache_root, key), &stderr_bytes)
291            .unwrap();
292        fs.write_file(
293            &layout::output_blob_path(cache_root, key, &content_hash),
294            &blob_bytes,
295        )
296        .unwrap();
297
298        manifest
299    }
300
301    fn fresh_cache(algo: HashAlgo) -> (Cache<MemFilesystem>, CacheKey) {
302        let fs = MemFilesystem::new();
303        let cache = Cache::new(fs, Path::new(WORKSPACE_ROOT), algo);
304        (cache, sample_key())
305    }
306
307    // ---- 1. hit ----
308
309    #[test]
310    fn cache_015_hit_returns_manifest() {
311        let (cache, key) = fresh_cache(HashAlgo::Blake3);
312        let expected = write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
313
314        let got = cache.lookup(&key).expect("expected a hit");
315        assert_eq!(got, expected);
316    }
317
318    // ---- 2. manifest absent ----
319
320    #[test]
321    fn cache_016_miss_when_manifest_absent() {
322        let (cache, key) = fresh_cache(HashAlgo::Blake3);
323        // Empty cache; not even the cache root exists yet.
324        assert!(cache.lookup(&key).is_none());
325    }
326
327    // ---- 3. manifest unparseable ----
328
329    #[test]
330    fn cache_016_miss_when_manifest_unparseable() {
331        let (cache, key) = fresh_cache(HashAlgo::Blake3);
332        cache
333            .fs()
334            .create_dir_all(&layout::entry_dir(cache.cache_root(), &key))
335            .unwrap();
336        cache
337            .fs()
338            .write_file(
339                &layout::manifest_path(cache.cache_root(), &key),
340                b"not json at all",
341            )
342            .unwrap();
343        assert!(cache.lookup(&key).is_none());
344    }
345
346    // ---- 4. hash-function mismatch ----
347
348    #[test]
349    fn cache_016_miss_when_hash_function_mismatches() {
350        // Entry written with Sha256; cache configured Blake3.
351        let (cache, key) = fresh_cache(HashAlgo::Blake3);
352        write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Sha256);
353        assert!(cache.lookup(&key).is_none());
354    }
355
356    // ---- 5. chapter-revision mismatch ----
357
358    #[test]
359    fn cache_016_miss_when_chapter_revision_mismatches() {
360        let (cache, key) = fresh_cache(HashAlgo::Blake3);
361        let mut manifest =
362            write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
363        manifest.chapter_revision = CHAPTER_REVISION.saturating_add(1);
364        cache
365            .fs()
366            .write_file(
367                &layout::manifest_path(cache.cache_root(), &key),
368                &manifest.to_json_bytes(),
369            )
370            .unwrap();
371        assert!(cache.lookup(&key).is_none());
372    }
373
374    // ---- 6. output blob missing ----
375
376    #[test]
377    fn cache_016_miss_when_output_blob_missing() {
378        let (cache, key) = fresh_cache(HashAlgo::Blake3);
379        let manifest = write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
380        // Replace the manifest with one that references a blob
381        // hash for which we never wrote a file.
382        let mut tampered = manifest;
383        tampered.outputs[0].content_hash = [0x99u8; 32];
384        cache
385            .fs()
386            .write_file(
387                &layout::manifest_path(cache.cache_root(), &key),
388                &tampered.to_json_bytes(),
389            )
390            .unwrap();
391        assert!(cache.lookup(&key).is_none());
392    }
393
394    // ---- 7. output blob size mismatch ----
395
396    #[test]
397    fn cache_016_miss_when_output_blob_size_mismatch() {
398        let (cache, key) = fresh_cache(HashAlgo::Blake3);
399        let manifest = write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
400        // Overwrite the blob on disk with a longer payload while
401        // leaving the manifest's recorded size unchanged.
402        let blob_path =
403            layout::output_blob_path(cache.cache_root(), &key, &manifest.outputs[0].content_hash);
404        cache
405            .fs()
406            .write_file(&blob_path, b"a-much-longer-payload")
407            .unwrap();
408        assert!(cache.lookup(&key).is_none());
409    }
410
411    // ---- 8 & 9. stdout missing / size mismatch ----
412
413    #[test]
414    fn cache_016_miss_when_stdout_missing() {
415        let (cache, key) = fresh_cache(HashAlgo::Blake3);
416        write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
417        // Remove the stdout file by overwriting with a directory
418        // would be invalid; instead remove via remove_dir_all of
419        // its parent and rewrite everything except stdout. The
420        // simpler path is to write a "directory" by avoiding the
421        // file. The fs has no remove_file; the entry directory
422        // can be torn down and rebuilt without stdout.
423        cache
424            .fs()
425            .remove_dir_all(&layout::entry_dir(cache.cache_root(), &key))
426            .unwrap();
427        // Rewrite the entry without stdout.
428        let stderr_bytes = b"stderr-body".to_vec();
429        let blob_bytes = b"blob-body".to_vec();
430        let content_hash = hash_bytes(HashAlgo::Blake3, &blob_bytes);
431        let manifest = Manifest {
432            chapter_revision: CHAPTER_REVISION,
433            hash_function: HashFunctionLabel::Blake3,
434            key,
435            outputs: vec![OutputBlob {
436                workspace_absolute_path: cp("/proj/out"),
437                content_hash,
438                #[allow(clippy::cast_possible_truncation)]
439                size: blob_bytes.len() as u64,
440                mode: 0o644,
441            }],
442            stdout_len: 11,
443            #[allow(clippy::cast_possible_truncation)]
444            stderr_len: stderr_bytes.len() as u64,
445            stdout_hash: [0u8; 32],
446            stderr_hash: hash_bytes(HashAlgo::Blake3, &stderr_bytes),
447            exit_status: 0,
448            created_at_unix: 0,
449        };
450        cache
451            .fs()
452            .create_dir_all(&layout::outputs_dir(cache.cache_root(), &key))
453            .unwrap();
454        cache
455            .fs()
456            .write_file(
457                &layout::manifest_path(cache.cache_root(), &key),
458                &manifest.to_json_bytes(),
459            )
460            .unwrap();
461        cache
462            .fs()
463            .write_file(
464                &layout::stderr_path(cache.cache_root(), &key),
465                &stderr_bytes,
466            )
467            .unwrap();
468        cache
469            .fs()
470            .write_file(
471                &layout::output_blob_path(cache.cache_root(), &key, &content_hash),
472                &blob_bytes,
473            )
474            .unwrap();
475        assert!(cache.lookup(&key).is_none());
476    }
477
478    #[test]
479    fn cache_016_miss_when_stdout_size_mismatch() {
480        let (cache, key) = fresh_cache(HashAlgo::Blake3);
481        write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
482        // Overwrite the stdout file with bytes of a different
483        // length than recorded in the manifest.
484        cache
485            .fs()
486            .write_file(
487                &layout::stdout_path(cache.cache_root(), &key),
488                b"different-length-stdout-payload",
489            )
490            .unwrap();
491        assert!(cache.lookup(&key).is_none());
492    }
493
494    // ---- 10 & 11. stderr missing / size mismatch ----
495
496    #[test]
497    fn cache_016_miss_when_stderr_missing() {
498        let (cache, key) = fresh_cache(HashAlgo::Blake3);
499        write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
500        cache
501            .fs()
502            .remove_dir_all(&layout::entry_dir(cache.cache_root(), &key))
503            .unwrap();
504        let stdout_bytes = b"stdout-body".to_vec();
505        let blob_bytes = b"blob-body".to_vec();
506        let content_hash = hash_bytes(HashAlgo::Blake3, &blob_bytes);
507        let manifest = Manifest {
508            chapter_revision: CHAPTER_REVISION,
509            hash_function: HashFunctionLabel::Blake3,
510            key,
511            outputs: vec![OutputBlob {
512                workspace_absolute_path: cp("/proj/out"),
513                content_hash,
514                #[allow(clippy::cast_possible_truncation)]
515                size: blob_bytes.len() as u64,
516                mode: 0o644,
517            }],
518            #[allow(clippy::cast_possible_truncation)]
519            stdout_len: stdout_bytes.len() as u64,
520            stderr_len: 11,
521            stdout_hash: hash_bytes(HashAlgo::Blake3, &stdout_bytes),
522            stderr_hash: [0u8; 32],
523            exit_status: 0,
524            created_at_unix: 0,
525        };
526        cache
527            .fs()
528            .create_dir_all(&layout::outputs_dir(cache.cache_root(), &key))
529            .unwrap();
530        cache
531            .fs()
532            .write_file(
533                &layout::manifest_path(cache.cache_root(), &key),
534                &manifest.to_json_bytes(),
535            )
536            .unwrap();
537        cache
538            .fs()
539            .write_file(
540                &layout::stdout_path(cache.cache_root(), &key),
541                &stdout_bytes,
542            )
543            .unwrap();
544        cache
545            .fs()
546            .write_file(
547                &layout::output_blob_path(cache.cache_root(), &key, &content_hash),
548                &blob_bytes,
549            )
550            .unwrap();
551        assert!(cache.lookup(&key).is_none());
552    }
553
554    #[test]
555    fn cache_016_miss_when_stderr_size_mismatch() {
556        let (cache, key) = fresh_cache(HashAlgo::Blake3);
557        write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
558        cache
559            .fs()
560            .write_file(
561                &layout::stderr_path(cache.cache_root(), &key),
562                b"different-length-stderr-payload",
563            )
564            .unwrap();
565        assert!(cache.lookup(&key).is_none());
566    }
567
568    // ---- 12. partial tmp dir on the same shard does not perturb other keys ----
569
570    // ---- lookup_status: AUX-015 step 11 discrimination ----
571
572    use crate::lookup::CacheLookupStatus;
573
574    #[test]
575    fn lookup_status_returns_hit_for_valid_entry() {
576        let (cache, key) = fresh_cache(HashAlgo::Blake3);
577        let expected = write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
578        match cache.lookup_status(&key).unwrap() {
579            CacheLookupStatus::Hit(m) => assert_eq!(m, expected),
580            other => panic!("expected Hit, got {other:?}"),
581        }
582    }
583
584    #[test]
585    fn lookup_status_returns_miss_no_entry_for_absent_manifest() {
586        let (cache, key) = fresh_cache(HashAlgo::Blake3);
587        assert_eq!(
588            cache.lookup_status(&key).unwrap(),
589            CacheLookupStatus::MissNoEntry,
590        );
591    }
592
593    #[test]
594    fn lookup_status_returns_miss_corrupt_entry_for_unparseable_manifest() {
595        let (cache, key) = fresh_cache(HashAlgo::Blake3);
596        cache
597            .fs()
598            .create_dir_all(&layout::entry_dir(cache.cache_root(), &key))
599            .unwrap();
600        cache
601            .fs()
602            .write_file(
603                &layout::manifest_path(cache.cache_root(), &key),
604                b"not json",
605            )
606            .unwrap();
607        assert_eq!(
608            cache.lookup_status(&key).unwrap(),
609            CacheLookupStatus::MissCorruptEntry,
610        );
611    }
612
613    #[test]
614    fn lookup_status_returns_miss_schema_mismatch_for_chapter_revision() {
615        let (cache, key) = fresh_cache(HashAlgo::Blake3);
616        let mut manifest =
617            write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
618        manifest.chapter_revision = CHAPTER_REVISION.saturating_add(1);
619        cache
620            .fs()
621            .write_file(
622                &layout::manifest_path(cache.cache_root(), &key),
623                &manifest.to_json_bytes(),
624            )
625            .unwrap();
626        assert_eq!(
627            cache.lookup_status(&key).unwrap(),
628            CacheLookupStatus::MissSchemaMismatch,
629        );
630    }
631
632    #[test]
633    fn lookup_status_returns_miss_schema_mismatch_for_hash_function() {
634        // Cache configured Blake3; entry written with Sha256.
635        let (cache, key) = fresh_cache(HashAlgo::Blake3);
636        write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Sha256);
637        assert_eq!(
638            cache.lookup_status(&key).unwrap(),
639            CacheLookupStatus::MissSchemaMismatch,
640        );
641    }
642
643    #[test]
644    fn lookup_status_returns_miss_corrupt_entry_for_missing_blob() {
645        let (cache, key) = fresh_cache(HashAlgo::Blake3);
646        let manifest = write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
647        // Re-write the manifest with a content-hash that has no
648        // backing blob file.
649        let mut tampered = manifest;
650        tampered.outputs[0].content_hash = [0x99u8; 32];
651        cache
652            .fs()
653            .write_file(
654                &layout::manifest_path(cache.cache_root(), &key),
655                &tampered.to_json_bytes(),
656            )
657            .unwrap();
658        assert_eq!(
659            cache.lookup_status(&key).unwrap(),
660            CacheLookupStatus::MissCorruptEntry,
661        );
662    }
663
664    #[test]
665    fn lookup_status_returns_miss_corrupt_entry_for_blob_size_mismatch() {
666        let (cache, key) = fresh_cache(HashAlgo::Blake3);
667        let manifest = write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
668        let blob_path =
669            layout::output_blob_path(cache.cache_root(), &key, &manifest.outputs[0].content_hash);
670        cache
671            .fs()
672            .write_file(&blob_path, b"different-payload-length")
673            .unwrap();
674        assert_eq!(
675            cache.lookup_status(&key).unwrap(),
676            CacheLookupStatus::MissCorruptEntry,
677        );
678    }
679
680    #[test]
681    fn lookup_status_returns_miss_corrupt_entry_for_stdout_size_mismatch() {
682        let (cache, key) = fresh_cache(HashAlgo::Blake3);
683        write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
684        cache
685            .fs()
686            .write_file(
687                &layout::stdout_path(cache.cache_root(), &key),
688                b"different-length-stdout-payload",
689            )
690            .unwrap();
691        assert_eq!(
692            cache.lookup_status(&key).unwrap(),
693            CacheLookupStatus::MissCorruptEntry,
694        );
695    }
696
697    #[test]
698    fn lookup_status_returns_miss_corrupt_entry_for_stderr_size_mismatch() {
699        let (cache, key) = fresh_cache(HashAlgo::Blake3);
700        write_valid_entry(cache.fs(), cache.cache_root(), &key, HashAlgo::Blake3);
701        cache
702            .fs()
703            .write_file(
704                &layout::stderr_path(cache.cache_root(), &key),
705                b"different-length-stderr-payload",
706            )
707            .unwrap();
708        assert_eq!(
709            cache.lookup_status(&key).unwrap(),
710            CacheLookupStatus::MissCorruptEntry,
711        );
712    }
713
714    // ---- 12. partial tmp dir on the same shard does not perturb other keys ----
715
716    #[test]
717    fn partial_tmp_dir_does_not_affect_other_keys() {
718        let (cache, key_a) = fresh_cache(HashAlgo::Blake3);
719        // Build a second key on the same shard as key_a (same
720        // first byte, different tail).
721        let mut b_bytes = [0u8; 32];
722        b_bytes[0] = 0xAB;
723        b_bytes[31] = 0xFF;
724        let key_b = CacheKey::from_bytes(b_bytes);
725        assert_eq!(layout::shard(&key_a), layout::shard(&key_b));
726
727        // Land a complete valid entry for A.
728        write_valid_entry(cache.fs(), cache.cache_root(), &key_a, HashAlgo::Blake3);
729
730        // Drop a partial tmp directory for B on the same shard.
731        // Its mere presence must not affect A's lookup.
732        let tmp = layout::tmp_entry_dir(cache.cache_root(), &key_b, "rnd123");
733        cache.fs().create_dir_all(&tmp).unwrap();
734        cache
735            .fs()
736            .write_file(&tmp.join("manifest.json"), b"partial junk")
737            .unwrap();
738
739        assert!(cache.lookup(&key_a).is_some());
740        // And B itself is a miss: there is no final entry yet.
741        assert!(cache.lookup(&key_b).is_none());
742    }
743}