1use 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#[derive(Debug, Snafu)]
43pub enum CacheLookupError {
44 #[snafu(display("filesystem error during cache lookup: {source}"))]
47 Io {
48 source: FsError,
50 },
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum CacheLookupStatus {
57 Hit(Manifest),
62 MissNoEntry,
64 MissSchemaMismatch,
68 MissCorruptEntry,
74}
75
76impl<Fs: Filesystem> CacheReader<Fs> {
77 #[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 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 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 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 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 #[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 #[test]
336 fn cache_016_miss_when_manifest_absent() {
337 let (cache, key) = fresh_cache(HashAlgo::Blake3);
338 assert!(cache.lookup(&key).is_none());
340 }
341
342 #[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 #[test]
364 fn cache_016_miss_when_hash_function_mismatches() {
365 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 #[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 #[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 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 #[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 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 #[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 cache
439 .fs()
440 .remove_dir_all(&layout::entry_dir(cache.cache_root(), &key))
441 .unwrap();
442 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 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 #[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 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 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 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 #[test]
732 fn partial_tmp_dir_does_not_affect_other_keys() {
733 let (cache, key_a) = fresh_cache(HashAlgo::Blake3);
734 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 write_valid_entry(cache.fs(), cache.cache_root(), &key_a, HashAlgo::Blake3);
744
745 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 assert!(cache.lookup(&key_b).is_none());
757 }
758}